From f426fb7902837d95286ea98207ef39afb038106c Mon Sep 17 00:00:00 2001 From: Ragdoll-Opus-5 Date: Mon, 21 Sep 2026 18:53:56 +0800 Subject: [PATCH 001/202] feat(F117): register the unified message delivery lifecycle Every connector input used to reach the user through a two-phase shape: append a Message, then separately enqueue its Queue row. Nothing made the pair atomic, so a crash or a rejection between the halves left a message nobody was asked to look at, or a queued row with no message behind it. This registers the target contract before any producer moves onto it: one atomic Message+Queue admission per input, the closed set of producers that must migrate, and the replay cutover for rows written under the old shape. Registering the set up front is what makes Phase I closeable -- the alternative is discovering producer #7 by grep after declaring victory. Co-Authored-By: Claude Opus 5 --- .../d12-recent-speaker.md" | 4 + .../hook.yaml" | 8 +- .../d12-active-participants.md" | 2 - .../d12-active-participant.md | 5 - assets/prompt-templates/d12-recent-speaker.md | 5 + docs/ROADMAP.md | 4 +- docs/architecture/a2a-protocol.md | 22 +- .../architecture/at-mention-routing-system.md | 71 +- .../f117-latest-main-replay-ledger.md | 411 + ...message-delivery-handling-handoff-audit.md | 600 +- docs/architecture/ownership/README.md | 8 +- .../ownership/cells/ball-custody.md | 32 +- .../ownership/cells/bubble-pipeline.md | 111 +- docs/architecture/ownership/cells/dispatch.md | 247 +- .../043-queue-durable-single-ledger.md | 423 + docs/features/F039-message-queue-delivery.md | 9 +- docs/features/F047-queue-steer.md | 79 +- .../F086-cat-orchestration-multi-mention.md | 18 +- .../F117-message-delivery-lifecycle.md | 536 +- docs/features/F122-unified-dispatch-queue.md | 10 + docs/features/F175-unified-message-queue.md | 15 +- docs/features/F220-a2a-collab-reliability.md | 20 +- .../F233-ball-custody-observability.md | 8 +- docs/features/F244-capability-tips-system.md | 21 +- .../F254-side-effect-freshness-gate.md | 49 +- .../F295-cancelable-execution-projection.md | 31 +- docs/features/index.json | 8 +- .../api/config/public-test-exclusions.json | 10 +- packages/api/package.json | 3 +- packages/api/src/config/cat-config-loader.ts | 76 +- packages/api/src/config/codex-cli.ts | 28 - packages/api/src/config/env-registry.ts | 14 +- .../api/src/config/runtime-cat-catalog.ts | 11 + .../api/src/config/user-preferences-store.ts | 10 +- .../domains/approval-hub/ApprovalIngress.ts | 2 +- .../ProactiveRelationshipService.ts | 7 +- .../A2ADispatchDispositionService.ts | 394 - .../A2ADispatchReplacementResolver.ts | 210 - ...ionSuccessorProjectionRetirementService.ts | 32 +- .../ball-custody/BallCustodyProbeScheduler.ts | 2 +- .../ball-custody/BallCustodyWakeSender.ts | 2 +- .../CoordinationTerminalRetirement.ts | 99 - .../DirectActionSuccessorCarrierRecovery.ts | 92 +- .../ManagedCommandWakeRecoveryEngine.ts | 340 + .../ManagedCommandWakeRecoverySweep.ts | 347 +- .../ManagedHoldDispositionService.ts | 314 - .../ball-custody/ManagedHoldReceiptService.ts | 157 - .../ManagedHoldSourceSelection.ts | 164 - .../RetiredManagedCommandTerminalRecovery.ts | 12 +- .../TurnCustodyAdoptionRegistry.ts | 132 - .../TurnCustodyProjectionService.ts | 182 +- .../ball-custody/TypedWaitCustodyGuard.ts | 79 - .../WaitContinuationRetryCommitter.ts | 318 - .../WaitContinuationRetryPreflight.ts | 151 - .../approved-action-carrier-classification.ts | 57 +- .../managed-command-wake-carrier-adapter.ts | 55 + .../managed-command-wake-lifecycle.ts | 96 +- .../managed-command-wake-message-fence.ts | 35 +- .../managed-command-wake-queue-adapter.ts | 83 - .../managed-command-wake-recovery-policy.ts | 78 +- .../managed-command-wake-task-projection.ts | 15 +- .../ball-custody/managed-hold-supersession.ts | 175 - .../record-managed-hold-disposition.ts | 63 - .../turn-custody-projection-types.ts | 25 +- .../turn-custody-wake-provenance.ts | 80 +- .../ball-custody/wait-continuation-carrier.ts | 39 +- .../CallerDispatchObservationIndex.ts | 85 + .../CallerDispatchObservationRegistry.ts | 289 + .../CallerDispatchProcessStartRegistry.ts | 60 + .../invocation/InvocationOwnerReaper.ts | 4 +- .../agents/invocation/InvocationQueue.ts | 3418 +++--- .../agents/invocation/InvocationTracker.ts | 168 +- .../invocation/MessageDeliveryService.ts | 60 +- .../PerCatTerminalDispositionCollector.ts | 59 +- .../invocation/PersistedQueueCarrier.ts | 136 +- .../invocation/PersistedQueueDelivery.ts | 179 +- .../QueueCarrierSourceProjection.ts | 107 - .../agents/invocation/QueueProcessor.ts | 5241 +++++---- ...QueuedMessageCustodyA2ARestartPreflight.ts | 99 - .../QueuedMessageCustodyCarrierProjection.ts | 44 - .../QueuedMessageCustodyCoordinator.ts | 1940 ---- .../QueuedMessageCustodyRestartProjector.ts | 130 - .../QueuedMessageCustodyRestartTargets.ts | 199 - .../QueuedMessageCustodyRestartWitness.ts | 45 - ...uedMessageCustodyRuntimeRestartAttempts.ts | 62 - ...dMessageCustodyStartupMessageReconciler.ts | 139 - ...dMessageCustodyStartupProjectionHelpers.ts | 47 - .../QueuedMessageCustodyStartupQueueEntry.ts | 167 - .../QueuedMessageCustodyStartupReconciler.ts | 221 - .../QueuedMessageCustodyStartupTypes.ts | 49 - .../agents/invocation/StartupReconciler.ts | 216 +- .../caller-dispatch-observation-model.ts | 61 + .../caller-dispatch-observation-projector.ts | 61 + .../caller-dispatch-observation-reader.ts | 171 + .../agents/invocation/convergeZombieQueue.ts | 87 - .../agents/invocation/invoke-single-cat.ts | 66 +- .../invocation/lifecycle-append-projection.ts | 120 + .../invocation/live-invocation-projection.ts | 29 +- .../invocation/message-lifecycle-kernel.ts | 169 + .../message-lifecycle-queue-order.ts | 24 + .../message-lifecycle-validation.ts | 145 + .../invocation/queue-entry-settlement.ts | 50 - .../queue-ledger/InMemoryQueueLedgerCommit.ts | 140 + .../queue-ledger/InMemoryQueueLedgerStore.ts | 329 + .../invocation/queue-ledger/QueueLedger.ts | 224 + .../queue-ledger/QueueLedgerAdmission.ts | 99 + .../queue-ledger/QueueLedgerValidation.ts | 145 + .../queue-ledger/RedisQueueLedgerCodec.ts | 166 + .../queue-ledger/RedisQueueLedgerReader.ts | 94 + .../queue-ledger/RedisQueueLedgerStore.ts | 347 + .../queue-ledger/queue-ledger-keys.ts | 11 + .../queue-ledger-redis-scripts.ts | 338 + .../queue-ledger-redis-transition-scripts.ts | 257 + .../invocation/queue-liveness-diagnostics.ts | 60 +- .../queue-prestart-group-retirement.ts | 77 +- .../agents/invocation/reconcileZombies.ts | 66 +- .../invocation/thread-execution-situation.ts | 91 + .../agents/providers/ClaudeAgentService.ts | 191 +- .../providers/ClaudeBgCarrierService.ts | 4 +- .../agents/providers/ClaudeSdkAgentService.ts | 340 + .../agents/providers/CodexAgentService.ts | 137 +- .../agents/providers/CodexAppServerClient.ts | 72 +- .../agents/providers/CodexAppServerRunner.ts | 102 +- .../providers/CodexUnixWebSocketSession.ts | 4 +- .../providers/DirectAgentCarrierSession.ts | 3 +- .../agents/providers/KimiAgentService.ts | 7 +- .../agents/providers/acp/AcpAgentService.ts | 1 + .../antigravity/AntigravityAgentService.ts | 1 + .../antigravity-event-transformer.ts | 1 + .../providers/claude-carrier-factory.ts | 1 + .../agents/providers/claude-mcp-config.ts | 152 + .../providers/claude-sdk-turn-input-state.ts | 176 + .../agents/providers/codex-event-transform.ts | 50 +- .../services/agents/routing/AgentRouter.ts | 300 +- .../agents/routing/WorklistRegistry.ts | 213 +- .../agents/routing/a2a-handoff-label.ts | 33 +- .../cloud-outbound-receipt-provenance.ts | 15 +- .../agents/routing/durable-a2a-lineage.ts | 84 + .../agents/routing/final-routing-slot.ts | 2 +- .../agents/routing/format-briefing.ts | 2 +- .../guards/event-backed-routing-exit.ts | 60 + .../agents/routing/navigation-context.ts | 37 + .../routing/persist-system-info-warnings.ts | 168 +- .../services/agents/routing/route-helpers.ts | 268 +- .../services/agents/routing/route-parallel.ts | 276 +- .../services/agents/routing/route-serial.ts | 1885 +--- .../agents/routing/routing-decision.ts | 118 - .../routing/routing-preflight-notice.ts | 2 +- .../agents/routing/void-hold-detect.ts | 2 +- .../cloud-assistant-return-ingest.ts | 10 +- .../cats/services/context/ContextAssembler.ts | 41 +- .../context/MessageBundlePromptResolver.ts | 13 +- .../context/MessageBundleSourceGroup.ts | 9 +- .../context/MessageBundleSourceProjection.ts | 26 +- .../context/MessageSelectionResolver.ts | 1 - .../context/message-selection-results.ts | 8 +- .../context/message-selection-types.ts | 6 +- .../context/prompt-template-loader.ts | 2 +- .../duty-briefing/briefing-delivery.ts | 2 +- .../freshness/FreshnessNoticeBroker.ts | 2 +- .../freshness/FreshnessNoticeService.ts | 6 +- .../freshness/FreshnessRelevancePolicy.ts | 3 +- .../freshness/FreshnessSourcePolicy.ts | 22 +- .../services/freshness/ThreadUnseenChecker.ts | 10 +- .../freshness/checkFreshnessForPostMessage.ts | 51 +- .../freshness/checkStreamOutputFreshness.ts | 23 +- .../FreshnessClosureStartupReconciler.ts | 4 +- .../freshness/createFreshnessReinvokeCheck.ts | 3 +- .../FreshnessOutputCommitCoordinator.ts | 51 +- .../FreshnessSupplementStartupReconciler.ts | 14 +- .../glass-box/freshness-output-contract.ts | 12 +- .../frustration/FrustrationDetector.ts | 16 +- .../cats/services/game/GameOrchestrator.ts | 2 +- .../cats/services/game/gameSystemMessage.ts | 3 +- .../domains/cats/services/game/wakeCatImpl.ts | 11 +- .../api/src/domains/cats/services/index.ts | 1 + .../push/decision-notification-policy.ts | 15 + .../session/BoundSessionHistoryImporter.ts | 2 +- .../session/InvocationPromptInputProjector.ts | 5 + .../cats/services/stores/message-from.ts | 55 + .../services/stores/ports/MessageStore.ts | 1752 ++- .../stores/ports/queued-message-custody.ts | 1369 --- .../stores/ports/queued-message-receipt.ts | 272 - .../stores/redis-keys/message-keys.ts | 4 +- .../stores/redis/RedisMessageStore.ts | 1928 +++- .../stores/redis/RedisThreadReadStateStore.ts | 8 +- .../redis/RedisTypedWaitCustodyGuard.ts | 99 - .../stores/redis/redis-message-append.ts | 23 +- .../redis-message-delivery-lua-scripts.ts | 30 +- .../redis/redis-message-frontier-append.ts | 72 +- .../stores/redis/redis-message-parsers.ts | 137 +- .../redis/redis-unread-summary-projection.ts | 38 +- .../redis/redis-visibility-lua-scripts.ts | 184 +- .../cats/services/stores/visibility.ts | 141 +- .../api/src/domains/cats/services/types.ts | 66 +- .../growing/ArtifactReviewReturnDispatcher.ts | 6 +- .../src/domains/leaderboard/mention-stats.ts | 5 +- .../src/domains/leaderboard/silly-stats.ts | 2 +- .../domains/limb/LimbTranscriptCatDelivery.ts | 24 +- .../DeferredPersonMemoryDailyTaskSpec.ts | 2 +- .../src/domains/memory/InputEntityDetector.ts | 9 + .../ProactiveMemoryCandidateDetector.ts | 4 +- .../memory/ThreadAwareSignalSources.ts | 14 +- .../memory/cue/MemoryCueTrustedConnector.ts | 16 +- .../cue/sources/PersonMemoryCueSource.ts | 4 +- .../api/src/domains/memory/event-backfill.ts | 5 +- .../people/AsrPersonMemoryReentryCarrier.ts | 6 +- .../PersonMemorySourceBundleResolver.ts | 4 +- .../api/src/domains/messaging/envelope.ts | 22 +- .../api/src/domains/messaging/send-service.ts | 2 +- .../src/domains/messaging/snapshot-capture.ts | 1 - .../collective-ingress-dispatcher.ts | 68 +- .../collective-work-authority.ts | 2 +- .../collective-work-dispatcher.ts | 54 +- .../resolvers/turn-resolvers-b.ts | 12 +- .../RoutingDispatchPreflightPort.ts | 7 + .../RuntimeInteractionCardPublisher.ts | 2 +- .../AsrPersonMemoryQueueCarrier.ts | 3 +- .../ThreadMeetingArtifactDispatcher.ts | 228 +- .../signals/services/podcast-generator.ts | 229 +- .../terminal/tmux-agent-carrier-session.ts | 8 +- packages/api/src/index.ts | 411 +- .../program-preparation-materialization.ts | 2 +- .../connectors/ConnectorCommandLayer.ts | 23 +- .../connectors/ConnectorRouter.ts | 56 +- .../connectors/connector-gateway-bootstrap.ts | 9 +- .../email/ConnectorInvokeTrigger.ts | 1402 +-- .../email/IssueCommentTaskSpec.ts | 2 +- .../email/deliver-connector-message.ts | 21 +- .../c2-void-hold-sample-evidence.ts | 2 +- .../domain/eval-domain-trigger-dispatch.ts | 2 +- .../freshness/freshness-replay-provider.ts | 101 +- .../freshness-windowed-signal-report.ts | 217 +- .../harness-eval/friction/paw-feel-source.ts | 17 +- .../manual-trigger/trigger-now.ts | 2 +- .../paw-feel-disposition/hot-intake.ts | 19 +- .../harness-eval/reeval-case-task-dispatch.ts | 2 +- .../infrastructure/scheduler/TaskRunnerV2.ts | 50 +- .../src/infrastructure/scheduler/delivery.ts | 9 +- .../scheduler/execute-pipeline.ts | 10 + .../scheduler/templates/reminder.ts | 92 +- .../scheduler/templates/web-digest.ts | 2 +- .../api/src/infrastructure/scheduler/types.ts | 15 +- .../infrastructure/telemetry/genai-semconv.ts | 1 - .../infrastructure/telemetry/instruments.ts | 17 +- .../infrastructure/websocket/SocketManager.ts | 29 +- .../api/src/routes/a2a-routing-projection.ts | 133 - .../api/src/routes/active-execution-routes.ts | 144 +- packages/api/src/routes/backlog.ts | 2 +- .../api/src/routes/callback-a2a-trigger.ts | 1808 ++-- .../routes/callback-auth-system-message.ts | 2 +- .../callback-hold-ball-cancel-routes.ts | 31 +- .../src/routes/callback-hold-ball-routes.ts | 448 +- .../api/src/routes/callback-limb-routes.ts | 8 +- .../routes/callback-multi-mention-routes.ts | 828 +- .../src/routes/callback-typed-wait-source.ts | 53 +- packages/api/src/routes/callbacks.ts | 2004 ++-- packages/api/src/routes/cats.ts | 104 +- .../routes/composer-draft-recall-handler.ts | 159 +- .../routes/composer-draft-recall-helpers.ts | 25 - .../composer-draft-recall-projection.ts | 8 +- .../api/src/routes/composer-draft-recall.ts | 10 +- packages/api/src/routes/concierge.ts | 2 - .../src/routes/config-message-disposition.ts | 1 - .../src/routes/cross-thread-coordination.ts | 23 + packages/api/src/routes/export.ts | 13 +- .../routes/hold-ball-terminal-visibility.ts | 75 + packages/api/src/routes/invocations.ts | 404 +- packages/api/src/routes/message-bundles.ts | 3 +- .../routes/message-disposition-admission.ts | 20 +- packages/api/src/routes/messages.schema.ts | 6 +- packages/api/src/routes/messages.ts | 2379 +--- .../native-realtime-companion-lifecycle.ts | 2 +- .../routes/native-thread-goal-projection.ts | 2 +- .../routes/native-thread-review-execution.ts | 2 +- packages/api/src/routes/parse-multipart.ts | 7 +- .../person-memory-proposal-source-contract.ts | 7 +- .../src/routes/prompt-injection-preview.ts | 2 +- .../src/routes/proposal-approve-dispatch.ts | 8 +- .../src/routes/proposal-approve-reconcile.ts | 14 +- .../api/src/routes/proposal-route-options.ts | 2 +- .../api/src/routes/proposal-seed-admission.ts | 173 +- packages/api/src/routes/queue.ts | 1644 ++- .../routes/session-handoff-approve-routes.ts | 7 +- .../api/src/routes/signal-podcast-routes.ts | 15 +- packages/api/src/routes/thread-branch.ts | 19 +- packages/api/src/routes/thread-cats.ts | 11 + .../api/src/routes/thread-context-envelope.ts | 5 + packages/api/src/routes/threads.ts | 9 +- packages/api/src/routes/votes.ts | 6 +- .../f254-withheld-message-recovery/core.ts | 2 +- .../scripts/f282-proactive-memory-replay.ts | 6 +- packages/api/src/utils/cli-diagnostics.ts | 20 +- packages/api/src/utils/queue-enrichment.ts | 488 +- packages/api/src/utils/sanitize-cli-stderr.ts | 17 + packages/api/test/a2a-coalesce.test.js | 733 -- ...2a-dispatch-disposition-contention.test.js | 164 - ...tch-disposition-invocation-binding.test.js | 187 - .../a2a-dispatch-disposition-same-cat.test.js | 57 - .../api/test/a2a-dispatch-disposition.test.js | 450 - ...2a-dispatch-replacement-enrichment.test.js | 70 - .../api/test/a2a-invariant-holders.test.js | 131 - packages/api/test/a2a-late-push-probe.test.js | 206 - .../test/a2a-parallel-fanout-custody.test.js | 737 +- .../api/test/a2a-routing-mode-honesty.test.js | 375 - packages/api/test/a2a-routing-persist.test.js | 287 - packages/api/test/a2a-settle-holder.test.js | 109 +- .../action-successor-carrier-recovery.test.js | 179 +- .../action-successor-index-wiring.test.js | 11 +- ...on-successor-projection-retirement.test.js | 49 +- .../test/action-successor-queue-fence.test.js | 285 +- packages/api/test/agent-router.test.js | 215 +- .../approval-ingress-append-recovery.test.js | 19 +- .../approval-hub/approval-ingress-fixtures.js | 21 +- .../approval-hub/approval-ingress.test.js | 19 +- .../dispatch-action-recovery.test.js | 230 +- .../dispatch-canonical-admission-gate.test.js | 2 +- ...h-canonical-admission-replay-redis.test.js | 2 +- ...patch-negative-authorization-fence.test.js | 7 +- .../scheduler-origin-callback-fixtures.js | 25 +- .../wave2-dispatch-ingress-failure.test.js | 19 +- .../wave2-origin-recovery.test.js | 37 +- .../api/test/auto-reply-to-worklist.test.js | 73 +- packages/api/test/auto-reply-to.test.js | 91 +- .../api/test/b1c-3-route-plumbing.test.js | 16 +- .../api/test/ball-custody-hold-events.test.js | 28 +- .../test/ball-custody-ingest-redis.test.js | 151 - .../api/test/callback-a2a-pingpong.test.js | 487 - .../api/test/callback-a2a-postmsg.test.js | 221 +- .../api/test/callback-a2a-trigger.test.js | 4176 +------ .../test/callback-auth-system-message.test.js | 2 +- ...llback-complete-managed-hold-route.test.js | 264 - .../callback-cross-post-fail-closed.test.js | 6 + packages/api/test/callback-delivery.test.js | 104 +- .../callback-f177h-participant-check.test.js | 6 + .../api/test/callback-f193-boundary.test.js | 26 +- ...allback-hold-ball-route-scheduling.test.js | 156 + .../api/test/callback-hold-ball-route.test.js | 17 + .../test/callback-hold-ball-wakewhen.test.js | 497 +- ...back-propose-profile-update-origin.test.js | 19 +- ...back-propose-profile-update-routes.test.js | 63 +- ...ropose-profile-update-socket-event.test.js | 19 +- .../test/callback-routes-agent-key.test.js | 45 +- packages/api/test/callback-routes.test.js | 1079 +- packages/api/test/callback-start-vote.test.js | 122 +- packages/api/test/callbacks-f182-c.test.js | 37 +- ...ller-dispatch-observation-registry.test.js | 326 + ...dispatch-observation-rich-terminal.test.js | 93 + ...ler-dispatch-observation-selection.test.js | 190 + packages/api/test/cancel-broadcast.test.js | 28 +- packages/api/test/cancel-scope-guard.test.js | 3 +- ...lity-evolution-preparation-message.test.js | 5 +- ...ity-evolution-preparation-recovery.test.js | 30 +- packages/api/test/cat-config-loader.test.js | 79 +- .../test/cats-routes-runtime-catalog.test.js | 38 + .../api/test/cats-routes-runtime-crud.test.js | 91 +- packages/api/test/cicd-router.test.js | 10 +- packages/api/test/claude-bg-carrier.test.js | 21 + .../api/test/cloud-delivery-retry.test.js | 134 + packages/api/test/codex-agent-service.test.js | 70 +- .../test/codex-app-server-interaction.test.js | 35 + .../api/test/codex-app-server-pooling.test.js | 78 +- .../test/codex-app-server-transport.test.js | 250 +- packages/api/test/codex-carrier-truth.test.js | 51 +- .../api/test/codex-event-transform.test.js | 79 + .../test/codex-unix-websocket-session.test.js | 26 + .../collective-ingress-dispatcher.test.js | 50 +- .../collective-owner-participation.test.js | 3 +- .../collective-persistent-authority.test.js | 46 +- .../test/collective-queue-recovery.test.js | 63 - .../api/test/collective-work-return.test.js | 5 +- packages/api/test/commands-route.test.js | 43 +- packages/api/test/concierge-a3b-route.test.js | 37 +- .../api/test/concurrent-fault-drill.test.js | 69 +- packages/api/test/conflict-router.test.js | 4 +- .../api/test/connector-invoke-trigger.test.js | 3431 +----- packages/api/test/connector-router.test.js | 32 +- packages/api/test/context-assembler.test.js | 52 + ...ation-terminal-dispatch-retirement.test.js | 272 - .../cross-thread-action-successor.test.js | 142 +- .../cross-thread-coordination-chain.test.js | 139 +- .../cross-thread-coordination-ingress.test.js | 13 +- .../test/cross-thread-review-affinity.test.js | 43 + packages/api/test/cursor-ack-on-abort.test.js | 67 +- packages/api/test/cursor-deferred-ack.test.js | 164 - .../api/test/cursor-order-extended.test.js | 391 +- .../test/cursor-order-mention-parity.test.js | 195 +- .../api/test/cursor-order-mentions.test.js | 93 +- .../test/cursor-order-r8-regression.test.js | 226 +- .../api/test/cursor-order-remaining.test.js | 239 +- .../cursor-order-route-regression.test.js | 423 +- .../test/cursor-order-sol-remaining.test.js | 511 +- packages/api/test/cursor-v2-allocator.test.js | 79 +- .../test/cursor-v2-store-integration.test.js | 301 +- .../test/defer-person-memory-routes.test.js | 39 +- .../deferred-person-memory-daily-task.test.js | 2 +- packages/api/test/delivery-status.test.js | 361 +- ...direct-action-carrier-recovery-e2e.test.js | 181 +- .../api/test/draft-messages-merge.test.js | 281 +- .../api/test/duty-briefing-delivery.test.js | 2 +- packages/api/test/export-route.test.js | 25 +- .../f046-b5-runtime-regression-seed.test.js | 361 +- .../test/f148-assemble-incremental.test.js | 265 +- packages/api/test/f148-briefing-card.test.js | 2 +- .../api/test/f148-context-briefing.test.js | 2 +- .../api/test/f148-navigation-context.test.js | 65 + ...67-a2a-replacement-queue-preflight.test.js | 605 -- .../api/test/f168-phase-b-dual-cursor.test.js | 10 +- .../f194-canonical-liveness-queue.test.js | 1 + .../f194-canonical-liveness-routes.test.js | 1 + packages/api/test/f202-phase2-d.test.js | 12 +- .../api/test/f215-malformed-toolcall.test.js | 178 +- .../f232-thread-artifacts-aggregator.test.js | 62 +- .../f232-thread-artifacts-endpoint.test.js | 37 +- .../test/f232-thread-artifacts-redis.test.js | 97 +- ...47-browser-assistant-return-ingest.test.js | 70 +- .../f247-cloud-receipt-provenance.test.js | 139 +- .../f247-cloud-return-append-winner.test.js | 57 +- .../test/f247-cloud-return-contract.test.js | 209 +- .../test/f247-cloud-runtime-terminal.test.js | 71 +- ...7-fingerprint-bounds-product-chain.test.js | 21 +- .../test/f247-first-message-admission.test.js | 132 - .../test/f247-first-message-recovery.test.js | 81 - .../f247-owner-chrome-product-chain.test.js | 106 +- ...47-queued-cloud-receipt-provenance.test.js | 112 - .../f254-freshness-closure-routes.test.js | 63 +- .../f254-freshness-reinvoke-check.test.js | 6 +- .../f254-freshness-replay-provider.test.js | 59 +- ...shness-supplement-startup-recovery.test.js | 37 +- .../api/test/f254-hold-ball-reminder.test.js | 3 +- .../test/f254-incident-registry-v1-2.test.js | 23 +- packages/api/test/f254-notice-service.test.js | 2 + .../f254-output-commit-coordinator.test.js | 223 +- .../api/test/f254-output-commit-race.test.js | 9 +- .../f254-output-commit-redis-race.test.js | 54 +- .../test/f254-phase-e-closure-wiring.test.js | 42 +- .../f254-provider-native-freshness.test.js | 118 +- .../test/f254-queue-aware-freshness.test.js | 129 +- .../test/f254-queue-restart-custody.test.js | 2575 ----- ...queued-message-custody-coordinator.test.js | 983 -- .../f254-queued-message-custody-redis.test.js | 1162 -- .../f254-queued-message-custody-store.test.js | 156 - .../api/test/f254-runtime-descriptor.test.js | 2 +- .../test/f254-stream-output-freshness.test.js | 51 +- .../test/f254-windowed-signal-report.test.js | 126 +- ...54-withheld-message-recovery-redis.test.js | 37 +- .../f264-author-message-disposition.test.js | 86 +- packages/api/test/f264-queue-receipt.test.js | 966 -- .../f264-queue-withdrawal-startup.test.js | 61 - .../test/f264-true-recall-contract.test.js | 221 +- .../api/test/f264-true-recall-redis.test.js | 201 +- .../api/test/f264-true-recall-route.test.js | 332 +- packages/api/test/f272-first-jump-e2e.test.js | 19 +- packages/api/test/f272-proactive-echo.test.js | 21 +- ...f276-agent-router-authority-wiring.test.js | 25 +- .../test/f280-github-wait-lifecycle.test.js | 16 +- .../api/test/f280-issue-wait-contract.test.js | 6 +- .../test/f280-legacy-pr-surface-guard.test.js | 2 +- .../f295-active-execution-projection.test.js | 611 +- .../api/test/f296-a1-recall-pointer.test.js | 5 +- .../api/test/f296-a2-open-questions.test.js | 5 +- .../f296-a4-sparse-prompt-acceptance.test.js | 37 +- .../test/f296-b3b1-context-projection.test.js | 19 +- .../f296-b3b3-post-compact-projector.test.js | 37 +- ...f296-b3b3-session-hook-integration.test.js | 37 +- ...3b4-bootstrap-briefing-convergence.test.js | 33 +- ...c-alpha-uat-dynamic-classification.test.js | 2 +- .../f296-b4c-alpha-uat-runner-guards.test.js | 2 +- .../test/f296-b4c-alpha-uat-runner.test.js | 6 +- ...d-rollout-notice-persistence-redis.test.js | 28 +- ...ersized-rollout-notice-persistence.test.js | 66 +- .../f298-phase-b-reconciler-structure.test.js | 32 +- .../test/f299-invocation-trajectory.test.js | 129 +- .../api/test/f306-native-review-route.test.js | 150 +- packages/api/test/force-reset-thread.test.js | 58 +- .../api/test/game-narrator-driver.test.js | 5 +- packages/api/test/game-phase-h-fixes.test.js | 18 +- .../api/test/get-message-visibility.test.js | 514 +- .../test/growing/custody-offer-routes.test.js | 24 +- .../growing/custody-offer-service.test.js | 24 +- .../custody-opportunity-runtime.test.js | 5 +- .../entrusted-work-custody-guards.test.js | 20 +- .../growing/entrusted-work-lifecycle.test.js | 20 +- .../design-gate-threshold-trigger.test.js | 2 +- .../harness-eval/eval-domain-daily.test.js | 4 +- .../eval-domain-trigger-dispatch.test.js | 2 +- .../harness-eval/paw-feel-adapter.test.js | 166 +- .../paw-feel-disposition-capacity.test.js | 2 +- .../paw-feel-disposition-reconciler.test.js | 22 +- .../test/harness-eval/paw-feel-source.test.js | 19 +- ...publish-verdict-freshness-maturity.test.js | 33 +- .../publish-verdict-freshness.test.js | 7 +- .../reeval-case-task-dispatch.test.js | 22 +- packages/api/test/heartbeat.test.js | 24 +- .../a2a-dispatch-disposition-harness.js | 175 - .../helpers/artifact-review-live-fixture.ts | 2 +- .../api/test/helpers/dual-store-harness.js | 65 +- .../helpers/f247-first-message-harness.js | 234 - .../helpers/incremental-context-helpers.js | 3 +- .../test/helpers/issue1371-cursor-harness.js | 8 +- .../helpers/issue1371-cursor-proof-harness.js | 12 +- .../issue1371-direct-witness-harness.js | 342 - .../issue1371-terminal-queue-harness.js | 142 - .../api/test/helpers/message-from-fixtures.js | 125 + .../test/helpers/persisted-queue-fixture.ts | 47 +- .../api/test/helpers/proposal-test-harness.js | 41 +- .../test/helpers/queued-message-custody.js | 20 - .../helpers/session-handoff-route-fixture.js | 2 +- .../helpers/turn-custody-route-harness.js | 43 +- .../helpers/typed-wait-custody-fixture.js | 69 +- .../api/test/hold-ball-cancel-route.test.js | 46 +- packages/api/test/hold-ball-cancel.test.js | 4 +- .../test/hook-pipeline-integration.test.js | 22 + packages/api/test/hook-resolvers-turn.test.js | 4 +- packages/api/test/image-upload.test.js | 80 +- .../socket-cancel-invocation.test.js | 80 +- .../api/test/integration/a2a-chain.test.js | 296 - .../integration/cross-cat-context.test.js | 19 +- packages/api/test/integration/history.test.js | 83 +- .../test/integration/mcp-prompt-e2e.test.js | 37 +- .../test/integration/thread-wiring.test.js | 93 +- packages/api/test/invocation-queue.test.js | 2286 +--- packages/api/test/invocation-tracker.test.js | 135 + packages/api/test/invocations-retry.test.js | 748 -- .../invoke-single-cat-turn-execution.test.js | 21 +- .../issue1371-cursor-interruption.test.js | 10 +- .../api/test/issue1371-direct-witness.test.js | 13 - .../test/issue1371-parallel-cursor.test.js | 16 +- .../test/issue1371-prestart-recovery.test.js | 285 +- .../issue1371-queue-source-boundaries.test.js | 247 - .../test/issue1371-redis-convergence.test.js | 396 +- .../issue1371-retirement-recovery.test.js | 178 - ...ue1371-terminal-convergence-review.test.js | 177 - .../test/lifecycle-append-projection.test.js | 139 + .../test/lifecycle-response-context.test.js | 99 + .../test/limb-transcript-cat-delivery.test.js | 5 +- .../test/live-carrier-agent-services.test.js | 700 ++ .../test/live-invocation-projection.test.js | 86 + ...naged-command-wake-carrier-adapter.test.js | 102 + ...managed-command-wake-queue-adapter.test.js | 155 - ...anaged-command-wake-recovery-sweep.test.js | 318 +- .../managed-force-queue-autoexecute.test.js | 563 - .../api/test/managed-hold-disposition.test.js | 1549 --- ...anaged-hold-stale-terminal-closure.test.js | 645 -- packages/api/test/mark-all-read.test.js | 113 +- .../asr-defer-receipt-lifecycle-route.test.js | 3 +- .../memory/asr-defer-reentry-route.test.js | 3 +- .../memory/asr-defer-route-authority.test.js | 3 +- .../asr-defer-route-opportunity-ref.test.js | 3 +- .../asr-person-memory-reentry-carrier.test.js | 11 +- .../memory/cat-owned-seed-memory-cue.test.js | 1 + .../test/memory/f287-alden-journey.test.js | 38 +- .../test/memory/input-entity-detector.test.js | 49 + ...roactive-memory-candidate-detector.test.js | 26 +- packages/api/test/mention-ack.test.js | 75 +- .../api/test/message-bundle-prompt.test.js | 5 +- .../api/test/message-bundles-route.test.js | 13 +- .../api/test/message-delivered-at.test.js | 107 +- packages/api/test/message-from.test.js | 71 + ...ssage-lifecycle-content-validation.test.js | 115 + .../test/message-lifecycle-ingress.test.js | 642 ++ .../api/test/message-lifecycle-kernel.test.js | 264 + .../message-lifecycle-legacy-parser.test.js | 27 + .../message-lifecycle-queue-order.test.js | 23 + .../message-lifecycle-redis-store.test.js | 420 + .../api/test/message-lifecycle-store.test.js | 854 ++ .../test/message-selection-cli-quote.test.js | 5 +- .../test/message-selection-resolver.test.js | 67 +- .../message-selection-rich-text-quote.test.js | 5 +- .../test/message-store-delivery-guard.test.js | 59 +- packages/api/test/message-store.test.js | 1144 +- ...sages-decision-notification-policy.test.js | 2 +- ...ssages-decision-notification-route.test.js | 155 - .../api/test/messages-delivery-mode.test.js | 2085 ---- packages/api/test/messages-endpoint.test.js | 2049 ++-- .../messages-f108b-whisper-dispatch.test.js | 383 - .../api/test/messages-intent-mode.test.js | 392 - .../messages-parallel-slot-release.test.js | 338 - .../multi-mention-b6-queue-dispatch.test.js | 794 +- .../api/test/multi-mention-routes.test.js | 493 +- .../parallel-cancel-signal-isolation.test.js | 9 +- .../api/test/parse-multipart-files.test.js | 15 + ...cat-terminal-disposition-collector.test.js | 53 +- .../api/test/persisted-queue-delivery.test.ts | 382 +- .../api/test/persistence-fault-drill.test.js | 81 +- ...son-memory-proposal-status-context.test.js | 32 +- ...rsonal-chrome-title-refresh-routes.test.js | 10 +- packages/api/test/pingpong-reset.test.js | 181 - packages/api/test/plugin-m0d-fixture-setup.js | 4 +- .../api/test/plugin-messaging-append.test.js | 19 +- ...in-messaging-event-stream-snapshot.test.js | 103 +- .../plugin-messaging-redis-stores.test.js | 71 +- .../plugin-messaging-replay-retention.test.js | 21 +- ...plugin-messaging-rollback-evidence.test.js | 149 +- .../plugin-messaging-send-adversarial.test.js | 57 +- .../test/podcast-context-injection.test.js | 92 +- .../test/podcast-thread-generation.test.js | 345 +- ...ost-message-disposition-settlement.test.js | 82 - .../test/post-message-successor-e2e.test.js | 26 +- .../proposal-approve-auth-provenance.test.js | 8 +- .../test/proposal-approve-dispatch.test.js | 36 +- .../test/proposal-approve-reconcile.test.js | 93 +- .../api/test/proposal-chain-protocol.test.js | 29 +- .../api/test/proposal-explicit-intent.test.js | 30 +- .../proposal-participant-admission.test.js | 11 +- packages/api/test/proposal-phase-aa.test.js | 16 +- .../api/test/proposal-race-rollback.test.js | 21 +- .../api/test/proposal-reporter-handle.test.js | 32 +- packages/api/test/proposal-resilience.test.js | 65 +- packages/api/test/proposal-withdraw.test.js | 19 +- .../propose-session-handoff-route-fixtures.js | 21 +- packages/api/test/queue-api.test.js | 1629 ++- .../test/queue-cancel-no-auto-resume.test.js | 253 +- .../test/queue-enrichment-publication.test.js | 327 +- .../api/test/queue-entry-settlement.test.js | 165 - .../test/queue-failed-only-fairness.test.js | 431 +- .../api/test/queue-gate-thread-level.test.js | 157 - .../api/test/queue-integration-f175.test.js | 226 +- packages/api/test/queue-integration.test.js | 527 +- packages/api/test/queue-ledger-redis.test.js | 701 ++ packages/api/test/queue-ledger.test.js | 249 + .../test/queue-liveness-diagnostics.test.js | 11 +- .../api/test/queue-liveness-incidents.test.js | 217 - .../api/test/queue-message-admission.test.js | 78 + .../test/queue-processor-delivered-at.test.js | 240 - .../test/queue-processor-pause-epoch.test.js | 1066 -- .../api/test/queue-processor-zombie.test.js | 146 +- packages/api/test/queue-processor.test.js | 9612 ++--------------- .../api/test/queue-steer-mixed-target.test.js | 91 - .../test/queue-steer-prestart-batch.test.js | 403 - .../queue-steer-prestart-restart-fixture.js | 8 +- ...ue-steer-prestart-terminal-failure.test.js | 412 - .../api/test/read-latest-endpoint.test.js | 255 +- packages/api/test/reconcileZombies.test.js | 111 +- .../redis-message-delivery-atomicity.test.js | 21 +- .../redis-message-delivery-contracts.test.js | 113 +- ...-message-parsers-z9-turn-roundtrip.test.js | 30 + packages/api/test/redis-message-store.test.js | 333 +- .../test/redis-queue-ledger-reader.test.js | 63 + .../api/test/redis-read-state-store.test.js | 490 +- ...s-unread-summary-visibility-cursor.test.js | 290 +- packages/api/test/reminder-template.test.js | 45 + packages/api/test/reply-to-threading.test.js | 151 +- packages/api/test/reply-to-validation.test.js | 268 +- .../api/test/review-feedback-router.test.js | 10 +- .../api/test/rich-block-interactive.test.js | 133 +- .../route-parallel-a2a-failure-report.test.js | 122 + .../route-parallel-freshness-commit.test.js | 249 +- ...route-parallel-mention-suppression.test.js | 6 +- ...oute-parallel-parent-invocation-id.test.js | 4 +- ...route-parallel-warning-persistence.test.js | 11 +- .../api/test/route-serial-a2a-tracker.test.js | 502 - .../test/route-serial-callback-dedup.test.js | 102 +- .../route-serial-error-persistence.test.js | 207 +- .../route-serial-freshness-reinvoke.test.js | 259 +- .../test/route-serial-notice-contract.test.js | 50 +- .../test/route-serial-phase-h-hint.test.js | 43 +- .../api/test/route-serial-pingpong.test.js | 530 - .../test/route-serial-replyto-stream.test.js | 74 - .../test/route-serial-verdict-hint.test.js | 39 +- packages/api/test/route-strategies.test.js | 643 +- ...ssage-disposition-preference-route.test.js | 36 +- .../api/test/routing-decision-streak.test.js | 78 - packages/api/test/routing-decision.test.js | 187 - .../test/routing-dispatch-preflight.test.js | 246 +- .../api/test/routing-human-recovery.test.js | 25 +- .../api/test/routing-warning-feedback.test.js | 20 + packages/api/test/s1-review-fixes.test.js | 33 +- packages/api/test/scheduler-delivery.test.js | 3 +- .../scheduler-reply-userid-backfill.test.js | 121 +- .../api/test/scheduler/phase4-e2e.test.js | 57 +- .../schedule-mutation-routes.test.js | 1 + .../scheduled-trigger-owner-fence.test.js | 124 + .../api/test/scheduler/task-runner-v2.test.js | 128 +- .../session-handoff-approve-route.test.js | 2 +- .../test/side-dispatch-integration.test.js | 71 +- .../meeting-intake-action-service.test.js | 7 +- .../thread-artifact-dispatcher.test.js | 94 +- packages/api/test/soft-delete.test.js | 37 +- packages/api/test/startup-reconciler.test.js | 215 +- packages/api/test/system-info.test.js | 16 +- .../api/test/system-prompt-builder.test.js | 10 +- .../telemetry/mention-dispatch-trace.test.js | 230 +- .../api/test/telemetry/step-summary.test.js | 40 +- .../api/test/thread-branch-permission.test.js | 37 +- packages/api/test/thread-branch.test.js | 227 +- packages/api/test/thread-cats.test.js | 23 +- .../test/thread-context-workflow-sop.test.js | 131 +- .../test/thread-execution-situation.test.js | 127 + packages/api/test/threads-endpoint.test.js | 127 +- packages/api/test/tmux-spawn-override.test.js | 10 + .../api/test/turn-custody-projection.test.js | 480 +- ...n-custody-shadow-route-metric-eval.test.js | 42 +- ...urn-custody-shadow-route-telemetry.test.js | 37 +- .../test/turn-custody-stop-gate-route.test.js | 1528 --- .../test/turn-custody-wake-provenance.test.js | 88 +- .../typed-wait-callback-continuation.test.js | 115 +- .../test/typed-wait-queue-authority.test.js | 99 - .../api/test/typed-wait-queue-redis.test.js | 193 - .../typed-wait-registration-redis.test.js | 106 + ...sibility-cursor-consumer-contracts.test.js | 19 +- ...lity-cursor-unresolved-read-policy.test.js | 21 +- ...continuation-retry-committer-redis.test.js | 371 - ...t-continuation-retry-linearization.test.js | 299 - .../wait-continuation-retry-preflight.test.js | 176 - packages/api/test/wake-cat-impl.test.js | 7 +- packages/api/test/web-digest-template.test.js | 2 +- .../api/test/web-outbound-delivery.test.js | 560 - packages/api/test/whisper-visibility.test.js | 129 +- .../api/test/worklist-registry-f121.test.js | 112 - .../api/test/worklist-registry-f122.test.js | 92 - .../api/test/worklist-registry-streak.test.js | 415 - packages/api/test/worklist-registry.test.js | 304 - ...7-message-lifecycle-completion-sunset.json | 54 + .../governance/mcp-surface-baseline.json | 600 +- packages/mcp-server/src/server-toolsets.ts | 13 +- .../mcp-server/src/tools/callback-tools.ts | 166 +- packages/mcp-server/src/tools/index.ts | 2 - .../mcp-server/test/callback-tools.test.js | 38 - .../post-message-principal-schema.test.js | 9 + .../mcp-server/test/tool-registration.test.js | 9 +- packages/shared/src/types/a2a-routing-mode.ts | 50 +- packages/shared/src/types/active-execution.ts | 4 + packages/shared/src/types/cat-breed.ts | 10 +- packages/shared/src/types/cat-carrier.ts | 28 + packages/shared/src/types/cat-routing.ts | 45 + packages/shared/src/types/cat.ts | 3 + packages/shared/src/types/cli-diagnostics.ts | 8 - .../types/cloud-bridge-outbound-receipt.ts | 22 + packages/shared/src/types/connector.ts | 18 +- packages/shared/src/types/index.ts | 67 +- .../shared/src/types/message-lifecycle.ts | 377 + packages/shared/src/types/queue-receipt.ts | 146 +- packages/shared/src/types/user-preferences.ts | 3 - .../game-thread-switch-recovery.test.ts | 2 - .../issue-586-state-trifecta.test.ts | 3 - .../dev/f294-quote-plane-fixtures/page.tsx | 1 + .../dev/issue1371-adopted-hold/preview.tsx | 17 +- .../src/components/AppendedInputReceipts.tsx | 101 + packages/web/src/components/ChatContainer.tsx | 7 +- .../web/src/components/ChatContextPicker.tsx | 2 +- packages/web/src/components/ChatInput.tsx | 281 +- .../src/components/ChatInputActionButton.tsx | 106 +- .../web/src/components/ChatInputAddMenu.tsx | 130 +- .../web/src/components/ChatInputMenus.tsx | 6 +- packages/web/src/components/ChatMessage.tsx | 354 +- .../web/src/components/ChatMessageRow.tsx | 13 +- .../src/components/CliDiagnosticsPanel.tsx | 1 - .../web/src/components/ConnectorBubble.tsx | 2 + packages/web/src/components/CopyIdButton.tsx | 5 +- .../web/src/components/ForceResetDialog.tsx | 148 - .../src/components/GovernanceBlockedCard.tsx | 56 +- packages/web/src/components/HubCatEditor.tsx | 4 +- .../src/components/HubMemberOverviewCard.tsx | 15 +- packages/web/src/components/IdeateHeader.tsx | 21 +- .../components/InvocationTrajectoryAnchor.tsx | 20 +- .../web/src/components/MessageActions.tsx | 360 +- .../src/components/MessageDispatchAvatars.tsx | 195 + .../components/MessageDispositionSelector.tsx | 286 +- .../web/src/components/MessageNavigator.tsx | 4 +- .../web/src/components/MessageReceiptDock.tsx | 335 - .../web/src/components/ParallelStatusBar.tsx | 185 - .../web/src/components/PathCompletionMenu.tsx | 2 +- .../src/components/PendingMemberBubble.tsx | 93 - .../web/src/components/QueueEntryActions.tsx | 75 +- packages/web/src/components/QueueEntryRow.tsx | 345 +- packages/web/src/components/QueuePanel.tsx | 499 +- .../web/src/components/RightStatusPanel.tsx | 3 +- .../src/components/RoutingWarningNotice.tsx | 36 + packages/web/src/components/SplitPaneView.tsx | 22 +- .../src/components/SteerQueuedEntryModal.tsx | 352 +- .../web/src/components/ThinkingIndicator.tsx | 213 - .../web/src/components/ThreadExecutionBar.tsx | 125 +- .../cat-selector-breed-title.test.ts | 2 + .../src/components/TrueRecallActionButton.tsx | 91 - packages/web/src/components/TtsPlayButton.tsx | 10 +- .../web/src/components/TurnAbsorptionDock.tsx | 140 - .../web/src/components/WhisperCatSelector.tsx | 2 +- .../ChatMessage-render-isolation.test.tsx | 84 + .../__tests__/CliDiagnosticsPanel.test.ts | 1 - .../CloudBindingRecoveryCard-races.test.tsx | 41 +- .../CloudBindingRecoveryCard-state.test.tsx | 8 +- ...BindingRecoveryCard-title-refresh.test.tsx | 6 +- .../CloudBindingRecoveryCard.test.tsx | 33 +- .../__tests__/ForceResetDialog.test.tsx | 85 - .../ThinkingIndicator-liveness.test.ts | 254 - .../__tests__/appended-input-receipts.test.ts | 79 + .../assistant-message-renderability.test.ts | 69 + .../capability-tip-placement.test.ts | 67 +- .../__tests__/cat-config-viewer.test.ts | 3 + .../chat-container-governance-refetch.test.ts | 6 - .../chat-container-intent-loading.test.ts | 404 - .../chat-container-layout-observer.test.ts | 6 - .../__tests__/chat-container-mobile.test.ts | 5 +- .../chat-container-read-ack-race.test.ts | 4 - ...hat-container-thread-scoped-active.test.ts | 4 - .../__tests__/chat-input-add-menu.test.tsx | 43 +- .../chat-input-b10-whisper-active.test.ts | 7 +- .../__tests__/chat-input-cloud-tag.test.ts | 2 + .../chat-input-context-picker.test.tsx | 2 +- .../chat-input-draft-persistence.test.ts | 12 +- .../__tests__/chat-input-history.test.ts | 2 +- .../chat-input-mention-filter.test.ts | 4 + .../chat-input-message-disposition.test.ts | 343 +- .../chat-input-options-labels.test.ts | 3 + .../chat-message-cli-diagnostics.test.tsx | 212 +- ...chat-message-cross-thread-receipt.test.tsx | 140 - .../chat-message-notice-rendering.test.ts | 179 +- .../chat-message-true-recall.test.tsx | 91 +- .../chat-store-subscription-scope.test.tsx | 29 +- .../chat-timeline-projection-key.test.ts | 24 +- .../__tests__/cloud-binding-recovery.test.ts | 108 +- .../connector-bubble-message-actions.test.tsx | 57 + .../__tests__/default-cat-selector.test.ts | 6 +- .../__tests__/f190-visual-contract.test.ts | 15 +- .../f264-queue-ux-hierarchy.test.tsx | 192 +- .../f264-turn-absorption-dock.test.tsx | 213 - .../f264-turn-absorption-summary.test.ts | 240 - .../__tests__/governance-blocked-card.test.ts | 136 +- .../__tests__/hub-cat-editor.test.tsx | 205 +- .../hub-member-card-actions.test.tsx | 11 +- .../__tests__/hub-member-list-delete.test.tsx | 1 + .../__tests__/hub-quota-board.helpers.test.ts | 1 - .../__tests__/ideate-header.test.ts | 33 + .../invocation-stall-cancel-invariant.test.ts | 28 +- .../invocation-trajectory-anchor.test.tsx | 28 + .../markdown-content-alias-source.test.ts | 1 + .../markdown-content-mentions.test.ts | 3 + .../message-actions-identity.test.ts | 28 +- .../message-actions-position.test.ts | 39 + .../message-actions-toast-errors.test.ts | 178 +- .../message-dispatch-avatars.test.tsx | 364 + .../__tests__/message-navigator.test.ts | 28 +- .../__tests__/message-receipt-dock.test.tsx | 869 -- .../__tests__/message-selection.test.tsx | 114 +- .../__tests__/mid-invocation-inject.test.ts | 352 +- .../mission-control-page.test-helpers.ts | 1 + .../parallel-status-bar-usage.test.ts | 79 - ...ding-member-bubble-capability-tip.test.tsx | 141 - .../pending-member-projection.test.ts | 141 - .../queue-entry-overflow-reader.test.tsx | 21 +- .../queue-panel-agent-entries.test.ts | 23 +- .../__tests__/queue-panel-images.test.ts | 3 +- .../queue-panel-partial-append.test.tsx | 63 + .../__tests__/queue-panel-processing.test.ts | 596 - .../__tests__/queue-panel-steer.test.ts | 861 +- .../__tests__/queue-panel-wait-reason.test.ts | 25 +- .../__tests__/queue-panel-withdraw.test.ts | 54 +- .../__tests__/right-status-panel.test.ts | 2 +- .../split-pane-execution-projection.test.tsx | 22 +- .../__tests__/stop-event-payload.test.ts | 99 - .../thread-execution-bar-force-reset.test.tsx | 136 +- ...execution-bar-unverified-recovery.test.tsx | 188 - .../__tests__/thread-execution-bar.test.ts | 4 + .../thread-item-draft-badge.test.tsx | 9 +- .../__tests__/thread-liveness-chrome.test.tsx | 25 - .../true-recall-action-button.test.tsx | 138 - .../__tests__/whisper-cat-selector.test.ts | 3 + .../assistant-message-renderability.ts | 68 +- .../components/capability-tip-placement.ts | 45 +- .../chat-timeline-projection-key.ts | 4 +- .../cloud-binding-recovery-operations.ts | 59 +- .../src/components/cloud-binding-recovery.ts | 88 +- .../game/__tests__/GameLobby.test.tsx | 3 + .../components/hub-cat-editor-advanced.tsx | 14 +- .../web/src/components/hub-cat-editor.acp.ts | 25 +- .../src/components/hub-cat-editor.model.ts | 50 +- .../src/components/hub-cat-editor.payload.ts | 29 +- .../components/hub-cat-editor.sections.tsx | 70 +- .../message-disposition-presentation.ts | 97 +- .../web/src/components/message-selection.ts | 3 +- .../components/pending-member-projection.ts | 55 - .../components/queue-receipt-projection.ts | 157 - .../web/src/components/queue-retry-action.ts | 10 - packages/web/src/components/status-helpers.ts | 4 +- .../src/components/steer-target-selection.ts | 78 + .../ReplayMessageList-empty.test.tsx | 14 + .../thread-chat/ThreadChatExport.tsx | 13 +- .../thread-chat/ThreadChatPendingMembers.tsx | 69 - .../thread-chat/ThreadChatSurface.tsx | 38 +- .../src/components/turn-absorption-summary.ts | 181 - .../src/components/useCloudBindingRecovery.ts | 61 +- .../components/useQueueActionConvergence.ts | 160 +- .../__tests__/cat-home-panel.test.tsx | 1 + .../__tests__/f299-legacy-retirement.test.ts | 12 +- .../web/src/debug/invocationEventDebug.ts | 2 +- .../src/debug/invocationEventDebug.types.ts | 3 - ...aceHydrationMessages-queue-receipt.test.ts | 82 +- ...-card-realtime-socket.integration.test.tsx | 2 +- .../queue-message-receipt-normalizer.test.ts | 82 - .../__tests__/system-info-visible.test.ts | 27 +- .../__tests__/use-cat-data-order.test.tsx | 32 +- .../useActiveExecutionProjection.test.tsx | 2 +- ...-background-system-info-web-search.test.ts | 8 +- .../useAgentMessages-background.test.ts | 7 +- .../useAgentMessages-bubble-merge.test.ts | 69 +- ...seAgentMessages-invocation-created.test.ts | 115 +- .../useAgentMessages-warning.test.ts | 8 +- .../hooks/__tests__/useCatData-retry.test.ts | 4 + .../useChatHistory-admission.test.tsx | 1 - .../useChatHistory-author-precedence.test.ts | 1 - ...eChatHistory-draft-restores-active.test.ts | 1 - .../useChatHistory-pagination.test.ts | 1 - .../__tests__/useChatHistory-priority.test.ts | 1 - .../__tests__/useChatHistory-queue.test.ts | 313 +- .../useChatHistory-replace-hydration.test.ts | 6 - .../useChatHistory-scroll-memory.test.ts | 1 - .../useChatHistory-task-progress.test.ts | 1 - .../useChatHistory-teleport-refresh.test.ts | 1 - .../useChatHistory-thread-switch.test.ts | 62 +- ...seChatHistory-z10-idle-server-wins.test.ts | 1 - .../useExecutionRecoveryVerification.test.tsx | 122 - .../useLiveExecutionCancelControl.test.tsx | 71 + ...eDispositionPreference-settlement.test.tsx | 1 - .../__tests__/useSendMessage-routing.test.ts | 2 +- .../useSendMessage-thread-source.test.ts | 395 +- .../useSendMessage-upload-state.test.ts | 23 +- ...seSocket-liveness-reconcile-writer.test.ts | 44 +- .../useSocket-queue-exact-live.test.tsx | 295 - .../useSocket-reconnect-catchup.test.ts | 1 - .../useSocket-stale-watchdog.test.ts | 1 - .../__tests__/useSocket-thread-guard.test.ts | 513 +- .../useThreadScopedSelectors.test.ts | 3 - .../queue-active-invocation-hydration.ts | 5 +- .../hooks/queue-message-receipt-normalizer.ts | 332 - packages/web/src/hooks/system-info-visible.ts | 6 +- .../src/hooks/useActiveExecutionProjection.ts | 56 +- packages/web/src/hooks/useAgentMessages.ts | 144 +- packages/web/src/hooks/useCatData.ts | 27 +- packages/web/src/hooks/useChatHistory.ts | 63 +- .../hooks/useExecutionRecoveryVerification.ts | 86 - .../hooks/useLiveExecutionCancelControl.ts | 55 +- .../hooks/useMessageDispositionPreference.ts | 54 +- packages/web/src/hooks/useSendMessage.ts | 230 +- packages/web/src/hooks/useSocket.ts | 371 +- .../web/src/hooks/useThreadScopedSelectors.ts | 2 +- .../web/src/lib/capability-tips.seed.json | 16 +- .../__tests__/adapter-system-info.test.ts | 7 +- .../__tests__/bubble-projection.test.ts | 111 + .../chatStore-a2a-handoff-order.test.ts | 167 - .../chatStore-delivered-mention.test.ts | 41 +- .../chatStore-parallel-status-merge.test.ts | 2 +- .../stores/__tests__/chatStore-queue.test.ts | 111 +- packages/web/src/stores/bubble-projection.ts | 122 +- packages/web/src/stores/chat-types.ts | 55 +- packages/web/src/stores/chatStore.ts | 195 +- packages/web/src/utils/focusLineageMessage.ts | 17 + .../fixtures/issue1371-queue-reconcile.tsx | 2 - .../browser/message-actions-density.test.mjs | 11 +- pnpm-lock.yaml | 377 +- scripts/generate-hook-manifests.mjs | 8 +- scripts/lib/f296-alpha-uat-contract.mjs | 2 +- 952 files changed, 53900 insertions(+), 104346 deletions(-) create mode 100644 "assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/d12-recent-speaker.md" rename "assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" => "assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/hook.yaml" (65%) delete mode 100644 "assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" delete mode 100644 assets/prompt-templates/d12-active-participant.md create mode 100644 assets/prompt-templates/d12-recent-speaker.md create mode 100644 docs/architecture/f117-latest-main-replay-ledger.md create mode 100644 docs/decisions/043-queue-durable-single-ledger.md delete mode 100644 packages/api/src/domains/ball-custody/A2ADispatchDispositionService.ts delete mode 100644 packages/api/src/domains/ball-custody/A2ADispatchReplacementResolver.ts delete mode 100644 packages/api/src/domains/ball-custody/CoordinationTerminalRetirement.ts create mode 100644 packages/api/src/domains/ball-custody/ManagedCommandWakeRecoveryEngine.ts delete mode 100644 packages/api/src/domains/ball-custody/ManagedHoldDispositionService.ts delete mode 100644 packages/api/src/domains/ball-custody/ManagedHoldReceiptService.ts delete mode 100644 packages/api/src/domains/ball-custody/ManagedHoldSourceSelection.ts delete mode 100644 packages/api/src/domains/ball-custody/TurnCustodyAdoptionRegistry.ts delete mode 100644 packages/api/src/domains/ball-custody/TypedWaitCustodyGuard.ts delete mode 100644 packages/api/src/domains/ball-custody/WaitContinuationRetryCommitter.ts delete mode 100644 packages/api/src/domains/ball-custody/WaitContinuationRetryPreflight.ts create mode 100644 packages/api/src/domains/ball-custody/managed-command-wake-carrier-adapter.ts delete mode 100644 packages/api/src/domains/ball-custody/managed-command-wake-queue-adapter.ts delete mode 100644 packages/api/src/domains/ball-custody/managed-hold-supersession.ts delete mode 100644 packages/api/src/domains/ball-custody/record-managed-hold-disposition.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationIndex.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/CallerDispatchProcessStartRegistry.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueueCarrierSourceProjection.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyA2ARestartPreflight.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCarrierProjection.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartProjector.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartTargets.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartWitness.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRuntimeRestartAttempts.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupMessageReconciler.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupProjectionHelpers.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupQueueEntry.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupTypes.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-model.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-projector.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-reader.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/convergeZombieQueue.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/lifecycle-append-projection.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-kernel.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-queue-order.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-validation.ts delete mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-entry-settlement.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerCommit.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerCodec.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerReader.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-keys.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-scripts.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-transition-scripts.ts create mode 100644 packages/api/src/domains/cats/services/agents/invocation/thread-execution-situation.ts create mode 100644 packages/api/src/domains/cats/services/agents/providers/ClaudeSdkAgentService.ts create mode 100644 packages/api/src/domains/cats/services/agents/providers/claude-mcp-config.ts create mode 100644 packages/api/src/domains/cats/services/agents/providers/claude-sdk-turn-input-state.ts create mode 100644 packages/api/src/domains/cats/services/agents/routing/durable-a2a-lineage.ts create mode 100644 packages/api/src/domains/cats/services/agents/routing/guards/event-backed-routing-exit.ts delete mode 100644 packages/api/src/domains/cats/services/agents/routing/routing-decision.ts create mode 100644 packages/api/src/domains/cats/services/push/decision-notification-policy.ts create mode 100644 packages/api/src/domains/cats/services/stores/message-from.ts delete mode 100644 packages/api/src/domains/cats/services/stores/ports/queued-message-custody.ts delete mode 100644 packages/api/src/domains/cats/services/stores/ports/queued-message-receipt.ts delete mode 100644 packages/api/src/domains/cats/services/stores/redis/RedisTypedWaitCustodyGuard.ts delete mode 100644 packages/api/src/routes/a2a-routing-projection.ts create mode 100644 packages/api/src/routes/hold-ball-terminal-visibility.ts delete mode 100644 packages/api/test/a2a-coalesce.test.js delete mode 100644 packages/api/test/a2a-dispatch-disposition-contention.test.js delete mode 100644 packages/api/test/a2a-dispatch-disposition-invocation-binding.test.js delete mode 100644 packages/api/test/a2a-dispatch-disposition-same-cat.test.js delete mode 100644 packages/api/test/a2a-dispatch-disposition.test.js delete mode 100644 packages/api/test/a2a-dispatch-replacement-enrichment.test.js delete mode 100644 packages/api/test/a2a-invariant-holders.test.js delete mode 100644 packages/api/test/a2a-late-push-probe.test.js delete mode 100644 packages/api/test/a2a-routing-mode-honesty.test.js delete mode 100644 packages/api/test/a2a-routing-persist.test.js delete mode 100644 packages/api/test/callback-a2a-pingpong.test.js delete mode 100644 packages/api/test/callback-complete-managed-hold-route.test.js create mode 100644 packages/api/test/caller-dispatch-observation-registry.test.js create mode 100644 packages/api/test/caller-dispatch-observation-rich-terminal.test.js create mode 100644 packages/api/test/caller-dispatch-observation-selection.test.js create mode 100644 packages/api/test/cloud-delivery-retry.test.js delete mode 100644 packages/api/test/collective-queue-recovery.test.js delete mode 100644 packages/api/test/coordination-terminal-dispatch-retirement.test.js delete mode 100644 packages/api/test/cursor-deferred-ack.test.js delete mode 100644 packages/api/test/f167-a2a-replacement-queue-preflight.test.js delete mode 100644 packages/api/test/f247-first-message-admission.test.js delete mode 100644 packages/api/test/f247-first-message-recovery.test.js delete mode 100644 packages/api/test/f247-queued-cloud-receipt-provenance.test.js delete mode 100644 packages/api/test/f254-queue-restart-custody.test.js delete mode 100644 packages/api/test/f254-queued-message-custody-coordinator.test.js delete mode 100644 packages/api/test/f254-queued-message-custody-redis.test.js delete mode 100644 packages/api/test/f254-queued-message-custody-store.test.js delete mode 100644 packages/api/test/f264-queue-receipt.test.js delete mode 100644 packages/api/test/f264-queue-withdrawal-startup.test.js delete mode 100644 packages/api/test/helpers/a2a-dispatch-disposition-harness.js delete mode 100644 packages/api/test/helpers/f247-first-message-harness.js delete mode 100644 packages/api/test/helpers/issue1371-direct-witness-harness.js delete mode 100644 packages/api/test/helpers/issue1371-terminal-queue-harness.js create mode 100644 packages/api/test/helpers/message-from-fixtures.js delete mode 100644 packages/api/test/helpers/queued-message-custody.js delete mode 100644 packages/api/test/integration/a2a-chain.test.js delete mode 100644 packages/api/test/invocations-retry.test.js delete mode 100644 packages/api/test/issue1371-direct-witness.test.js delete mode 100644 packages/api/test/issue1371-queue-source-boundaries.test.js delete mode 100644 packages/api/test/issue1371-retirement-recovery.test.js delete mode 100644 packages/api/test/issue1371-terminal-convergence-review.test.js create mode 100644 packages/api/test/lifecycle-append-projection.test.js create mode 100644 packages/api/test/lifecycle-response-context.test.js create mode 100644 packages/api/test/live-carrier-agent-services.test.js create mode 100644 packages/api/test/live-invocation-projection.test.js create mode 100644 packages/api/test/managed-command-wake-carrier-adapter.test.js delete mode 100644 packages/api/test/managed-command-wake-queue-adapter.test.js delete mode 100644 packages/api/test/managed-force-queue-autoexecute.test.js delete mode 100644 packages/api/test/managed-hold-disposition.test.js delete mode 100644 packages/api/test/managed-hold-stale-terminal-closure.test.js create mode 100644 packages/api/test/message-from.test.js create mode 100644 packages/api/test/message-lifecycle-content-validation.test.js create mode 100644 packages/api/test/message-lifecycle-ingress.test.js create mode 100644 packages/api/test/message-lifecycle-kernel.test.js create mode 100644 packages/api/test/message-lifecycle-legacy-parser.test.js create mode 100644 packages/api/test/message-lifecycle-queue-order.test.js create mode 100644 packages/api/test/message-lifecycle-redis-store.test.js create mode 100644 packages/api/test/message-lifecycle-store.test.js delete mode 100644 packages/api/test/messages-decision-notification-route.test.js delete mode 100644 packages/api/test/messages-delivery-mode.test.js delete mode 100644 packages/api/test/messages-f108b-whisper-dispatch.test.js delete mode 100644 packages/api/test/messages-intent-mode.test.js delete mode 100644 packages/api/test/messages-parallel-slot-release.test.js delete mode 100644 packages/api/test/pingpong-reset.test.js delete mode 100644 packages/api/test/post-message-disposition-settlement.test.js delete mode 100644 packages/api/test/queue-entry-settlement.test.js delete mode 100644 packages/api/test/queue-gate-thread-level.test.js create mode 100644 packages/api/test/queue-ledger-redis.test.js create mode 100644 packages/api/test/queue-ledger.test.js delete mode 100644 packages/api/test/queue-liveness-incidents.test.js create mode 100644 packages/api/test/queue-message-admission.test.js delete mode 100644 packages/api/test/queue-processor-delivered-at.test.js delete mode 100644 packages/api/test/queue-processor-pause-epoch.test.js delete mode 100644 packages/api/test/queue-steer-mixed-target.test.js delete mode 100644 packages/api/test/queue-steer-prestart-batch.test.js delete mode 100644 packages/api/test/queue-steer-prestart-terminal-failure.test.js create mode 100644 packages/api/test/redis-queue-ledger-reader.test.js create mode 100644 packages/api/test/route-parallel-a2a-failure-report.test.js delete mode 100644 packages/api/test/route-serial-a2a-tracker.test.js delete mode 100644 packages/api/test/route-serial-pingpong.test.js delete mode 100644 packages/api/test/routing-decision-streak.test.js delete mode 100644 packages/api/test/routing-decision.test.js create mode 100644 packages/api/test/scheduler/scheduled-trigger-owner-fence.test.js create mode 100644 packages/api/test/thread-execution-situation.test.js delete mode 100644 packages/api/test/turn-custody-stop-gate-route.test.js delete mode 100644 packages/api/test/typed-wait-queue-authority.test.js delete mode 100644 packages/api/test/typed-wait-queue-redis.test.js create mode 100644 packages/api/test/typed-wait-registration-redis.test.js delete mode 100644 packages/api/test/wait-continuation-retry-committer-redis.test.js delete mode 100644 packages/api/test/wait-continuation-retry-linearization.test.js delete mode 100644 packages/api/test/wait-continuation-retry-preflight.test.js delete mode 100644 packages/api/test/web-outbound-delivery.test.js delete mode 100644 packages/api/test/worklist-registry-f121.test.js delete mode 100644 packages/api/test/worklist-registry-f122.test.js delete mode 100644 packages/api/test/worklist-registry-streak.test.js delete mode 100644 packages/api/test/worklist-registry.test.js create mode 100644 packages/mcp-server/governance/cutovers/f167-message-lifecycle-completion-sunset.json create mode 100644 packages/shared/src/types/cat-carrier.ts create mode 100644 packages/shared/src/types/message-lifecycle.ts create mode 100644 packages/web/src/components/AppendedInputReceipts.tsx delete mode 100644 packages/web/src/components/ForceResetDialog.tsx create mode 100644 packages/web/src/components/MessageDispatchAvatars.tsx delete mode 100644 packages/web/src/components/MessageReceiptDock.tsx delete mode 100644 packages/web/src/components/ParallelStatusBar.tsx delete mode 100644 packages/web/src/components/PendingMemberBubble.tsx create mode 100644 packages/web/src/components/RoutingWarningNotice.tsx delete mode 100644 packages/web/src/components/ThinkingIndicator.tsx delete mode 100644 packages/web/src/components/TrueRecallActionButton.tsx delete mode 100644 packages/web/src/components/TurnAbsorptionDock.tsx delete mode 100644 packages/web/src/components/__tests__/ForceResetDialog.test.tsx delete mode 100644 packages/web/src/components/__tests__/ThinkingIndicator-liveness.test.ts create mode 100644 packages/web/src/components/__tests__/appended-input-receipts.test.ts create mode 100644 packages/web/src/components/__tests__/assistant-message-renderability.test.ts delete mode 100644 packages/web/src/components/__tests__/chat-message-cross-thread-receipt.test.tsx create mode 100644 packages/web/src/components/__tests__/connector-bubble-message-actions.test.tsx delete mode 100644 packages/web/src/components/__tests__/f264-turn-absorption-dock.test.tsx delete mode 100644 packages/web/src/components/__tests__/f264-turn-absorption-summary.test.ts create mode 100644 packages/web/src/components/__tests__/ideate-header.test.ts create mode 100644 packages/web/src/components/__tests__/message-dispatch-avatars.test.tsx delete mode 100644 packages/web/src/components/__tests__/message-receipt-dock.test.tsx delete mode 100644 packages/web/src/components/__tests__/parallel-status-bar-usage.test.ts delete mode 100644 packages/web/src/components/__tests__/pending-member-bubble-capability-tip.test.tsx delete mode 100644 packages/web/src/components/__tests__/pending-member-projection.test.ts create mode 100644 packages/web/src/components/__tests__/queue-panel-partial-append.test.tsx delete mode 100644 packages/web/src/components/__tests__/queue-panel-processing.test.ts delete mode 100644 packages/web/src/components/__tests__/thread-execution-bar-unverified-recovery.test.tsx delete mode 100644 packages/web/src/components/__tests__/true-recall-action-button.test.tsx delete mode 100644 packages/web/src/components/pending-member-projection.ts delete mode 100644 packages/web/src/components/queue-receipt-projection.ts delete mode 100644 packages/web/src/components/queue-retry-action.ts create mode 100644 packages/web/src/components/steer-target-selection.ts delete mode 100644 packages/web/src/components/thread-chat/ThreadChatPendingMembers.tsx delete mode 100644 packages/web/src/components/turn-absorption-summary.ts delete mode 100644 packages/web/src/hooks/__tests__/queue-message-receipt-normalizer.test.ts delete mode 100644 packages/web/src/hooks/__tests__/useExecutionRecoveryVerification.test.tsx create mode 100644 packages/web/src/hooks/__tests__/useLiveExecutionCancelControl.test.tsx delete mode 100644 packages/web/src/hooks/__tests__/useSocket-queue-exact-live.test.tsx delete mode 100644 packages/web/src/hooks/queue-message-receipt-normalizer.ts delete mode 100644 packages/web/src/hooks/useExecutionRecoveryVerification.ts delete mode 100644 packages/web/src/stores/__tests__/chatStore-a2a-handoff-order.test.ts create mode 100644 packages/web/src/utils/focusLineageMessage.ts diff --git "a/assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/d12-recent-speaker.md" "b/assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/d12-recent-speaker.md" new file mode 100644 index 0000000000..90d5909f75 --- /dev/null +++ "b/assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/d12-recent-speaker.md" @@ -0,0 +1,4 @@ + + + +最近发言:{{RECENT_SPEAKER_LABEL}} diff --git "a/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" "b/assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/hook.yaml" similarity index 65% rename from "assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" rename to "assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/hook.yaml" index 25a04baa85..5d6f65bf61 100644 --- "a/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" +++ "b/assets/prompt-hooks/d12-\346\234\200\350\277\221\345\217\221\350\250\200\350\200\205/hook.yaml" @@ -1,13 +1,13 @@ id: D12 -name: 活跃参与者 +name: 最近发言者 stage: per-turn order: 1200 version: 1 enabled: true # Content resolution -template: d12-active-participants.md -resolver: D12ActiveParticipantsResolver +template: d12-recent-speaker.md +resolver: D12Resolver # Dependencies inputs: @@ -22,4 +22,4 @@ transparencyTier: opt-in-view governanceTier: human-gated # operator-facing -userExplanation: "当前 thread 中活跃的猫猫列表" +userExplanation: "最近在此 thread 发过言的成员(不表示正在执行)" diff --git "a/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" "b/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" deleted file mode 100644 index db4e472485..0000000000 --- "a/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/assets/prompt-templates/d12-active-participant.md b/assets/prompt-templates/d12-active-participant.md deleted file mode 100644 index 9e92e6f678..0000000000 --- a/assets/prompt-templates/d12-active-participant.md +++ /dev/null @@ -1,5 +0,0 @@ - - - - -最近活跃:{{ACTIVE_LABEL}} diff --git a/assets/prompt-templates/d12-recent-speaker.md b/assets/prompt-templates/d12-recent-speaker.md new file mode 100644 index 0000000000..a22f90d9f4 --- /dev/null +++ b/assets/prompt-templates/d12-recent-speaker.md @@ -0,0 +1,5 @@ + + + + +最近发言:{{RECENT_SPEAKER_LABEL}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e7136bdfff..9cf9873ef6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,7 +7,7 @@ created: 2026-02-26 # Clowder AI Feature Roadmap -> 维护者:三猫 | 最后更新:2026-09-07(F233 已于 2026-08-17 failed-close (partial-value-preserved),按"只放活跃"规则下线;架构归一整改由 F310 托付 Task `private-source-id` 跟踪,见 *(internal reference removed)*) +> 维护者:三猫 | 最后更新:2026-09-20(F117 exact candidate 已通过本地跨族代码复审与基础旅程 1–5 验收;完整 UAT、fork soak 与上游 gates 仍待完成) > > **规则**:只放活跃 Feature(idea/spec/in-progress/review),done 后移除。 > 详细信息见 `docs/features/Fxxx-*.md`。 @@ -36,6 +36,7 @@ created: 2026-02-26 | F107 | 脑门贴词 — 坏猫战术推理游戏 #1 | spec | Ragdoll | internal | [F107](features/F107-headband-guess-game.md) | | F109 | Message Actions 修复与增强 — 软删除/Branch/编辑/通知 | in-progress | Ragdoll | internal | [F109](features/F109-message-actions-overhaul.md) | | F110 | 训练营愿景引导增强 — operator 需求挖掘 + SOP 显式加载 | spec | Ragdoll | internal | [F110](features/F110-bootcamp-vision-elicitation.md) | +| F117 | Message Delivery Lifecycle — 单 source Queue + History actual-dispatch lifecycle | implementing | Ragdoll + Maine Coon | community [#20](https://github.com/zts212653/clowder-ai/issues/20) | [F117](features/F117-message-delivery-lifecycle.md) | | F119 | 谁是卧底 — 坏猫战术推理游戏 #2 | spec | Ragdoll | internal | [F119](features/F119-who-is-spy-game.md) | | F124 | Apple Ecosystem × Clowder AI 语音交互系统 — iOS/watchOS/AirPods | spec | Ragdoll | internal | [F124](features/F124-apple-ecosystem-voice-interaction.md) | | F126 | 四肢控制面 — Clowder AI Limb Control Plane | in-progress | Ragdoll | internal | [F126](features/F126-limb-control-plane.md) | @@ -64,7 +65,6 @@ created: 2026-02-26 | F193 | Cross-Thread Communication Unification (Phase E: 发现即投递) | in-progress | Ragdoll (Opus 4.6) | internal | [F193](features/F193-cross-thread-comm-unification.md) | | F210 | Gemini CLI to Antigravity CLI Migration | in-progress | Maine Coon/Maine Coon | internal | [F210](features/F210-antigravity-cli-migration.md) | | F219 | 核心引擎技术债盘点 + 架构演进(routeSerial 等核心调用链)| in-progress | Ragdoll Opus 4.8 | internal | [F219](features/F219-tech-debt-architecture-evolution.md) | -| F220 | A2A 协作的可观测·可靠·可恢复(启动中占位可见 + 卡死根因 + force-reset 逃生口 UI)| spec | Ragdoll Opus-4.8(驱动 [thread-id])| internal | [F220](features/F220-a2a-collab-reliability.md) | | F221 | Taste Lane — per-user 品味导航(Phase B: Taste Capture Loop — propose/approve/落盘闭环 + 三路由隔离) | in-progress | Ragdoll | internal | [F221](features/F221-taste-lane.md) | | F23-followup | F23 Phase 2 — dir-size 5 个目录真拆(utils/config/providers 快线 + invocation/routes 跟 F219 协调)| in-progress | Ragdoll/Opus-4.7 | internal | [F23 § Phase 2](features/F023-directory-corrosion-defense.md) | | F224 | A2A 协作的会话/消息状态可靠性(会话延续协调器 + 消息去重 + 触发合并 + 重生会话)| in-progress | Ragdoll Opus-4.8 | community [clowder-ai#834](https://github.com/zts212653/clowder-ai/pull/834) | [F224](features/F224-a2a-session-message-reliability.md) | diff --git a/docs/architecture/a2a-protocol.md b/docs/architecture/a2a-protocol.md index 083061b73c..2bcdb7d976 100644 --- a/docs/architecture/a2a-protocol.md +++ b/docs/architecture/a2a-protocol.md @@ -12,18 +12,18 @@ The delivery kernel is built on exactly three objects. Everything else — body |---|---|---| | **Queue Entry** | yes | An ordered pending input plus its recoverable source identity and enqueue-time target *intent*. **While it sits in the queue, normal dispatch has not started.** | | **Chat History Message** | yes | An input, agent message, or response bubble that has entered the chat panel. It has a fixed order key; actually-delivered public messages carry causal `dispatchRefs` to their exact targets and results. | -| **Active Run** | no (in memory) | The kernel's record that it admitted specific inputs for one agent and that agent's one response bubble. It is *one input* into the execution picture — not the whole truth of execution. | +| **Turn Execution** | yes | The durable child lifecycle for one admitted target and its fixed response bubble. Process-local Active Run/tracker state is only a liveness witness and delivery adapter, never restart truth by itself. | -The pivotal distinction: **a public input lives only in the Queue** — invisible to the chat panel and to other agents' context — until a single admission step materializes it into History. +The pivotal distinction is about **publication**, not whether the source has durable identity. A queued user or connector input has one durable source record plus one Queue Entry, but it is not a History member and remains invisible to the chat panel and other agents' context until its first actual delivery materializes that same source into History. An agent-authored source is already public History; its Queue Entry carries only the targets that still need that existing source delivered. ## The journey of a message A normal input goes through seven steps: -1. **A source forms the message.** A user, connector, system, agent, or existing protocol owner has already decided the content, purpose, and targets. The kernel does not re-guess intent. -2. **It persists into the Queue.** The entry point wraps the source envelope as a Queue Entry. Public inputs, agent wakes, and private inputs all enter the *same* durable priority queue, then signal a drain. -3. **The strict head is selected.** A per-thread drain looks only at the single head the comparator computes. If the target is busy it waits; it never scans for a "better" candidate and never skips the head. -4. **Admission — the single cutover.** One transaction promotes (or reuses) the entry into a History message, flips each target's ref to `dispatched`, and creates a fixed `processing` response bubble per target. **Only after that transaction commits** is the in-memory Active Run created and the agent called. +1. **A source forms the message.** A user, connector, system, agent, or existing protocol owner decides the content and explicit targets. For an ordinary user input with no authored `@`, the server binds the most recent currently-routable completed responder, then the configured default, before Queue admission; the message's authored mentions remain empty. +2. **Source and Queue custody persist together.** The entry point atomically writes one durable source record and one Queue Entry with its pending `targets[]`. Public inputs, agent wakes, and private inputs all enter the *same* durable priority queue, then signal a drain. This does not yet publish a user or connector source to History. +3. **The strict source head is selected.** A per-thread drain looks only at the single source entry the comparator computes. Ordinary drain proceeds only when that entry's complete pending target set is admissible; it never peels off an idle subset or skips to a later source. Explicit singleton Steer and exact unread adoption are separate cutovers. +4. **Admission — one source claim, concurrent target cutovers.** One source claim creates or confirms an independent response receiver and Turn Execution for every target in the admitted set. Each durable receiver appends its exact `dispatched` ref, materializes (or reuses) the one source in History, and removes only its target from Queue. **Only after that durable cutover commits** is the corresponding process-local Active Run created and the agent client called. A target that fails before its cutover is restored without rolling back already-admitted siblings; later target executions and outcomes settle independently. 5. **The same bubble streams.** Output updates only the bubble admission created. A member's internal session rollover, context compaction, or continuation stays inside the agent client and creates no new kernel object. 6. **One terminal, in place.** completed, failed, or canceled all finalize the *same* bubble; in one durable transaction each structured owner commits its own disposition, and only the follow-up that outcome allows is created. 7. **Release, then continue.** After the terminal commits, the exact Active Run is released and the drain runs again — so the next queued item never sits silently. @@ -40,9 +40,9 @@ The result is a structural invariant, not a watchdog: **it is impossible to sit ## Fallback — when no target is given -Fallback applies **only to a public message at the head, and only when the thread is idle** (no Active Run). It routes to the member of the most recent **completed** response bubble if that member is available; otherwise to the server's default member; otherwise it surfaces a visible delivery failure. Members whose latest bubble is processing, failed, or canceled are not candidates. +For an ordinary new user input with no authored `@`, fallback is resolved **before atomic source + Queue admission**. It binds the member of the most recent **completed** response bubble if that member is currently routable; otherwise the server's configured default. The chosen target is persisted in the Queue Entry while the source message keeps `mentions=[]`, because fallback is not authored text. -**Explicit targets and private or structured work never fall back.** A failed exact target produces a typed failure and diagnostic — never a silent reassignment to someone else. +Head-time fallback exists only for historical/recovered targetless rows and warning-bearing rows whose authored mention was invalid. Those rows stay at the strict head while the thread is active; when idle, the same resolver may bind a real target. If it still cannot resolve one, the source becomes a visible delivery failure. A failed exact target is never silently reassigned. ## How a turn ends @@ -67,8 +67,8 @@ Users and agents read the **same order**, threaded by the History order key. If Four explicit operations act on a specific entry or run. They are *not* remedies for normal scheduling, and they never pause or reorder the queue: -- **Append** — adds your input to the target's *existing* run; no new run is created. -- **Steer** — cancels the still-live run for that target, then admits a fresh one. +- **Guide reply** — immediately adds your input to an exact supported current reply; no new run is created. +- **Interrupt reply** — cancels the still-live run for that target, then admits a fresh one. - **Cancel queued** — deletes a not-yet-dispatched entry; no run is affected. - **Stop agent** — snapshots the exact live agent run(s) and cancels them; the normal `canceled` callback closes the bubble. (Managed commands and jobs are not in this snapshot.) @@ -81,7 +81,7 @@ Each thread has its own event-driven drain and its own queue head. Work in one t The whole model rests on five rules: 1. **One owner per fact.** Every fact — queued input, admitted invocation, body exposure, public result, structured responsibility — has exactly one owner. Everyone else references it; no one copies and re-adjudicates it. -2. **Change on one cutover.** Order, side effects, and who-acts-next change only when a single durable admission transaction commits. +2. **Change on one cutover.** Ordinary drain claims one complete source target set or does nothing; each member's order, side effects, and who-acts-next change only when that target's durable receiver/ref commits. Later target executions and outcomes are independent. 3. **Don't infer one fact from another.** Dispatched is not seen; settled is not handled; enqueue targets are intent, re-verified at dispatch. 4. **One terminal per run.** Each admitted run has exactly one in-place result; a committed verdict cannot be undone or duplicated. 5. **Projections are rebuildable; fail closed.** Avatars, "processing," and refs are all derived from canonical facts. When evidence is missing or ambiguous, omit the dynamic claim and show a diagnostic — never a fake "seen," "working," or "done." diff --git a/docs/architecture/at-mention-routing-system.md b/docs/architecture/at-mention-routing-system.md index 2c55b8623e..f2121cbc70 100644 --- a/docs/architecture/at-mention-routing-system.md +++ b/docs/architecture/at-mention-routing-system.md @@ -4,6 +4,8 @@ > > 作者:Ragdoll/claude-opus-4-6 > 日期:2026-06-24 +> +> 2026-09-20 校准:消息投递、Queue ownership 与无 `@` fallback 以 F117 + ADR-043 的当前终态为准;本文对应章节已同步到该模型。 --- @@ -34,13 +36,13 @@ Clowder AI 是一个多智能体系统,多个由 LLM 驱动的"Clowder AI"在 └───────────┬──────────────┘ ▼ ┌──────────────────────────┐ - │ 3. 回退梯级 │ 无显式 @:上次回复者 → 偏好猫 → 默认猫 - │ (机械层) │ 扫描用户最近消息中的提及 + │ 3. 入队目标绑定 │ 普通用户无显式 @:最近 completed 回复者 → 默认猫 + │ (机械层) │ 入队前持久化 targets[],不伪造 authored mention └───────────┬──────────────┘ ▼ ┌──────────────────────────┐ - │ 4. 分发调度 │ 唤醒目标猫,串行或并行 - │ (机械层) │ 护栏:深度限制、去重、乒乓检测 + │ 4. Queue 分发调度 │ 一 source 一 entry;整组准入后并发 fan-out + │ (机械层) │ 护栏:严格队首、生命周期 fence、乒乓检测 └───────────┬──────────────┘ ▼ ┌──────────────────────────┐ @@ -141,30 +143,27 @@ Clowder AI 是一个多智能体系统,多个由 LLM 驱动的"Clowder AI"在 --- -## 第 3 层:路由回退(没有显式 @ 时) +## 第 3 层:入队目标绑定(普通用户消息没有显式 @ 时) -不是每条消息都包含 `@提及`。当用户只是发了"看起来不错,合掉吧"而没有 `@任何猫`,路由器需要自己判断该给谁处理。 +不是每条普通用户消息都包含 `@提及`。这类输入不会把空 `targets[]` 留到 Queue 队首再临时猜人;服务端在 source + Queue 原子 admission 之前运行 canonical resolver,并把选择持久化为这条 entry 的 pending target。 -### 回退优先级(从高到低) +### 当前优先级 | 优先级 | 策略 | 说明 | |--------|------|------| -| 1 | **显式 @提及** | 从当前消息解析——永远最高优先 | -| 2 | **群组提及** | `@all`、`@thread`、`@all-Ragdoll` → 展开为花名册 | -| 3 | **最近用户提及** | 扫描最近 5 条用户消息(1 小时内)中的 @提及 | -| 4 | **最后回复者** | 该线程中最近活跃的猫(如果健康) | -| 5 | **线程偏好猫** | 该线程配置的默认猫 | -| 6 | **系统默认猫** | 全局兜底猫(通常是主力 Opus) | - -### 为什么需要"最近用户提及"? +| 1 | **当前消息的有效显式 @** | 解析、校验并持久化为 authored targets | +| 2 | **最近 completed 回复者** | 只接受当前仍可路由的 response target;processing / failed / canceled 不是候选 | +| 3 | **配置的默认成员** | completed 回复者不可用时的服务端兜底 | -这处理了一个常见场景:用户 @ 了一只猫,猫回复了,用户回复时没再 @。路由器会往回看用户最近几条消息,找到之前在跟谁说话。没有这个机制,每次回复都得显式 @。 +fallback 选中的成员只进入 Queue `targets[]`;source 的 `mentions` 仍为空,因为这不是用户写下的文本。幂等重放复用首次 admission 已持久化的 target,不随稍后的会话变化改派。 -这个扫描故意只检查**用户消息**(不是猫的消息),因为系统自动生成的猫消息(愿景守护报告、跨线程通知等)可能会劫持路由。 +只有两类兼容输入可以保持 targetless:历史/恢复 row,以及带无效 authored mention 与结构化 routing warning 的 row。它们在 thread 活跃时停在严格队首,空闲后才运行同一个 resolver;仍无目标时形成可见失败,不静默丢弃或改写用户的无效 @。 ### 源代码文件 -`packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts` — `parseAllMentions()` +- `packages/api/src/routes/messages.ts` — 普通用户输入在原子 admission 前绑定 target +- `packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts` — `resolveConversationTargetsAtAdmission()` +- `packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts` — 队首可用性复核与历史 targetless 恢复 --- @@ -172,19 +171,20 @@ Clowder AI 是一个多智能体系统,多个由 LLM 驱动的"Clowder AI"在 目标猫确定后,系统分发调用。 -### 串行 vs. 并行 +### source 顺序与 target fan-out -- **串行**(默认):一次一只猫。猫 A 完成后,猫 B 才开始。用于交接链(`@opus → @codex → @opus`)。 -- **并行**(`cat_cafe_multi_mention`):多只猫同时唤醒。用于"问所有人"的场景。状态机追踪完成情况:`pending → running → partial → done`。 +- **不同 source 严格有序**:一次 try-drain 只看 comparator 选出的一个 source head;头部不能准入时,后面的 source 不绕过。 +- **同一 source 的 targets 并发**:普通 drain 只有在完整 pending target set 都可 admission 时才领取;领取后为每个 target 并发建立独立 response / Turn Execution。不会把正文拆成多条 Queue row,也不会等第一只猫完成后再 drain sibling。 +- **显式 singleton cutover**:Queue Steer 或 exact unread adoption 可以只领取一个 target;这是显式操作,不会把普通 drain 改成“先投空闲子集”。 ### 安全护栏 | 护栏 | 用途 | 限制 | |------|------|------| -| **深度限制** | 防止 A2A 链失控 | 每线程最多 10 个 agent 条目 | -| **去重(合并)** | 同一轮交接给同一只猫会合并 | 内容合并,不重新分发 | +| **严格队首** | 保持 source 顺序 | 一次 try-drain 最多领取一条 source,blocked 时无副作用 | +| **幂等 source×target** | 防止恢复/重放重复唤醒 | 已有 durable dispatch 时只清理残留 pending target | | **乒乓检测** | 同一对猫来回踢皮球 | N 轮后注入警告到上下文 | -| **超时** | 并行提及的响应截止时间 | 每请求 3-20 分钟 | +| **生命周期 fence** | 防止过期 owner、wait 或 action 续跑 | 准入前验证 exact generation / owner / source | ### 乒乓问题 @@ -194,9 +194,9 @@ Clowder AI 是一个多智能体系统,多个由 LLM 驱动的"Clowder AI"在 | 文件 | 用途 | |------|------| -| `packages/api/src/routes/callback-a2a-trigger.ts` | 发消息后的 A2A 分发 | -| `packages/api/src/domains/cats/services/agents/routing/MultiMentionOrchestrator.ts` | 多猫状态机 | -| `packages/api/src/domains/cats/services/agents/routing/multi-mention-state-machine.ts` | 状态转换校验 | +| `packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts` | 所有来源的 Queue admission 适配 | +| `packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts` | 严格队首、整组准入与并发 fan-out | +| `packages/api/src/routes/callbacks.ts` | callback/A2A source 解析与 Queue 入站 | --- @@ -531,20 +531,19 @@ Clowder AI 是一个多智能体系统,多个由 LLM 驱动的"Clowder AI"在 > Added 2026-07-01 | 详见 ADR-040 -当用户发消息给正在执行的猫时,消息进入排队状态(`queued`)。排队消息的读取(read)、处理(handled)、投递(delivered)、目标消费(target consumed)是四个独立状态层: +当用户发消息给正在执行的猫时,消息进入排队状态(`queued`)。Queue 只保存一条 source entry 及尚待投递的 `targets[]`;读取、实际投递与回复终局由各自的 History / execution owner 表达。ADR-043 D8 规定 exact full-read journey 通过一条 active-child adoption 路径提交相关转换: | 层 | 含义 | Scope | 影响其他猫? | |----|------|-------|------------| -| `delivery` | 消息是否进入 thread history | message-level | 否 | -| `queued_seen` | 猫获取了排队正文 | per-cat + queue entry | 否 | -| `queued_handled` | 猫处理完毕或显式 disposition | per-cat + queue entry | 否 | -| `target_consumed` | 路由 work item 对该 target 猫已解决 | per-target + queue entry | 否(仅编排器聚合) | +| `dispatchRef` | exact child 接管后,source→target 的实际投递时间、invocation 与 response identity 写入 History | source×target History lifecycle | 不消费其他 target | +| body exposure | exact child 获得该 target 的完整正文 | exact response / Active Run evidence | 否 | +| pending target removal | actual delivery 后从同一 Queue Entry 的 `targets[]` 删除该 target | one source Queue Entry | 否;siblings 仍 pending | +| terminal response | completed / failed / canceled 原位终局同一 response bubble | exact target response lifecycle | 否 | -**设计规则**:read is per-cat, handled is per-cat, target consumption is per-target. Nothing is global by default. +**设计规则**:full queued read is an exact per-target adoption, not a passive peek. A+B 中 A 接管只从 source entry 删除 A 并写入 A 的 `dispatchRef`;B 后续从同一可见 History source 接管自己,最后没有剩余 target 才删除 Queue Entry。Sparse/cross-thread/无法证明 active child 的读取不接管。Queue 不保存 `queued_seen` / `queued_handled` / terminal tombstone。 **关联 Feature**: -- F254 产出/消费 `queued_seen`,用于抑制 freshness 重复提醒 -- F086 拥有 canonical per-target `TargetStatus` 状态机 +- F254 消费 exact History dispatch / body-exposure evidence 来抑制 freshness 重复提醒,但不在 Queue 复制这些事实 +- F086/F264 从 `dispatchRefs` 与 response lifecycle 投影逐目标 UI 状态 - F108 拥有独立 fan-out context cutoff 策略 - F117/F039 定义 queued delivery lifecycle 底层语义 - diff --git a/docs/architecture/f117-latest-main-replay-ledger.md b/docs/architecture/f117-latest-main-replay-ledger.md new file mode 100644 index 0000000000..7d096d938a --- /dev/null +++ b/docs/architecture/f117-latest-main-replay-ledger.md @@ -0,0 +1,411 @@ +--- +title: "F117 latest-main replay migration ledger" +description: "Accounts for every latest-main source deletion, MCP surface change, and deleted test family when replaying F117 onto main 6291ff079." +doc_kind: architecture +feature_ids: [F117, F167, F247, F254, F264] +topics: [message, queue, delivery, replay, migration, testing] +created: 2026-09-19 +updated: 2026-09-20 +status: review +architecture_cell: "dispatch + ball-custody + bubble-pipeline" +architecture_map_delta: "none — records the F117 cutover from legacy custody and receipt projections to existing canonical owners" +author: "Maine Coon/cat-eqdvbcxw@gpt-5.6-sol" +related_docs: + - docs/features/F117-message-delivery-lifecycle.md + - docs/decisions/043-queue-durable-single-ledger.md + - docs/architecture/ownership/cells/dispatch.md + - docs/architecture/ownership/cells/bubble-pipeline.md +source_pr: "zts212653/clowder-ai#1398" +replay_base: "6291ff079" +reviewed_pre_replay_head: "be84940fe" +--- + +# F117 latest-main replay migration ledger + +This ledger closes the continuity audit for replaying the reviewed F117 tree onto +`main=6291ff079`. It distinguishes an intentional semantic cutover from a main regression. The +normative rule is **main first, then F117 delta**: a missing latest-main declaration is acceptable +only when its old owner is retired here and its replacement owner and regression evidence are +named. + +The final base advance from `d8bf77403` to `9ab0eaf28` is the F202 terminal Plugin Manager +landing. Its 96-path delta is preserved in full. Only three paths overlap the F117 patch +(`packages/api/src/index.ts`, `packages/mcp-server/src/tools/index.ts`, and +`packages/shared/src/types/index.ts`); Git merged their additive composition/export changes without +conflict. The durable replay-checkpoint comparison +`git range-diff d8bf77403..5b7723dd9 9ab0eaf28..dd080052e` reports the implementation commit as +patch-equivalent (`=`) and the documentation commit as intentionally changed (`!`): this ledger +updates its replay-base annotation and adds this F202 continuity paragraph. The range +`dd080052e..HEAD` is reserved for post-replay UAT corrections discovered against that exact +checkpoint, so later fixes cannot silently rewrite the proof interval. Focused post-replay +verification covers the API composition root, F202 Plugin Manager routes/composition, MCP +registration/governance, shared exports, and the F117 message/Queue lifecycle. + +The final base advance `9ab0eaf28..6291ff079` is the CI-only public-test resource-sharding landing +(#1483): 20 paths. Its intersection with the pre-advance F117 patch is exactly three F296 Alpha UAT +tests. The replay preserves both sides there: #1483's explicit `expectedRevision` fixtures remain, +while F117's canonical `/api/cats` `carrier` contract replaces the retired provider-specific +`codexCarrier.effective` shape. There is no production-source overlap. The final reproducible +comparison is `git range-diff 9ab0eaf28..94e136e16 6291ff079..HEAD`; both commits are expected `!` +because the implementation now also carries the post-UAT no-mention ingress and #1371 pre-start +recovery corrections, while this ledger records their public evidence and the new base. + +## 1. Deleted latest-main source files + +All twelve files below are also absent from the reviewed pre-replay F117 tree `be84940fe`; none was +silently lost by the latest-main replay. + +| Deleted latest-main source | Why it is retired | Canonical replacement and evidence | +|---|---|---| +| `A2ADispatchDispositionService.ts` | Ordinary A2A no longer closes a second Ball disposition after message delivery. | `source → Queue admission → response terminal`; `message-lifecycle-*`, `queue-processor.test.js`, and the F167 MCP cutover manifest. | +| `ManagedHoldDispositionService.ts` | A managed-hold wake is an urgent Queue source, not a hold-specific completion protocol. | `callback-hold-ball-*.test.js`, `managed-command-wake-carrier-adapter.test.js`, and the canonical response lifecycle. | +| `ManagedHoldSourceSelection.ts` | Wake source selection is fenced at registration and Queue admission instead of re-selected at terminal disposition. | Hold-ball route tests plus `QueueLedgerAdmission` / `QueueProcessor`. | +| `record-managed-hold-disposition.ts` | The retired terminal disposition writer would duplicate the response terminal. | Hold-ball registration tests and the F167 completion-tool cutover manifest. | +| `TurnCustodyAdoptionRegistry.ts` | Queue admission and action-successor responsibilities now keep their own typed owners; there is no generic turn-adoption side ledger. | QueueLedger claim/commit tests and action-successor recovery tests. | +| `TypedWaitCustodyGuard.ts` | Typed waits are admitted once through their registration identity rather than a parallel custody guard. | `typed-wait-registration.test.js` and `typed-wait-registration-redis.test.js`. | +| `RedisTypedWaitCustodyGuard.ts` | Redis parity moved with the same typed-registration cutover. | `typed-wait-registration-redis.test.js`. | +| `QueueCarrierSourceProjection.ts` | Queue rows identify one canonical source directly; a carrier-source projection is no longer a second identity. | `QueueLedgerEntry.sourceRecordId`, `queue-ledger*.test.js`, and `invocation-queue.test.js`. | +| `QueuedMessageCustodyCoordinator.ts` | Queue custody is owned by the durable QueueLedger instead of a second coordinator/store. | `QueueLedger`, `QueueProcessor`, `StartupReconciler`, and their Memory/Redis tests. | +| `QueuedMessageCustodyStartupQueueEntry.ts` | Startup reconstructs canonical QueueLedger entries and executions directly. | `queue-ledger*.test.js`, `queue-processor*.test.js`, and startup reconciler tests. | +| `queued-message-custody.ts` | The legacy custody port duplicated QueueLedger state. | `QueueLedgerStore` with in-memory and Redis parity tests. | +| `MessageReceiptDock.tsx` | The standalone receipt dock was a second user-visible delivery model. | Source-bubble dispatch avatars plus the canonical processing/terminal response; `message-dispatch-avatars.test.tsx` and `assistant-message-renderability.test.ts`. | + +## 2. MCP surface ledger + +The current source surface differs from latest main by exactly two names: + +| Tool | Disposition | Reason / evidence | +|---|---|---| +| `cat_cafe_complete_a2a_dispatch` | retired | Ordinary A2A completion is the canonical response terminal. | +| `cat_cafe_complete_managed_hold` | retired | Managed-hold recovery is an urgent Queue source with the same response lifecycle. | +| `cat_cafe_get_hold_status` | retained | It remains registered in `callback-tools.ts` and in `mcp-surface-baseline.json`; the replay restored it. | + +The exact retirement is governed by +`packages/mcp-server/governance/cutovers/f167-message-lifecycle-completion-sunset.json`; there is no +alias or dual-write fallback. + +## 3. Tests added on latest main after the old F117 base + +These ten main-new tests were deliberately superseded rather than accidentally omitted: + +| Deleted main-new test | Replacement coverage | +|---|---| +| `a2a-dispatch-disposition-contention.test.js` | QueueLedger claim/commit contention and per-target QueueProcessor convergence. | +| `collective-queue-recovery.test.js` | `collective-ingress-dispatcher.test.js` and `collective-persistent-authority.test.js`. | +| `f247-first-message-admission.test.js` | `cloud-delivery-retry.test.js`, message-lifecycle ingress, and QueueProcessor admission tests. | +| `f247-first-message-recovery.test.js` | `cloud-delivery-retry.test.js`, QueueLedger recovery, and startup reconciliation. | +| `f247-queued-cloud-receipt-provenance.test.js` | Canonical source/response lifecycle plus cloud-delivery retry provenance. | +| `helpers/f247-first-message-harness.js` | Retired together with the three F247 scenario tests above. | +| `issue1371-direct-witness.test.js` | Queue source identity, response lifecycle, and persisted Queue integration tests. | +| `helpers/issue1371-direct-witness-harness.js` | Retired with the direct-witness test above. | +| `typed-wait-queue-authority.test.js` | `typed-wait-registration.test.js` plus QueueLedger authority tests. | +| `typed-wait-queue-redis.test.js` | `typed-wait-registration-redis.test.js` plus `queue-ledger-redis.test.js`. | + +## 4. Complete deleted-test family ledger + +The remaining deleted tests are exhaustively accounted for by the path sets below. A path set is a +semantic family, not a claim that one replacement test preserves the old implementation shape. + +| Deleted test family (exact files or exhaustive globs) | Why the old assertions are invalid | Replacement suites | +|---|---|---| +| `a2a-*.test.js`; `callback-a2a-pingpong.test.js`; `coordination-terminal-dispatch-retirement.test.js`; `f167-a2a-replacement-queue-preflight.test.js`; `integration/a2a-chain.test.js`; `pingpong-reset.test.js`; `post-message-disposition-settlement.test.js`; `route-serial-a2a-tracker.test.js`; `route-serial-pingpong.test.js`; `routing-decision*.test.js`; `turn-custody-stop-gate-route.test.js`; `worklist-registry*.test.js`; `helpers/a2a-dispatch-disposition-harness.js` | These assert Ball disposition, ping-pong/streak, or Worklist completion after delivery. F117 makes source/Queue/response the only delivery lifecycle. | `message-lifecycle-*`, `queue-ledger*`, `invocation-queue.test.js`, `queue-processor*.test.js`, and lifecycle response-context tests. | +| `callback-complete-managed-hold-route.test.js`; `managed-command-wake-queue-adapter.test.js`; `managed-force-queue-autoexecute.test.js`; `managed-hold-*.test.js`; `typed-wait-queue-*.test.js`; `wait-continuation-retry-*.test.js` | Hold-specific completion/custody and retry-commit protocols are replaced by typed registration plus a normal urgent Queue wake. | `callback-hold-ball-*.test.js`, `managed-command-wake-carrier-adapter.test.js`, `typed-wait-registration*.test.js`, QueueLedger, and QueueProcessor tests. | +| `cursor-deferred-ack.test.js`; `f254-queue-*.test.js`; `f264-queue-*.test.js`; `queue-entry-settlement.test.js`; `queue-gate-thread-level.test.js`; `queue-liveness-incidents.test.js`; `queue-processor-delivered-at.test.js`; `queue-processor-pause-epoch.test.js`; `queue-steer-*.test.js`; `helpers/queued-message-custody.js` | Legacy Queue custody, receipt, pause, and settlement stores were competing owners. | `queue-ledger*.test.js`, `redis-queue-ledger-reader.test.js`, `invocation-queue.test.js`, `queue-integration*.test.js`, and `queue-processor*.test.js`. | +| `issue1371-*.test.js`; `helpers/issue1371-*.js` | Direct-witness and retirement harnesses encoded the old custody/receipt pipeline. | Canonical Queue source identity, message lifecycle, Queue integration, and lifecycle response-context tests. | +| `messages-decision-notification-route.test.js`; `messages-delivery-mode.test.js`; `messages-f108b-whisper-dispatch.test.js`; `messages-intent-mode.test.js`; `messages-parallel-slot-release.test.js`; `web-outbound-delivery.test.js`; `invocations-retry.test.js` | Message admission, target intent, and retry are now expressed by per-target author intent, Queue admission, and canonical execution lifecycle. | `message-lifecycle-ingress.test.js`, `message-lifecycle-queue-order.test.js`, disposition preference tests, QueueProcessor, and TurnExecution tests. | +| `ForceResetDialog.test.tsx`; `ThinkingIndicator-liveness.test.ts`; `thread-execution-bar-unverified-recovery.test.tsx`; `true-recall-action-button.test.tsx`; `parallel-status-bar-usage.test.ts` | Force-reset/unverified recovery and duplicate parallel status controls are retired; Stop and canonical execution projection are the only control/read model. | `useLiveExecutionCancelControl.test.tsx`, active-execution projection tests, and processing response presentation tests. | +| `chat-message-cross-thread-receipt.test.tsx`; `f264-turn-absorption-*.test.*`; `message-receipt-dock.test.tsx`; `queue-message-receipt-normalizer.test.ts`; `useSocket-queue-exact-live.test.tsx`; `chatStore-a2a-handoff-order.test.ts` | Standalone receipt/dock and socket-derived Queue receipt projections are retired. | `message-dispatch-avatars.test.tsx`, `appended-input-receipts.test.ts`, `assistant-message-renderability.test.ts`, and canonical lifecycle store tests. | +| `pending-member-*.test.*`; `queue-panel-processing.test.ts`; `useExecutionRecoveryVerification.test.tsx` | Pending targets remain Queue-only; processing is proven by response plus exact execution, not a pending-member bubble or client recovery heuristic. | `queue-panel-steer.test.ts`, message dispatch avatar tests, and active/turn execution tests. | + +The path families above cover every test deleted by `origin/main..F117` at this replay. The unrelated +latest-main test `memory/f287-explicit-taste-consumption.test.js`, which the earlier replay had +accidentally dropped, is restored unchanged. + +## 5. Mechanical continuity audit interpretation + +The audit scripts intentionally compare exact added lines, so renamed/refactored canonical owners +remain as heuristic hits. The exact A1 differences (files untouched by the intervening main delta) +are accounted for below; none is an accidental replay edit. + +| A1 residual | Attribution | +|---|---| +| `env-registry.ts` | Restores the legacy `CAT_CAFE_CODEX_CARRIER` compatibility input consumed by the canonical carrier resolver; new member configuration still uses `carrier`. | +| `CallerDispatchObservationIndex.ts` and `CallerDispatchObservationRegistry.ts` | Mechanical extraction of the bounded process-local index from the registry after the reviewed head exceeded the repository size gate; canonical observation behavior is unchanged and remains covered by caller-observation tests. | +| `memory/f287-explicit-taste-consumption.test.js` | Unrelated latest-main regression test that the earlier replay dropped; restored unchanged as continuity evidence. | +| `scheduled-trigger-owner-fence.test.js` | Updates the fixture for the strict trigger-store API and asserts that an ordinary reminder cannot claim private managed-hold provenance. | +| `typed-wait-registration-redis.test.js` | Redis parity coverage for the replacement typed-registration authority named in section 1. | +| `cancel-scope-guard.test.js` | Supplies the current QueueProcessor processing-slot reservation probe and includes the response body in the existing scope-guard assertion; the cancellation boundary is unchanged. | +| `multi-mention-b6-queue-dispatch.test.js` | Aligns the serial router fixture with one multi-target route execution and its single Queue parent invocation while retaining per-target lifecycle terminals. | +| Four `ChatInput` test files | Removes the retired one-shot disposition argument from fixtures; production `ChatInput` now sends only persistent preference plus per-entry Steer. | +| `thread-item-draft-badge.test.tsx` | The same retired one-shot disposition argument is removed from the draft-send fixture; draft clearing behavior is unchanged. | +| Claude/Kimi carrier services, `cats.ts`, disposition admission, `message-disposition-presentation.ts`, and their F264/runtime/Queue Web tests | Introduces the required, fail-closed `activeInvocationGuidance` dimension independently from provider read precision. Codex app-server and Claude SDK explicitly support Guide; other concrete carriers remain unsupported. The public owner decision and exact provider semantics are recorded in section 7. | +| `message-lifecycle-ingress.test.js`, `routing-warning-feedback.test.js`, and `thread-cats.test.js` | Locks the restored no-mention invariant: ingress binds the canonical recent-completed/default target before atomic Queue admission, keeps source mentions truthful, and preserves invalid-mention warnings without a silent ingress retarget. | +| `issue1371-prestart-recovery.test.js` | Restores the complete production-shaped seven-case matrix. A sibling that becomes busy during preflight restores the exact claim without loss/failure; force-reset cannot retire through another user's live sibling target. | +| `useChatHistory-queue.test.ts` | Covers the post-UAT first-message bubble recovery: an empty initial History plus an active Queue projection requests one bounded catch-up, while a slower non-empty initial History does not duplicate the fetch. | +| `queue-api.test.js`, `f194-canonical-liveness-queue.test.js`, and `f247-owner-chrome-product-chain.test.js` | Aligns fixtures/assertions with canonical Queue lifecycle and the required guidance capability field; no alternate queue or carrier owner is introduced. | +| This ledger and the F117 link | New replay-review evidence requested by the continuity audit. | +| `F233-ball-custody-observability.md` | Keeps the tips exemption explicit after the F117 cutover; it changes no runtime contract. | + +The A2 missing-declaration heuristic is fully partitioned as follows: + +| Heuristic residual | Canonical disposition | +|---|---| +| `QueueCustodyLifecycleRecord`, `QueueAccumulator`, and its window helpers | Renamed/rebuilt as `FreshnessQueueLifecycleRecord` over the canonical replay provider; no custody store is restored. | +| `ManagedHold*`, `HoldSource`, `completeSource`, `recordManagedHoldDisposition`, `completeLatestInvocation` | Intentional F167 completion/disposition retirement; the hold wake enters QueueLedger and finishes through the canonical response lifecycle. | +| `TypedWaitCustodyGuard`, Redis guard helpers/Lua, and coordinator guard imports | Intentional typed-registration cutover; `TypedWaitRegistration` plus Task CAS and QueueLedger admission own authority. | +| `AdoptionCommit`, `AdoptionHandler`, `AdoptedManagedHold`, `TurnCustodyAdoptionReservation` | Intentional generic adoption-registry retirement; typed action-successor/managed-wake owners remain. | +| `QueueCustodyLifecycleRecord` in MessageStore/Redis | Intentional custody record retirement; QueueLedger is the only pending-work truth. | +| `parsePawFeelSourceOrigin`, `projectPawFeelSourcePresence`, `PersistedHostMessageExtra`, `projectPawFeelSourceStream`, `projectPawFeelSourceCrossPost` | Main capability retained under the composed `projectPawFeelSourceMessage`, `projectPawFeelSourceTiming`, `safeParsePawFeelSourceExtra`, and hydration helpers. | +| `TimelineOrderMessage`, `isQueuedUserTimelineMessage`, `isQueuedOwnerConnectorTimelineMessage` | Replaced by the single lifecycle-aware visibility predicates in `visibility.ts`; queued work is not a second timeline message type. | +| `PromptExposureAdoptionReservation`, `PromptMessagesSeenOptions` | Replaced by QueueLedger claim plus durable body-exposure/`queued_seen` evidence in QueueProcessor. | +| `readCompleteCarrier` | Replaced by claimed-target validation and per-target commit/restore against the durable Queue row. | +| `hasUnsettledQueueReceipt` | Intentional receipt-model retirement; Web reconciles canonical lifecycle plus active execution instead of socket-local receipt state. | +| `preflightRejectedCatIds` / `routing_preflight_rejected` collector state | Replaced by per-target Queue admission outcomes and durable target terminal state. Routing preflight rejection no longer needs a second in-process collector set: rejected targets are recorded on the canonical Queue/message lifecycle and excluded before provider launch. | + +These groups account for every absent identifier printed by +`/tmp/fable-replay-audit2.mjs`; the twelve absent source owners themselves are enumerated in +section 1 rather than repeated here. + +The final replay gate also runs two symmetric composition audits rather than relying on missing-line +counts alone: + +- `/tmp/fable-replay-audit-new.mjs` reports main-added declarations absent from the candidate by + file, so every high-residual composition root is reviewed against this ledger. +- `/tmp/fable-unwired.mjs` finds exported `register*` / `create*` owners that have a production + caller on latest main but only their own definition in the candidate. The final candidate keeps + the request-review owner callbacks, named-cat content callbacks, and Claude carrier factory + reachable from their production composition roots; the callback registration regression injects + both HTTP endpoints and proves they are registered behind callback authentication. + +`packages/api/src/index.ts` has no remaining missing-main-line hit. Latest-main runtime registration, +provider-native freshness, bootstrap trace, MCP hold-status, and Paw Feel projections are all wired +in the replayed tree. + +## 6. Main-new test cases removed inside retained files + +A file-level deletion audit cannot see a test file that remains while individual latest-main cases +inside it disappear. `/tmp/fable-infile-tests.mjs` therefore compares declared `test` / `it` names +across `c0cf29f0a → d8bf77403 → F117`. It finds 42 main-new names across the 16 retained files +below. This table accounts for all 42; renamed cases preserve their assertion, while retired cases +name the canonical replacement owner and evidence. + +| Retained test file (main-new names absent) | Disposition | Replacement evidence / explicit choice | +|---|---|---| +| `persisted-queue-delivery.test.ts` (14) | Retired with the process-local producer recovery owner, including pause epochs, force-reset suppression, partial-carrier restoration, cross-user live custody, and producer-return priority. | `queue-ledger*.test.js`, `invocation-queue.test.js`, `queue-message-admission.test.js`, and `queue-processor.test.js` cover atomic Message+Queue admission, exact source replay, target-scoped restore, busy-slot retry, withdrawal, and durable priority without a second `PersistedQueueDelivery` owner. | +| `f254-windowed-signal-report.test.js` (4) | Renamed in place after Queue/Supplement custody became History dispatch lifecycle. | The same file retains four corresponding cases: fail-closed structural maturity, retry-success terminal time, pre-window terminal isolation, and untimed History/Supplement overlap. | +| `managed-command-wake-recovery-sweep.test.js` (3) | Renamed in place around canonical urgent Queue ingress. | Its first three cases still assert strict provenance restoration, legacy `unknown` provenance, and rejection of compatibility-provenance promotion. | +| `issue1371-redis-convergence.test.js` (3) | Old adopted-receipt/direct-completion scenarios retired with message receipt custody and hold-specific completion. | The retained Redis suite proves bounded terminal recovery plus HTTP pre-start Queue recovery; `queue-ledger-redis.test.js`, `queue-processor.test.js`, and managed-command wake recovery cover exact child/source convergence and crash repair. | +| `redis-message-store.test.js` (3) | Renamed in place from queue-custody HMGET vocabulary to narrow source-provenance reads. | The same file retains bounded source-only HMGET, per-source failed outcomes on batch rejection, and isolation of one malformed provenance row from later batches. | +| `connector-invoke-trigger.test.js` (2) | Old A2A slot-claim and legacy custody-upgrade cases migrated to canonical Queue admission. | The retained suite proves one atomic Queue row, exact wait-continuation copying, verified managed-command generation fencing, strict provenance only from verified carriers, and `unknown` for ordinary connector ingress. | +| `callback-routes.test.js` (2) | Deliberate exact-drill boundary: `get-message` remains a read-only drill and does not adopt Queue custody. | Full `thread-context` remains the sole read-and-adopt surface (`full thread-context adopts published A2A custody` and `queued body adoption binds the exact child response`). A very large queued cat-authored message can therefore be shown by exact drill and later delivered normally; this can repeat content but cannot lose or steal custody. | +| `ball-custody-ingest-redis.test.js` (2) | Direct/managed completion heartbeat-CAS cases retired with the two completion disposition services. | Redis QueueLedger transition tests now own CAS contention and replay; `issue1371-redis-convergence.test.js` covers restart recovery against the canonical message and Queue stores. | +| `per-cat-terminal-disposition-collector.test.js` (2) | Collector custody/adopted-source tracking retired; this collector now classifies provider terminals only. | `queue-processor.test.js` retains exact child/source dispatch refs and independent multi-target convergence; QueueLedger stores the durable target ownership instead of event-payload inference. | +| `queue-processor.test.js` (1) | F293 source-versus-replay provenance moved from transient processor assertions into the Queue row. | `invocation-queue.test.js` and `queue-message-admission.test.js` assert deterministic source identity and replay without duplication; connector ingress asserts `sourceCategory` independently from replay/idempotency identity. | +| `messages-endpoint.test.js` (1) | Pending-target retry projection retired from browser History. | The same file now asserts that untouched/pre-admission queued user work is hidden until delivery and that another owner cannot gain queued body access; retry authority stays on the Queue endpoint. | +| `useSocket-thread-guard.test.ts` (1) | Socket-local sibling clearing no longer owns execution liveness. | The retained tests re-read canonical active-execution truth after lifecycle change and reconcile newer background cleared/completed events without letting stale hydration revive slots. | +| `CloudBindingRecoveryCard-races.test.tsx` (1) | Renamed to immutable retry-fence vocabulary. | `surfaces a stale immutable retry fence without duplicating the message` preserves the stale-response race assertion. | +| `CloudBindingRecoveryCard-state.test.tsx` (1) | Retry authority is carried by the exact immutable attempt rather than replaced by a client projection. | `retries only the immutable attempt carried by the recovery notice` preserves current-authority behavior without a second message. | +| `CloudBindingRecoveryCard-title-refresh.test.tsx` (1) | Pending-delivery polling was retired with the client-owned retry projection. | `does not poll retired retry authority or start title observation` fixes the read-only boundary explicitly. | +| `cloud-binding-recovery.test.ts` (1) | Waiting-state wording was replaced by exact retryable source/target projection. | The retained tests keep one exact recovery source, suppress only its linked notice, retain a standalone notice when the source is absent, and reject forged/cross-source metadata. | + +Counts: `14 + 4 + 3 + 3 + 3 + 2 + 2 + 2 + 2 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 42`. + +## 7. RFC invariant continuity and rejected-design absence + +The normative gate starts from the RFC and the co-creator's explicit model, never from a previously +reviewed tree. Manual UAT is a safety net for implementation drift, not the definition of correctness. +The table below binds each core invariant to its authority, production owner, and a regression that +distinguishes the intended model from a plausible but rejected transition design. Logic without one +of these authority bindings is treated as transitional/fallback behavior until its contract is found. +If implementation evidence shows that an accepted invariant is itself flawed, the change must be an +explicit RFC/operator pushback with its trade-off and replacement proof; a replay may not silently +rewrite the invariant, its specification, and its tests together. + +An operator quote is not interpreted in isolation. The audit follows the complete correction chain +and uses the latest explicit clarification that is consistent with the merged RFC. This matters, for +example, for Steer: `requested` is the persisted name of the user's sending strategy, not a third +runtime truth source. The intended default is therefore still exactly the persisted strategy plus +the member's static guidance capability; current-run state only affects execution-time admission. + +| Normative invariant | Authority | Production owner | Distinguishing regression | +|---|---|---|---| +| One Queue entry/source is the ordinary dequeue unit; after the whole target set is admissible, that set fans out concurrently. Ordinary drain never peels off the currently idle subset. | RFC audit §§2.4, 5.2, 5.5, 6.6. Public decision recorded here: queue manages an entry as one dequeue unit; after dequeue, its targets fan out concurrently. A busy sibling keeps the whole ordinary entry queued. | `QueueProcessor.tryExecuteNext*` requires `idleTargetCats.length === resolvedTargetCats.length`; `InvocationQueue.markProcessingGroup*Durable` claims the exact resolved set and creates independent target receivers. | `queue-processor.test.js`: `starts every idle target of one source before either target completes`; `keeps an exact target set queued when one sibling is busy`; `issue1371-prestart-recovery.test.js` distinguishes ordinary drain from explicit singleton Steer. | +| A blocked try-drain is a no-op; enqueue/cutover/terminal signals request another drain, which still processes at most one source head per attempt. | RFC audit §§6.3, 6.5 and scenario A8. Public decision recorded here: “cannot dequeue” is not a partial dequeue; the next relevant lifecycle signal retries the same head. | `QueueProcessor.requestDrain`, `drainThread*`, and terminal closure re-enter the per-thread coordinator only after the current durable transition. | `queue-processor.test.js`: `treats a blocked try-drain as a no-op and retries from the active target terminal`. | +| Different source messages are never merged into one durable entry, one body, or one History identity. | RFC audit §§3.5 L1–L2, 6.3 and impossible-state table. Public decision recorded here: each source, regardless of target count, remains its own ordered entry. | `InvocationQueue.enqueueExistingMessageDurable` and producer-specific ingress preserve `sourceRecordId`; Queue comparator advances one source at a time. | `queue-processor.test.js`: `admits consecutive FIFO sources separately without concatenating bodies`; `connector-invoke-trigger.test.js`: `keeps separate connector messages as separate deterministic ledger rows`. | +| `targets[]` is exactly the undelivered set. A committed dispatch/read adoption removes only its exact target; the final removal deletes the row. | RFC audit §§4.1, 5.2, 5.4, 6.6 and L1/L3. Public decision recorded here: pending targets shrink only through an actual cutover such as dispatch, Steer, or exact read adoption—not through projection or readiness probing. | `QueueLedger` / `InvocationQueue.commitClaimedLifecycleTarget` and target-reconciliation CAS mutate the one canonical row; History `dispatchRefs` own actual delivery. | `queue-ledger.test.js`: `deletes the pending Queue row when Steer removes its final target` and `deletes the Queue Entry when its final target is committed and retains no terminal tombstone`; QueueProcessor exposure tests retain pending siblings. | +| Ordinary user input without an explicit `@` binds a server-selected default before Queue admission instead of persisting an empty target. The selector is the most recent currently routable `completed` response target, then the configured default; source mentions remain empty because fallback is not authored text. | RFC audit §§5.4, 9 and scenarios A11–A13. Public decision recorded here: no-mention send continues with the recent completed responder/default; neither Web nor a later execution branch invents the target. | `messages.ts` calls `AgentRouter.resolveConversationTargetsAtAdmission` before atomic source+Queue admission; QueueProcessor calls the same resolver again for availability revalidation and historical targetless recovery; `/threads/:id/cats` exposes its read-only projection. | `message-lifecycle-ingress.test.js`: `binds an ordinary unmentioned input to the canonical conversation fallback before enqueue`, keeps stored `mentions=[]`, and `replays the originally admitted no-mention target after the conversation fallback changes`; `agent-router.test.js`: `canonical no-mention fallback binds the latest completed lifecycle responder before Queue admission`; `thread-cats.test.js` locks the shared projection. | +| Steer acts on one exact Queue entry: Guide appends non-interruptingly to the selected member's current invocation; Interrupt explicitly interrupts before processing. Guidance support is independently declared from provider read timing. | RFC audit §9 and the [public F117 owner decision](https://github.com/zts212653/clowder-ai/pull/1398#issuecomment-5747658094): Guide means append to the already running member invocation; Interrupt means interrupt first. `queued_internal_turn` may support Guide without claiming exact visible-turn cognition. | Shared `activeInvocationGuidance` capability and `supportsActiveInvocationGuidance`; `message-disposition-admission.ts`; Queue exact Append/Steer claims; provider adapters such as `ClaudeSdkAgentService`. | `live-carrier-agent-services.test.js`: `Claude SDK streams append into the active query and interrupts only for explicit steer`; `f264-author-message-disposition.test.js` proves capability and precision are independent; Queue/Web steer tests preserve the exact action and copy. | +| Successful dispatch is the delivery cutover. Each admitted target owns one response bubble; failure terminalizes that bubble and exact A2A caller lineage gets one idempotent `a2a_failure` fail-back—never a second public result or recursive wake. | RFC audit §§3.5 L4, 7.2–7.6 and scenarios A33/A66. | Queue admission creates target response/ref; route terminal transaction updates the same response and `commitCompletedResponseAndEnqueueA2ATargets` / failed-caller path emits the exact control wake. | `routing-dispatch-preflight.test.js` asserts one `a2a_failure` control carrier and no second public failure; lifecycle routing tests cover independent target terminals and failed caller delivery. | +| User, connector, plugin, and system public inputs enter the same canonical Queue/History path; only authenticated `from` / source category and producer fences differ. | RFC audit §§5.1–5.2 and L1–L3. | All producers call `InvocationQueue.enqueueExistingMessageDurable`; connector continuation additionally validates the typed wait/action owner before admission. | `connector-invoke-trigger.test.js` proves deterministic rows plus delivered-outcome/obsolete-generation rejection; message-lifecycle ingress tests prove the same author-intent envelope for user input. | +| Internal/private owner state never becomes a public failure message. Private pre-admission failure stays in private evidence/telemetry; public delivery failure stays on the canonical response bubble. A producer must not persist an internal notice and rely on API/Web filters to hide it. Unclassified provider diagnostics are likewise neither persisted nor rendered; only an explicitly classified `user_action_required` warning may become conversation truth. | RFC audit §§7.1–7.4, §16 and L1/L5. Public decision recorded here: redundant system diagnostics are deleted at their producer, not papered over at presentation; provider diagnostics that are not actionable user facts stop at the adapter boundary. | `QueueProcessor.terminalizeUnavailablePrivateHead` owns internal diagnostics; stop-gate remediation records telemetry only; `isUserFacingSystemInfoContent` and `persistUserFacingSystemInfoNotices` admit only classified public notices; public pre-admission/terminal paths use MessageStore lifecycle transactions. The History filter for `routing-guard-failure` is legacy-read compatibility only. | `turn-custody-shadow-route-telemetry.test.js`: `emits a redacted bounded sample for unknown_legacy + agree_block` proves a failed remediation emits telemetry without appending a hidden History row; `f296-oversized-rollout-notice-persistence.test.js` rejects unclassified provider warnings; `system-info-visible.test.ts` rejects the same warning from live chat; routing preflight tests reject a second public failure result. | +| Queue custody and shared History are different views of the same source, not duplicate timelines. Queued user/system/connector input remains outside shared History until actual admission; already-published agent speech may carry outbound Queue custody without being hidden. Routing warnings remain structured on the source instead of becoming a detached notice. | RFC audit §§5.1–5.4, §§7.1–7.4 and L1–L3. Public decision recorded here: the Queue owns pending delivery; History owns delivered conversation truth. | `isTimelinePublished`, MessageStore's atomic source+Queue admission, and History `dispatchRefs` form the only publication boundary. | `messages-endpoint.test.js`: `keeps untouched durable queued user work out of History until admission`, `publishes a queued user source only at actual delivery without a Queue receipt`, and `keeps structured routing warnings attached to the delivered input`; `queue-ledger-redis.test.js`: `adds Queue custody to published Agent speech without hiding the History source`. | +| Steer presentation has only two default inputs: the user's persisted sending strategy and the selected member's static guidance capability. Live invocation state and the earlier effective fallback do not rewrite that preference; they are consulted only when the action is admitted and executed. | RFC audit §9. Public decision recorded here: the stored `requested` disposition is the sending strategy; capability is configured client truth, while runtime availability is not a third default source. | `QueuePanel` projects `authorIntentByTarget[target].requested`; `SteerQueuedEntryModal.defaultStrategy` combines that value with `supportsActiveInvocationGuidance`; `message-disposition-admission.ts` resolves the actual parent/fallback. | `queue-panel-steer.test.ts`: `keeps guide capability and default strategy independent of active-run state` and `uses requested per-target disposition instead of the live effective fallback`. | +| One target attempt owns one visible processing/terminal response bubble. Streaming, success, provider failure, cancellation, and full diagnostic text update that identity; lifecycle stages and internal owner conflicts never create a second public notice. | RFC audit §§7.1–7.6 and L4–L5. | MessageStore lifecycle transactions and response-owned presentation metadata update the existing response; diagnostics stay process-local or private until a real public target result exists. | `message-lifecycle-store.test.js`: `replaces one processing bubble in place and replays only the exact terminal`, `atomically completes the same response bubble with its outbound ledger admission`, and `advances one target monotonically while preserving the exact response bubble`; `f296-oversized-rollout-notice-persistence.test.js` keeps lifecycle stages out of History. | +| A caller learns each dispatched source×target result from canonical response/ref facts on its next natural turn. Separate sources remain separate even for the same target/run; no semantic callback, durable custody clone, or synthetic wake is required to claim success. | RFC audit §§7.2–7.6 and L2/L4. | `CallerDispatchObservationRegistry` plus the bounded `CallerDispatchObservationIndex` follow exact source/ref/response lineage and clear only facts included in a successful prompt. | `caller-dispatch-observation-registry.test.js`: `keeps same-target dispatches from different sources independent and projects every response terminal`; `queue-processor.test.js`: `injects a caller terminal observation on its next natural turn and clears only after success` and `registers a persisted direct delivery failure for the caller without creating a wake`. | +| Hold, typed-wait, connector continuation, and ordinary system wake use the same authenticated Queue ingress and response lifecycle. Their task/owner records fence who may enqueue or retire the source; they do not introduce a second completion/disposition protocol. | RFC audit §§5.1–5.2, §§7.2–7.6 and L1–L5. | `ManagedCommandWakeRecoveryEngine` / sweep publish one system source through `enqueueExistingMessageDurable`; typed wait/action ownership and exact carrier identity fence replay and retirement. | `managed-command-wake-recovery-sweep.test.js`: `dispatches strict-owner holds through canonical urgent Queue ingress`, `event carrier is not duplicated and retires only from exact F264 handled truth`, `consumes a failed wake from its canonical response terminal without manual disposition`, and restart cases prove exactly-once recovery. | +| Every timeline-published reply is shared unread/History truth for every authorized thread member, regardless of whether its author is the operator or another cat. Visibility is allocated by the canonical MessageStore commit; a sender-kind filter must never silently drop cat-authored speech. | RFC audit §§5.3–5.4, §§7.2–7.6 and Phase E. Public decision recorded here: members resume from one shared conversation, not an operator-only unread stream; exact whisper ACL remains the only narrower visibility rule. | MessageStore terminal commits allocate `visibilitySeq`; `ContextAssembler`, callback History reads, and delivery cursors consume the same visibility order and `messageFrom` identity. | `get-message-visibility.test.js`: `context includes other-cat persisted stream-origin speech in play-mode thread`; `message-lifecycle-redis-store.test.js`: `publishes a lifecycle response to cursor reads only with its terminal body`; `f254-provider-native-freshness.test.js`: `treats a visible other-cat stream body as unread at a play-mode safe boundary`. | +| A delivered hold/wait fact is shared conversation truth: the operator and every authorized thread member read the same History source. A queued wake stays outside cat context until actual Queue admission. | RFC audit §§5.1–5.4 and Phase G. Public decision recorded here: waiting state is not a private operator-only projection. | MessageStore owns the waiting source; Queue publication and `ContextAssembler` apply the ordinary delivery boundary without a hold-specific view. | `context-assembler.test.js`: `includes delivered owner-bound managed hold facts for the shared user/cat view` and `keeps managed-hold wake sources out of cat context until Queue admission`. | +| Queue presentation is the source body plus one sender → target route. Pending targets come from the Queue row; delivered/read targets come from source `dispatchRefs`. It exposes neither internal ids nor a second receipt structure. | RFC audit §§5.1–5.4 and Phase C. Public decision recorded here: the Queue card is a readable delivery view, not scheduler diagnostics. | `QueuePanel` joins the canonical source, Queue target set, and response-linked `dispatchRefs`; no Queue-owned receipt is materialized. | `queue-panel-steer.test.ts`: `renders friendly pending targets without internal queue diagnostics`, `refreshes a multi-target route from Queue targets and source dispatchRefs without a receipt copy`, and `reads successor delivery from response-source dispatchRefs without a Queue-owned receipt`. | +| Source delivery avatars and response bubbles consume the same response/ref/active-execution facts. Source kind changes alignment only; it never creates a second lifecycle state machine. | RFC audit §§7.1–7.6 and Phase C. | `MessageDispatchAvatars` resolves exact source `dispatchRefs` to the same processing response and `LifecycleActiveRun` used by the response bubble. | `message-dispatch-avatars.test.tsx`: `blinks and links only for the exact processing response and ActiveRun`, `uses animation only for processing and adds no terminal outcome badge`, and the user/cat alignment cases reuse that state. | +| A committed Steer mutation remains visible to the caller as selection facts: removed C is not delivered, retained D remains initially selected, and added E is marked Steer-selected. These facts annotate canonical Queue/History truth; they do not replace it. | RFC audit Phase H and L2/L4. | `CallerDispatchObservationRegistry.registerInitialSource/registerSteerChanges` records the bounded runtime selection delta and projects canonical source status on the caller's next natural turn. | `caller-dispatch-observation-selection.test.js`: `retains initial and Steer selection facts after a target leaves Queue`. | +| Terminal courtesy suppression applies only to prose with no explicit routing credential. A line-start `@cat` or structured `targetCats` is an instruction: after terminal it mints a fresh active coordination generation, enters ordinary Queue admission, and remains bounded by the same loop-streak guard—even if the sender also supplied the stale `phase=terminal` label. | RFC audit callback-routing boundary and the public tool contract. Public decision recorded here: text and structured routing syntax cannot be downgraded to a silent ACK by lifecycle projection. | `callbacks.ts` parses routing intent before `resolveCrossThreadCoordination`; `cross-thread-coordination.ts` distinguishes quiet terminal ACK from a new explicitly addressed active hop. | `cross-thread-review-affinity.test.js`: implicit, explicit-terminal, and same-id explicit-terminal target cases; `cross-thread-coordination-chain.test.js` proves unaddressed courtesy suppression plus line-start and targetCats-only wake end to end. | +| Ordinary callback send has no forced-read/HELD escape hatch. Phase-A post-message freshness and `acknowledgeHeld` are retired; exact output-commit closure, nonblocking freshness notice, and CAS refinement remain because they protect output truth rather than block transport. | RFC audit Phase F and the F254 retirement decision. Public decision recorded here: sending cannot become an inbox-reading side effect, while current-turn output must still close against durable freshness truth. | Production `callbacks.ts` has no `checkFreshnessForPostMessage`, `acknowledgeHeld`, or `markQueuedNotified`; `FreshnessOutputCommitCoordinator` and closure store remain wired at serial/parallel answer exits. | `f254-phase-e-closure-wiring.test.js`: `retires post-message freshness/HELD side effects while preserving output truth refinement`. | +| User-facing send strategy has only persistent Thread/global scopes; there is no one-shot or “restore inheritance” control and no redundant Queue explanation. Independent sampling has one concise contract and no repeated member controls/cost display. | RFC audit Phase E and UI simplification decisions. | `MessageDispositionSelector` / `ChatInput` own persistent strategy; `IdeateHeader` owns the one independent-sampling explanation. | `chat-input-message-disposition.test.ts`: `offers only persistent scopes without inheritance controls or redundant explanatory copy`; `ideate-header.test.ts`: `explains independent sampling without duplicating execution controls or usage`. | +| Cancellation/interruption is explicit lifecycle truth. A canceled own response says the user canceled it; an interrupted response says it was interrupted, without “previous turn” or harness language. | RFC audit §§7.2–7.6 and L4/L5. | `lifecycleResponseContext` projects the exact response terminal and reason into shared context; UI hydrates the same lifecycle status. | `lifecycle-response-context.test.js`: `#1398: explains that a user-canceled own response was stopped by the user`, `#1398: projects the current cat own empty interrupted response as lifecycle context`, and the preemption wording regression. | +| Stop operates on the exact live execution owner: per-member Stop cancels only that thread/member execution, while Stop-all cancels the authenticated thread scope. It does not edit Queue truth, manufacture an intermediate public state, or kill an unrelated same-cat execution. | RFC audit execution-control boundary and L4–L5. | `ActiveExecutionOwnerService`, `InvocationTracker.cancelInvocation` / `cancelAll`, and provider process ownership bind control to exact execution identities; terminalization then follows the ordinary response chain. | `f295-active-execution-projection.test.js`: `cancels only the exact thread execution when the same cat runs elsewhere`, `treats a stale exact Stop as the same per-cat intent and stops the current replacement`, and `persists control-plane failure through the ordinary response and input settlement chain`. | +| Member configuration has one canonical `carrier` coordinate. Legacy spelling is normalized only at the configuration read boundary; invalid client/carrier pairs fail closed, and a selected carrier never silently falls back to another transport after launch failure. | RFC audit Phase F and AC-F1/AC-F2. Public decision recorded here: compatibility is one boundary adapter, not scattered provider/UI precedence or a runtime fallback chain. | Cat config normalization emits top-level `carrier`; catalog/provider factories and Web read only that value and the shared capability matrix. | `cats-routes-runtime-crud.test.js`: `POST and PATCH /api/cats persist canonical carrier and enforce the client matrix`; `hub-member-card-actions.test.tsx` rejects provider-specific precedence; `hub-cat-editor.test.tsx` writes and renders the canonical carrier. | +| A newly opened thread projects its first submitted source without requiring a page refresh or thread switch. Recovery is one bounded History catch-up requested only after initial History and Queue hydration both settle, Queue proves an active invocation, and the settled History is still empty. | RFC audit Phase E and the canonical History/Queue publication boundary. Public decision recorded here: an initial render race cannot manufacture a second timeline or require navigation as recovery. | `useChatHistory` consumes the thread-scoped `requestStreamCatchUp` version used by the WebSocket path; it neither starts Queue hydration again nor publishes queued work. | `useChatHistory-queue.test.ts`: `reconciles empty History when queue hydration discovers an active invocation` and `does not request catch-up when slower initial History already contains messages`. | +| A response has one runtime-canonical cat signature at the real successful stream end. Provider chunks, resumed internal turns, and quoted/other-cat signature-like text cannot append duplicate signatures; failed or interrupted terminals do not fabricate a success signature. | RFC audit response-identity boundary and L4/L5. Public decision recorded here: signature is response presentation, not per-provider-turn output. | Codex event transformation strips only the current cat's transport-added terminal signature and appends one canonical final signature after successful exhaustion. | `codex-event-transform.test.js`: `completed Codex turns keep all prose but emit one canonical final signature`, `multiple completed turns keep the canonical signature at the real stream end`, and negative controls for failed/interrupted terminals and quoted signature-like text. | +| Compatibility is centralized at the read boundary. New writes persist canonical `from`; legacy `userId`/`catId`/connector inference occurs only in `messageFrom`, and an unparseable connector can never be promoted to user authority. No producer-specific dual-write/fallback branch may recreate identity. | RFC audit §§3.5 L1 and the migration boundary. | `message-from.ts` is the single legacy normalization boundary; current producer and consumer paths use typed `MessageFrom`. | `message-from.test.js`: `uses canonical MessageFrom without re-inferring identity from compatibility projections`, projects known legacy sources, and `keeps an unparseable legacy connector outside user authority`. | +| Ordinary no-mention fallback is distinct from invalid authored routing. An empty mention set binds the recent responder/default before admission. Explicit but invalid mentions keep their structured warning and a targetless Queue head: they wait while the thread is active, then the canonical idle resolver may choose a recent/default responder; only “still no resolvable target” becomes failure. The invalid authored target is never silently treated as valid. | RFC audit §§5.4, §9 and scenarios A11–A13. | `messages.ts` gates pre-admission fallback on the absence of authored routing warnings and persists warnings on the source/Queue payload; `QueueProcessor` applies the shared targetless idle barrier and resolver. | `message-lifecycle-ingress.test.js`: no-mention binding, unavailable composer selection, and warning-on-source regressions; `queue-processor.test.js`: `keeps a targetless head queued while the thread is active and resolves it only after idle`. | + +After the authority-first table is satisfied, comparing the last pre-replay tree `be84940fe` with the +candidate (and subtracting intervening latest-main changes) is still useful as a mechanical alarm. It +can reveal a missing fence or a returning fallback, but it cannot prove correctness: both trees may +share the same bug, and an accepted implementation may legitimately be rewritten while preserving +the RFC invariant. + +| Normative behavior | Current owner / replay evidence | +|---|---| +| A newly opened thread shows its first submitted message without requiring a thread switch. | `useChatHistory` reconciles an initially empty History only after both History and Queue hydration settle and Queue proves an active invocation. The positive race test requires the catch-up request; the inverse slow-History test keeps the request count at one. | +| 「立即发送,引导回复」appends non-interruptingly to the selected member's current invocation; 「中断回复」interrupts it. | The shared `supportsActiveInvocationGuidance` predicate is consumed by catalog projection and admission. Each adapter declares guidance support independently from `deliverySemantics`; Claude Agent SDK declares `supported` plus `queued_internal_turn`, so it can guide the same run without claiming exact consumption by the visible provider turn. Its tests retain ordinary append versus forced interrupt and exact settlement coverage. | +| Guide availability is static configured-client capability, not a UI guess from current liveness. | `/api/cats` projects the shared predicate. Web parses the shared provider/carrier/semantics vocabulary and uses canonical active-run state only to determine whether an exact parent exists. | +| Queue pending work is not a second History/receipt timeline. | Queue UI reads canonical History `dispatchRefs`; no receipt dock, queued-message custody coordinator, queue carrier source projection, or socket-local unsettled receipt is restored. | +| Multi-target delivery remains one source with independent target terminals. | QueueProcessor and multi-mention regressions retain independent target append/restore, partial-target handling, and source handoff ordering. | +| Writer contention, duplicate status chrome, and owner-less fallbacks stay retired. | Queue ledger CAS/claim tests, processing-bubble rendering tests, and exact execution reconciliation tests retain the accepted single-owner behavior. | +| Empty terminal lifecycle markers and caller signatures remain visible without duplicating ordinary self output. | Lifecycle response context and signature propagation tests remain present after replay. | +| Sampling/observation remains bounded and independent of durable custody. | `CallerDispatchObservationIndex` is a mechanical extraction from the reviewed registry; focused observation tests retain its bounded process-local contract. | + +The production-source absence census has zero hits for the rejected owner/surface names +`hasCurrentReply`, `MessageReceiptDock`, `QueuedMessageCustodyCoordinator`, +`QueueCarrierSourceProjection`, `QueuedMessageCustodyStartupQueueEntry`, `queue-entry-settlement`, +`reminderMode`, `oneShotDisposition`, `PREEMPT_PENDING_PRESTART`, +`freshnessRequiredFrontierMessageId`, `hasUnsettledQueueReceipt`, +`complete_a2a_dispatch`, and `complete_managed_hold`. + +A second test-name audit covered 88 feedback-scoped retained test files. Only three reviewed names are +absent: the MCP `subjectRef` case was renamed and strengthened with invocation authentication; the +mobile approval-sheet case was replaced by the canonical F202 workspace interaction; and the Codex +pre-turn retry case was renamed and strengthened with typed capability policy. No feedback-scoped +behavior assertion disappeared without a named replacement. + +## 8. Auxiliary F117-side line-level residual ledger + +Sections 1–6 start from latest main and ask what the replay removed. Section 7 supplies the normative +gate: RFC/operator invariant → production owner → distinguishing test. This reverse line audit is +only an auxiliary alarm. It starts from the earlier F117 patch `c0cf29f0a..be84940fe`, extracts every +non-trivial line added in a production source file, and asks whether the candidate still contains +that exact line. A hit must be resolved against section 7; the earlier tree is not an oracle, and an +exact retained line is not evidence that the line is correct. + +The discovery snapshot at `0c5614702ec2` covered 342 F117-touched source files. Nineteen files had +141 textual residuals; no reviewed source file was wholly absent. The counts below intentionally +preserve that discovery snapshot even where this correction restores the exact line, so the finding +cannot disappear from the audit record. + +The final exact-HEAD rerun covers the same 342 files and reports 20 files / 160 exact-line residuals, +with zero reviewed source files absent. The increase is expected and attributable: `messages.ts` +adds 13 residuals because the reviewed targetless/late-Guide branch was replaced by canonical +no-mention binding before atomic admission; `QueueProcessor.ts` now reports 11 because four copied +reservation-replaced blocks were consolidated into one helper that distinguishes true ownership +replacement from a sibling becoming busy and restores the exact claim for the latter; +`message-disposition-presentation.ts` reports 2 after guidance capability was separated from read +precision. Conversely, `ConnectorInvokeTrigger.ts` falls from 53 to 49 after restoring its wait +continuation fence. These are named behavioral corrections, not unexplained replay loss. + +| Earlier F117 source | Residual lines | Disposition against the normative invariant | +|---|---:|---| +| `packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts` | 53 (discovery), 49 (final rerun) | The reviewed private `enqueueCanonical` wrapper was inlined into the public trigger while canonical source ownership, Queue admission, queue-full projection, action-successor fencing, and drain scheduling moved to the current Queue APIs. The replay incorrectly dropped `assertCurrentWaitContinuationCarrier`; this correction restores its typed `ITaskStore` dependency and fail-closed pre-admission call alongside the newer action-successor lease check. The delivered-outcome and obsolete-generation regressions distinguish this fence from idempotency. | +| `packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.ts` | 42 | The bounded map, pruning, acknowledgement, and fingerprint bookkeeping were mechanically extracted to `CallerDispatchObservationIndex`; the registry delegates to that index. Caller-observation tests retain the same limits and acknowledgement behavior. | +| `packages/api/src/routes/messages.ts` | 13 (final rerun) | The old late targetless Guide branch is intentionally absent. Ordinary no-mention input now resolves the recent completed responder/default before the atomic source+Queue admission, persists that Queue target while keeping authored `mentions=[]`, and reuses the originally admitted target on an idempotent retry. Invalid authored mentions retain their warning and do not silently take this ingress fallback. | +| `packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts` | 12 | F306 rewrote the two ingress branches around `appendIdempotent`, exact public-participation identity/revision checks, and the canonical Queue source contract. Visible events still materialize once; agent events still use one Message+Queue admission and then drain. The newer participation fence and standing-work hook replace the old unscoped append/enqueue lines rather than bypassing F117. | +| `packages/web/src/hooks/useActiveExecutionProjection.ts` | 9 | Active-execution hydration changed from a thread URL to the canonical project-scoped projection. The hook now resolves project identity from the sidebar/chat projections and passes it through refresh, cancel convergence, reconnect, and polling; the same refresh triggers remain. | +| `packages/api/src/domains/cats/services/agents/routing/route-helpers.ts` | 3 | The visibility predicate is expanded over `loadedUnseen`, and baton extraction now filters already-answered legacy delivery boundaries before formatting navigation. The `canViewMessage` gate and extracted baton remain; the missing exact lines are a control-flow rewrite, not a fallback owner. | +| `packages/api/src/index.ts` | 3 | Direct Claude constructors were replaced by `createClaudeAgentServiceForCanary` for non-SDK carriers, preserving the F198/F230 `print` / `bg_daemon` / `interactive_pty` selection. SDK still uses `ClaudeSdkAgentService`; factory and composition regressions cover both creation sites. | +| `packages/web/src/components/cloud-binding-recovery-operations.ts` | 3 | Retry hydration now expresses the same immutable-attempt state as one conditional object, and HTTP 409 is the authoritative stale-fence result even when an intermediary strips the body code. The retained cloud-binding race/state tests cover missing attempt identity and stale authority. | +| `packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts` | 2 (discovery), 11 (final rerun) | The two genuine replay regressions remain closed: ordinary drain requires the complete exact target set, while singleton Steer/read adoption remains separate. The additional final residuals consolidate repeated pre-start cancellation blocks into one owner-aware helper: a true reservation replacement skips stale settlement, while a sibling becoming busy restores the exact claimed group, cancels only the unstarted attempt, and leaves custody queued. The distinguishing #1371 tests cover both that race and cross-user orphan recovery. | +| `packages/api/src/domains/cats/services/agents/routing/route-parallel.ts` | 2 | Provider-error selection and `lifecycleErrorOwnedByResponse` remain at all three terminal persistence sites; surrounding terminal-reason and user-facing notice composition changed their exact line shapes. Counts and focused lifecycle-error tests confirm no duplicate system error owner was restored. | +| `packages/api/src/routes/callbacks.ts` | 2 | `isTimelinePublished` remains on exact-message and neighbor reads, with an explicit exception for an authenticated readable managed-hold source. Timeline publication is still checked three times; the broader expression does not publish ordinary queued work. | +| `packages/web/src/hooks/useSocket.ts` | 2 | The two direct active-execution refresh calls now go through `refreshActiveExecutionForThread`, which resolves canonical project identity before calling the project-scoped endpoint. Spawn and response-lifecycle events retain their refresh triggers. | +| `packages/api/src/domains/cats/services/agents/providers/ClaudeSdkAgentService.ts` | 1 | `exact_active_turn` was an overclaim and is intentionally `queued_internal_turn`. This does not disable guidance: the adapter independently declares `activeInvocationGuidance: supported`, and the shared predicate reads that capability instead of inferring it from precision; UI copy distinguishes exact visible-turn consumption from the next internal turn. | +| `packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts` | 1 | The active-writer wait signal call is retained in the recovery stream and emits once after the same threshold; formatting and adjacent F306 recovery composition changed the extracted line shape. The active-writer notice tests remain the behavioral evidence. | +| `packages/api/src/domains/cats/services/freshness/FreshnessAttentionEventLog.ts` | 1 | The missing line is a superseded comment for the retired adoption vocabulary. Durable target adoption is represented by Queue target removal plus History dispatch/body-exposure evidence; no second freshness custody store is reintroduced. | +| `packages/api/src/routes/cats.ts` | 1 | The inline `=== 'exact_active_turn'` comparison was replaced by the shared guidance-capability predicate. This is required to keep append capability distinct from provider read timing and is covered jointly with disposition admission and Web parsing. | +| `packages/mcp-server/src/server-toolsets.ts` | 1 | Tool invocation now prefers `runWithExtra` so cancellation can reach implementations, then normalizes the returned record into the same typed MCP result. The ordinary `tool.handler` fallback remains; tool-registration and MCP contract suites cover the composed surface. | +| `packages/web/src/components/ChatMessage.tsx` | 1 | The message header condition adds provider subexecution events to the reviewed `catStyle || supplement` cases. F306 subexecution rendering is additive; supplement and ordinary cat headers remain covered. | +| `packages/web/src/components/message-disposition-presentation.ts` | 1 (discovery), 2 (final rerun) | The single exact-turn label became a semantics-aware branch: exact delivery says the current reply will read it, while queued-internal delivery says the current run's next internal turn will. Both still present the accepted 「立即发送,引导回复」 action. | +| `packages/web/src/hooks/system-info-visible.ts` | 1 | Routing preflight presentation now handles both `warned` and `rejected`: `allowed` remains hidden, warned attempts remain visible as attempts, and rejected attempts say they were not executed. Stable reason-code copy replaces the rejected-only condition; this is a presentation expansion, not a new routing authority. | + +The acceptance gate is authority-first and then symmetric: + +1. **RFC/operator model → candidate:** every core behavior must appear in section 7 with a primary + authority, one production owner, and a test that fails for the known competing design. No source + tree, passing generic suite, or manually accepted build substitutes for this gate. +2. **main → candidate:** run the missing-main declaration/file/test and unwired-export audits from + sections 1–6; every residual needs a retired owner, replacement owner, and evidence. +3. **earlier F117 → candidate (auxiliary only):** run the line audit above and the feedback-scoped + test-name audit; every residual needs a per-file disposition against a section-7 invariant. Exact + comparisons at admission/security boundaries require a distinguishing regression, not merely a + passing test that accepts both behaviors. +4. **candidate → both baselines:** run the rejected-surface census and composition-root audit so a + renamed fallback, second truth source, or disconnected replacement cannot pass by textual + coincidence. + +For the corrected candidate, the two security/behavior findings above are closed only by their +specific regressions: stale GitHub-wait outcomes fail before Queue admission, and an ordinary +multi-target drain leaves the exact source row untouched unless every resolved target is admissible. + +The final two commits use `--no-verify` only after the repository pre-commit hook ran its 787-file +Biome index scan successfully. The implementation hook then stopped on three inherited full-tree +brand checks (`SplitPaneView.tsx` brand text, connector frontend-port fallback, and `AgentRouter` +API-port fallback) plus the expected 1224-class quarantine advisory. The docs hook stopped on four +inherited public-brand strings in already tracked architecture/feature-index files. None is +introduced by the final correction delta. This bypass does not waive the exact-HEAD build, focused +regression, public CI, state-migration quarantine, or seven-day witness gates recorded in this +ledger. + +## 9. Runtime cutover and rollback boundary + +The Redis migration implemented by this candidate is narrower than a migration from the current +`main` / fork runtime. `RedisQueueLedgerStore.ensureThreadMigrated()` lazily migrates one accessed +thread inside the QueueLedger key family: + +- `queue:{}:entries` +- `queue:{}:order` +- `queue:{}:messages` +- `queue:{}:schema` + +It reads QueueLedger v1/v2 rows, derives one v2 source row plus its order and message index, validates +the exact preimage in Lua, deletes the first three QueueLedger keys only after validation, rewrites +them, and sets schema `2`. A completed migration is idempotent; a concurrent change retries the CAS, +and an interrupted call can retry because the Lua replacement is atomic. + +That code does **not** import the actual pre-F117 runtime baseline. Current `main` and the existing +fork runtime keep pending Queue ownership on MessageStore `msg:` hashes as `queueCustody`, +`queueCustodyRevision`, and `queueCustodyAdmission`. The candidate removes that reader and startup +scans the new QueueLedger keys; there is no converter that enumerates those MessageStore fields and +creates v2 source rows. Therefore starting this candidate over a runtime with pending legacy custody +would make that work absent from the new Queue projection. This is not a safe in-place historical +migration and must not be represented as one. + +The fork dogfood cutover consequently uses a **drain-first operational gate**, not silent migration: + +1. stop admitting new work for the cutover window; +2. prove the legacy Queue is empty and no Agent execution or managed wake is in flight; separately + inventory durable hold / typed-wait registrations instead of inferring their safety from an empty + Queue; +3. take a full Redis backup before the first new-code startup; +4. start the exact reviewed candidate and verify Queue, History, active execution, hold, and typed-wait + projections before reopening traffic. + +Rollback is code **plus data**. Pre-F117 code cannot read QueueLedger v2 or the new lifecycle facts, +so returning only to `backup/develop_base-pre-rebuild-` is insufficient after new-code writes. +The rollback recipe is to stop the candidate, restore the pre-cutover Redis backup, return to the +backup code ref, and only then restart. The runtime worktree is not modified or restarted by this +ledger; the operator controls backup and restart timing. + +This drain-first gate is acceptable only for the isolated fork experience lane. It does not satisfy +the upstream historical-state replay, mixed-version consumer, rollback/off-mode, or seven-day +exact-HEAD witness gates. Upstream merge remains blocked until an actual MessageStore-custody +migration/quarantine path and those proofs exist, or the maintained deployment contract explicitly +rules out historical pending state with independently verifiable evidence. diff --git a/docs/architecture/message-delivery-handling-handoff-audit.md b/docs/architecture/message-delivery-handling-handoff-audit.md index 4c6c17b18c..ef8c7a85f6 100644 --- a/docs/architecture/message-delivery-handling-handoff-audit.md +++ b/docs/architecture/message-delivery-handling-handoff-audit.md @@ -5,7 +5,7 @@ doc_kind: architecture feature_ids: [F039, F055, F078, F117, F122, F167, F175, F177, F194, F233, F254, F264, F275, F277, F280, F296] topics: [message, queue, delivery, execution, a2a, routing, history, failure, observability] created: 2026-08-13 -updated: 2026-09-04 +updated: 2026-09-14 status: approved architecture_cell: "dispatch + ball-custody + bubble-pipeline + approval-index" architecture_map_delta: "none — consolidates existing delivery, custody, result, and responsibility owners; adds no canonical cell or parallel ledger" @@ -46,7 +46,7 @@ Why: the RFC makes one end-to-end contract from existing owner facts so implemen 一个 thread 同时容纳用户、Agent、Connector 与系统消息。消息可能需要等待、投给一个或多个成员、由用户追加到正在执行的成员,或者由用户 Steer 立即纠正方向。 -当前实现的问题不是状态多,而是 owner 边界没有在一张图里说清:Queue/custody、body exposure、structured action/wait、InvocationRecord/TurnExecution 与 UI projection 本来回答不同问题,却经常被一个模糊的“已投递 / 正在处理 / 已完成”互相代替。局部补丁又让普通排队与 Steer 竞争,最终出现 client 已失败但 Queue 停住、消息已经显示却没有结果、或者没有 Agent 执行但 Queue 仍静默积压。 +当前实现的问题不是状态多,而是 owner 边界没有在一张图里说清:Queue pending intent、History dispatch/result、structured action/wait、InvocationRecord/TurnExecution 与 UI projection 本来回答不同问题,却经常被一个模糊的“已投递 / 正在处理 / 已完成”互相代替。局部补丁又让普通排队与 Steer 竞争,最终出现 client 已失败但 Queue 停住、消息已经显示却没有结果、或者没有 Agent 执行但 Queue 仍静默积压。 本文只回答一条消息从出现到结果的生命周期: @@ -62,18 +62,18 @@ Why: the RFC makes one end-to-end contract from existing owner facts so implemen 4. 稳定状态下不可能出现“Queue 非空、没有 Active Run、也没有 drain owner”; 5. 每次被 admission 的运行先有固定响应气泡,成功、失败、取消、重启都原位终局; 6. Steer、Append、Cancel queued 与 Stop running 是用户对具体 entry/run 的显式操作,不是正常调度的补救机制; -7. Agent 内部复用现有未读 cursor 与上下文投影;用户侧在承载被投递正文的公开 History 消息自身下,用每个实际 target 的成员头像表达 processing/terminal 投影。public conversation 的同一 `sourceRecordId` 只有在 admission 后取得 History membership 才承载头像;admission 前的 Queue 投影与始终隐藏的 `private_input` 都不承载头像,也不引入 read receipt; +7. Agent 内部复用现有未读 cursor 与上下文投影;用户侧在承载被投递正文的公开 History 消息自身下,用每个实际 target 的成员头像表达 processing/terminal 投影。public conversation 的同一 `sourceRecordId` 只有在第一次实际 dispatch 后取得 History membership 才承载头像;admission 前的 Queue 投影与始终隐藏的 `private_input` 都不承载头像,也不引入第二套 read receipt; 8. 不同来源已经给定消息用途;入口只把来源 envelope 封装为对应 QueueEntry。所有 `private_input` 与公开 entry 写入同一 durable priority Queue,彼此只在 inline payload 正文上不同;action fence、wait carrier 或 callback carrier 等 typed state 留在各自 owner store,由 owner 以 exact invocation 绑定并在 terminal 提交自身 disposition。 -9. 每条实际被投递的公开 History 消息为每个 exact target 保存一条单调的 `dispatchRef` 因果边;消息可以由用户、成员、外部通知或系统产生,也可以是带下一跳 target 的 completed response bubble。阶段只表达尚待 dispatch、已经 dispatch 或已经结束,具体成功、失败、取消与中断仍由关联结果记录唯一表达。 -10. enqueue-time target、Queue admission、provider body exposure、source handled 与 responsibility completed 是五个不同事实;任何一个都不能冒充另一个。 +9. 每条实际被投递的公开 History 消息为每个 exact target 保存一条单调的 `dispatchRef` 因果边;消息可以由用户、成员、外部通知或系统产生,也可以是带下一跳 target 的 completed response bubble。Queue `targets[]` 只保存尚未 dispatch 的成员;具体成功、失败、取消与中断仍由关联 response 记录唯一表达。 +10. enqueue-time target、实际 dispatch、provider presentation、response terminal 与 structured responsibility completed 是不同事实;任何一个都不能冒充另一个。 ### 1.3 非目标 本文明确不设计: - 持久化 Active Run、重建 provider client 或重启后自动重放; -- 重新实现现有 Queue custody 的 per-target attempt、exact body exposure、seen/handled owner;本文只规定它们怎样与 delivery kernel 对接,不复制其 canonical state; -- 面向用户新增一套已读/未读回执,或把 `dispatchRefs` 升格为 body exposure / handled / responsibility receipt; +- 在 Queue 中保存 per-target attempt、seen/handled 或 terminal owner;这些事实分别归 History response lifecycle、普通未读 cursor、provider presentation 与 structured owner; +- 面向用户新增另一套已读/未读回执,或把 `dispatchRefs` 升格为 provider-presented / responsibility receipt; - 把 `private_input`、action/wait carrier 或其他隐藏协议输入公开成 Chat History 消息、Queue Panel row 或头像锚点;未来若需要公开,必须另行定义可见性、迁移与权限契约; - 将多条消息拼成一条正文、覆盖消息边界,或绕过 Queue 顺序的 batching; - Queue entry 的 `queued / processing / handled / failed` 状态机; @@ -82,7 +82,7 @@ Why: the RFC makes one end-to-end contract from existing owner facts so implemen - 成员运行时内部如何 compact context、切换/续接 session、handoff continuation 或触发下一段执行;这些都封装在 Agent Client 内,不扩张 Queue / History / Active Run 模型; - 每种 provider 的具体 append、steer、cancel RPC。 -外部等待、action successor、callback principal 与 predecessor failure return 可以从各自上游协议产生一条已经定型的 `private_input` 来源消息。owner 可以只提交自身状态变更而不唤起 Agent;只有 owner 已经决定“需要 exact target 继续处理”并签发 envelope 时,Queue 才出现一条 `private_input`。主生命周期不从 payload 反推这个决定,也不复制 owner 状态或等待记录形成第二条 Queue / 平行 lifecycle。 +外部等待、action successor 与 callback principal 可以从各自上游协议产生一条已经定型的 `private_input` 来源消息。owner 可以只提交自身状态变更而不唤起 Agent;只有 owner 已经决定“需要 exact target 继续处理”并签发 envelope 时,Queue 才出现一条 `private_input`。普通 A2A completed/canceled/interrupted 不属于这组 producer;A2A failed terminal 则原子提交一条引用 canonical failed response 的 exact `message_wake`(`sourceCategory=a2a_failure`)。它是幂等调度控制边,不是第二条结果或平行 lifecycle,消费时禁止递归 fail-back。 ## 2. 先看一条消息如何完整走完 @@ -91,7 +91,7 @@ Why: the RFC makes one end-to-end contract from existing owner facts so implemen 1. **来源形成消息**:用户、Connector、系统、Agent 或已有协议 owner 已经决定消息正文、用途与 targets;主生命周期不重新猜用途。 2. **持久入 Queue**:入口把来源 envelope 封装为一条 QueueEntry。公开输入、Agent wake 与私有输入都写入同一 durable priority Queue;默认 `normal`,同级追加到 FIFO 尾部;commit 后触发 `requestDrain(threadId)`。 3. **严格处理 comparator 队首**:per-thread drain 只看 `position → urgent/normal → enqueue FIFO` 算出的唯一 head。目标忙时等待;目标可用或存在确定失败时继续,绝不靠 timer 扫描补救。 -4. **Admission 一次切换**:一个事务 exact-take Queue input,按 kind materialize 或复用实际被投递的 History message,把每个 actual target 的 ref 切到 `dispatched`,并为每个 actual target 创建固定 processing response bubble;事务提交后才建立内存 Active Run 并调用 Agent Client。 +4. **Admission 一次切换**:短 claim 本次 actual targets,按 kind materialize 或复用实际被投递的 History message,把每个 actual target 的 ref 切到 `dispatched`,并为每个 actual target 创建固定 processing response bubble;已 admission target 从 Queue Entry 的 `targets[]` 离开,尚未投递 sibling 留在同一 entry。事务提交后才建立内存 Active Run 并调用 Agent Client。 5. **同一气泡持续更新**:stream 只更新 admission 已创建的 response bubble;成员内部 compact、session rollover 或 handoff continuation 都留在 Agent Client 内,不产生新的主生命周期对象。 6. **Terminal 一次闭合**:completed、failed 或 canceled 都原位终局同一 bubble;同一持久事务让每个 structured owner 按自身 predicate 提交 exact disposition,并只创建该 outcome 合法的 follow-up。这里闭合的是本跳 delivery result,不代替 owner 宣称责任完成。 7. **释放并继续 drain**:terminal commit 成功后删除 exact Active Run,再次 `requestDrain`。下一条 Queue work 因此不会静默积压。 @@ -146,11 +146,11 @@ sequenceDiagram participant T as Terminal Closure alt A calls post_message(@B) - A->>H: append Agent message M1(ref B=assigned) + A->>H: append Agent message M1 A->>Q: same transaction enqueues message_wake(ref=M1, targets=[B]) Note over A: A 的当前 run 不会因此自动取消 else A completed final contains @B - A->>H: terminalize response bubble RA + ref B=assigned + A->>H: terminalize response bubble RA A->>Q: same transaction enqueues message_wake(ref=RA, targets=[B]) Note over H: 不追加第二条 Agent message end @@ -166,11 +166,11 @@ sequenceDiagram T->>D: requestDrain(threadId) ``` -B 正常结束后,M1/RA 上指向 B 的 ref 已经 `settled`,A→B 的 delivery result 闭合;structured owner 是否完成、交回或继续等待仍由其 predicate 决定。若 B 的 completed final 又指向 D,那是 B 自己 response bubble 上从 `assigned` 开始的新 B→D ref/wake;它不会重新打开 A→B,也不会让 A 递归等待 D。 +B 正常结束后,M1/RA 上指向 B 的 ref 已经 `settled`,A→B 的 delivery result 闭合;structured owner 是否完成、交回或继续等待仍由其 predicate 决定。若 B 的 completed final 又指向 D,先只在同一 response bubble 上建立 pending Queue target;真正 dispatch D 时才产生新 ref。它不会重新打开 A→B,也不会让 A 递归等待 D。 ### 2.3 私有协议输入投给 exact target -action successor、event-wait wake、registered callback 与 predecessor failure return 都是同一种 `private_input`。它们只在 inline payload 正文上不同;Queue 不解析 subtype,也不复制 action fence、wait carrier 或 callback generation。需要 terminal disposition 的协议 owner 把 typed state 留在自己的 store,并在 admission/terminal 以 exact invocation 绑定和提交自身状态变更。 +action successor、event-wait wake 与 registered callback 都是同一种 `private_input`。它们只在 inline payload 正文上不同;Queue 不解析 subtype,也不复制 action fence、wait carrier 或 callback generation。需要 terminal disposition 的协议 owner 把 typed state 留在自己的 store,并在 admission/terminal 以 exact invocation 绑定和提交自身状态变更。A2A failed fail-back 使用引用既有 response 的 `message_wake`,不生产 `private_input` 或第二条 History message。 ```mermaid sequenceDiagram @@ -197,8 +197,8 @@ sequenceDiagram B-->>T: completed / failed / canceled callback T->>H: terminalize the same response bubble T->>O: same transaction settles exact invocation generation - opt failed/interrupted and owner returns exact predecessor - T->>Q: enqueue ordinary private_input with failure evidence + opt protocol owner independently schedules an explicit continuation + O->>Q: enqueue owner-defined private_input under its own contract end T->>R: delete exact run after terminal commit T->>D: requestDrain(threadId) @@ -208,7 +208,7 @@ sequenceDiagram ### 2.4 一条消息投给多个成员 -一条 `@B @C` 消息仍只有一个 Queue entry 和一份公开 input。它在 B、C 都可 admission 时整体出队;随后每个 target 拥有独立 invocation、response bubble 与 terminal,某个 sibling 失败不会回滚另一个已经接受的运行。 +一条 `@B @C` 消息始终只有一份 source record、一条 Queue Entry 和一个公开 input bubble。Queue Entry 的 `targets=[B,C]` 表示两位成员尚待投递;任一成员实际接收后,只从数组删除该成员,并在同一 History source 上追加它的 `dispatchRef`。尚未投递 sibling 继续留在原 entry,不复制正文或 UI row。 ```mermaid sequenceDiagram @@ -220,11 +220,14 @@ sequenceDiagram participant O as Exact Source Owner participant T as Terminal Closure - Q->>D: head M1(targets=[B,C]) - D->>D: wait until B and C are both admissible - Note over Q,H: one all-or-none admission transaction - D->>Q: take M1 once - D->>H: materialize M1 once + create bubble B + bubble C + Q->>D: head M1 targets=[B,C] + D->>D: freeze exact dispatchable target set [B,C] + par durable receiver B + D->>H: response B + dispatchRef B + and durable receiver C + D->>H: response C + dispatchRef C + end + D->>Q: one commit removes admitted target set par independent run B D->>B: launch exact M1 B-->>T: terminal callback B @@ -236,14 +239,18 @@ sequenceDiagram C-->>T: terminal callback C T->>H: terminalize bubble C T->>O: apply exact C owner disposition if any - opt C failed and owner returns predecessor - T->>Q: enqueue one private failure return - end + Note over T,Q: failed response + exact idempotent caller control wake commit atomically T->>D: release C run and request drain end ``` -兼容的连续 public inputs 可以共用一次 client dispatch,但每条 input 仍有独立 Queue entry、History message 与顺序。多目标 fan-out 也不创建 per-target Queue 残片;Queue 只有一条输入,运行与结果才按 target 分开。 +初始 enqueue 把完整 target set 一次写入同一 Queue row。普通 drain 按 source comparator 顺序领取 entry,并把该 +source 本轮 exact target set 作为一次 fan-out:每个 target 的 receiver/ref 并发落盘后,在同一 source claim 内各自 +原子删除自己的 target 并启动 provider;最后一个 target 退役才删除 row。它不是先启动 B、再由 drain 领取 C。Steer 可以从同一 entry 领取一个 +singleton target 立即处理;未被领取的 siblings 继续 pending。cutover 后每个 target 拥有独立 invocation、response +bubble 与 terminal,某个 sibling 的 provider outcome 不回滚另一个已经接受的运行。 + +连续 public inputs 仍逐 source admission;Queue 不为节省 client 调用而合并 source、消息身份或正文。 ### 2.5 Stop running 与正常终局 @@ -308,7 +315,7 @@ admission commit 之前退出,entry 仍在 Queue;startup 必须把原 owner/ - §8–§11 处理未读顺序、Append/Steer/Cancel/Stop、失败重启与用户可见投影; - §12–§16 是实现责任面、迁移顺序、Issue 对照、验收矩阵与不可能状态,供实现和 review 查漏。 -这些场景不是六套并列状态机。它们用同一条主流程验证不同入口和 outcome 能否闭环;实现不能反过来为每个场景堆一组 `if/else`,再把偶然重合包装成架构。后文任何局部机制都必须能指出自己服务于上面哪一步,并保持 Queue membership、History result、Agent execution、body exposure 与 structured responsibility 各归其 owner。 +这些场景不是六套并列状态机。它们用同一条主流程验证不同入口和 outcome 能否闭环;实现不能反过来为每个场景堆一组 `if/else`,再把偶然重合包装成架构。后文任何局部机制都必须能指出自己服务于上面哪一步,并保持 Queue pending、History actual dispatch/result、Agent execution、普通未读 cursor 与 structured responsibility 各归其 owner。 ## 3. 投递内核、owner 边界与规范法则 @@ -318,11 +325,11 @@ admission commit 之前退出,entry 仍在 Queue;startup 必须把原 owner/ | 对象 | 持久化 | 唯一职责 | |---|---:|---| -| Queue Entry | 是 | 保存一个有序待处理输入、可恢复 source identity、enqueue 时的 target intent,以及 Queue UI 回显所需 payload;仍在 Queue 就表示尚未开始正常 dispatch | +| Queue Entry | 是 | 保存一个有序 source、可恢复 source identity、Queue UI payload,以及**尚未实际投递**的 `targets[]`;某 target 离开数组即表示它已由 History dispatch fact 接管 | | Chat History Message | 是 | 保存已经进入聊天面板的输入、Agent 消息与响应气泡;拥有固定 `orderKey`,实际被投递的公开消息可持有指向 exact target/result 的 `dispatchRefs` | | Active Run | 否 | 表示 delivery kernel 已为某个 Agent Client admission 的 exact inputs,并关联唯一响应气泡;它是 active-execution projection 的一个输入,不代表 managed command/job 或全部执行真相 | -Queue custody/body exposure、ActionSuccessor/AwaitState/TurnExecution、durable InvocationRecord、callback principal 与 provider presentation ledger 都是已有 owner 对象。它们不进入这三个内核对象,也不会因为本文使用相同的 `entryId / invocationId / messageId` 引用就失去自己的 canonical state。 +ActionSuccessor/AwaitState/TurnExecution、durable InvocationRecord、callback principal、普通未读 cursor 与 provider presentation ledger 都是已有 owner 对象。它们不进入这三个内核对象,也不会因为本文使用相同的 `entryId / invocationId / messageId` 引用就失去自己的 canonical state。Queue 不再另建 seen/handled owner。 ### 3.2 全局对象关系图 @@ -338,13 +345,13 @@ flowchart LR Q --> D["Per-thread Drain
事件驱动 single owner"] QC --> D Q2 --> D - D --> M["Admission transaction
take exact Queue entry"] + D --> M["Admission cutover
claim exact target(s)"] M --> K{"QueueEntry.kind"} K -->|"conversation_input:materialize"| IH["Promote source record into public History
复用 sourceRecordId · 分配 orderKey"] K -->|"message_wake:解析引用"| RH["Reuse referenced History message
不复制正文"] K -->|"private_input:不创建 History message"| B["Chat History processing bubble
固定 responseMessageId + orderKey"] - IH -->|"同一 admission transaction"| B - RH -->|"同一 admission transaction"| B + IH -->|"append dispatchRef + remove pending target"| B + RH -->|"append dispatchRef + remove pending target"| B B -->|"commit 后"| A["Active Run
内存态"] A --> C["Agent Client
成员 session / compact / handoff 内部透明
launch · stream · final · cancel exact invocation"] S1["Stop 指定 Agent"] --> X["快照所选 exact Active Runs"] @@ -357,25 +364,22 @@ flowchart LR F -->|"success / completed"| FS{"final 有有效 target?"} FS -->|"是"| FW["Queue message_wake
引用同一 responseMessageId"] FS -->|"否"| R["提交本 invocation 的 typed owner disposition(若有)
commit 成功后释放 exact Active Run"] - F -->|"failed"| FF{"source owner 返回 predecessor?"} - FF -->|"是"| N["Queue private_input
payload 携带 failure evidence;不进 History"] - FF -->|"否"| R + F -->|"failed"| R F -->|"canceled"| R FW --> R - N --> R R -->|"requestDrain"| D ``` 图中的 conversation work 只在 Drain 的 admission transaction 汇合: - public `conversation_input` 是 `Queue → take → materialize 为公开 History message → 创建 processing bubble → Active Run → client`; -- Agent `post_message` 已经在 History;有有效 target 时,同一事务写 `assigned` refs 并让 Queue 保存它的引用; -- Agent Client 的 completed final 不另写一条 Agent message:它原位终局 admission 时创建的 response bubble,只解析一次 canonical final;有有效 target 时,同一 terminal transaction 写 `assigned` refs 并创建引用同一 `responseMessageId` 的 Queue wake; +- Agent `post_message` 已经在 History;有有效 target 时让 Queue 保存它的引用与 pending `targets[]`,但不提前写 dispatch ref; +- Agent Client 的 completed final 不另写一条 Agent message:它原位终局 admission 时创建的 response bubble,只解析一次 canonical final;有有效 target 时,同一 terminal transaction 创建引用同一 `responseMessageId` 的 Queue wake,真正下一跳 dispatch 时才写 ref; - CLI output 只从 Agent Client 的 stream/terminal 边进入 History,不经过来源消息的 QueueEntry 封装;`private_input` 来源消息则被直接封装为同一 priority Queue 中的 entry,唤起 exact target,但它本身不创建 History message。 -因此 inline `conversation_input` materialize 为 History message 后不会再次进入 Queue。`History → Queue` 只表示一条已经存在的 Agent `post_message` 或 completed response bubble 需要唤起成员时创建 `message_wake + message_ref`;它不复制正文。stream chunk 不参与 target 解析,Stop/failed/canceled 也不会凭残留输出创建 wake。`private_input` 已经是 Queue work:drain admission 时只把它作为 exact 私有输入注入,不再额外生成一条 History message;Agent Client 随后的 stream/terminal 仍走同一 response bubble 主链。 +因此 inline `conversation_input` materialize 为 History message 后不会再次进入 Queue。`History → Queue` 表示一条已经存在的 Agent `post_message`、completed response bubble,或 failed response 的 exact caller control edge 需要唤起成员时创建 `message_wake + message_ref`;它只引用 canonical History record,不复制正文。stream chunk 不参与 target 解析,Stop/failed/canceled 也不会凭残留输出或正文猜测创建 wake;failed-only `a2a_failure` 来自 admission 时冻结的 exact caller lineage。`private_input` 已经是 Queue work:drain admission 时只把它作为 exact 私有输入注入,不再额外生成一条 History message;Agent Client 随后的 stream/terminal 仍走同一 response bubble 主链。 -主链的 Agent Client terminal 只有 `completed / failed / canceled` 三类 outcome;`interrupted` 是 startup recovery 在没有 live client callback 时合成的 failure-like terminal,沿 failed 分支收敛,不是第四条正常 callback 路径。success 只可能追加 completed-final message ref;failed 只在匹配的 structured source owner 返回 exact predecessor 时把 failure 交回该 predecessor;canceled 不追加 follow-up。用户、Connector、定时任务与公开通知没有 owner binding,不参与 predecessor return,直接进入共同 closure。 +主链的 Agent Client terminal 只有 `completed / failed / canceled` 三类 outcome;`interrupted` 是 startup recovery 在没有 live client callback 时合成的 failure-like terminal,不是第四条正常 callback 路径。只有 completed final 中新的显式 target 可以追加业务 successor ref;exact A2A `failed` 依据冻结的 caller lineage 原子追加一条幂等 `a2a_failure` control ref,`canceled / interrupted` 不隐式创建 predecessor work。dispatching member 也可在后续自然 invocation 从 History 观察当前 source×target terminal;其他自动续跑由既有 continuation owner 显式创建 work。 共同 closure 把“让本 invocation 已绑定的 structured owners 提交 typed disposition”和“释放 exact Active Run”放在一起表达,但两者有明确先后:History terminal、outcome follow-up 与 owner disposition 先在一个持久事务中提交;只有 commit 成功后才释放内存态 exact Active Run。公开输入没有 source binding,因此跳过 owner disposition,但仍释放 run。generation fence 留在 action-successor / event-wait 等既有 typed carrier 与 owner store 中,不被主生命周期复制成一套通用字段;这样旧回调既不能关闭新一代责任,也不会因 owner CAS 失败而提前丢掉 Active Run。 @@ -385,13 +389,18 @@ Stop Agent 不直接写 History terminal。它只从 domain active-execution pro ### 3.3 Canonical facts 与 derived projections -- Queue membership 是“尚未开始 normal dispatch”的唯一调度真相;已有、将被投递的 Agent History message 上的 `assigned` ref 只是同一 enqueue transaction 写出的可重建因果投影,不能代替 Queue; +- Queue `targets[]` 是“尚未实际 dispatch”的唯一调度真相;pending target 不在 History 中预写 ref; - exact live Agent Client 由 durable InvocationRecord/TurnExecution、process-local tracker 与 callback principal 共同形成 domain-owned active-execution projection。Active Run 是其中属于本 delivery kernel 的 exact input/response 关联,不是全部 execution 的唯一真相; -- Queue custody/body exposure owner 记录 exact target/invocation 何时真正拿到 prompt body,以及随后是否 handled。`assigned`、`dispatched`、processing bubble 或 provider accepted 都不能自动推出 seen/handled; +- provider presentation owner 可以记录 exact target/invocation 是否收到 prompt projection;它不回写 Queue,也不能由 `dispatched`、processing bubble 或 provider accepted 反推; - freshness relevance suppression 只表示同一 source wave 不需要额外 rerun,不证明 sibling 正文已被当前 invocation 投影、provider 接收、seen 或 handled。若当前并行 wave 暂缓 sibling output,incremental projection 必须把 delivery boundary 截在该 output 之前;由不同的后续 public user trigger 发起的新回合再恢复该 output,沿既有 cursor/presentation owner 正常投递; - canonical visibility relation 是公开 History 输出进入增量投影的必要事实。每条可发布消息都必须在自身持久 append 的同一原子边界取得唯一 `visibilitySeq` 并进入 thread visibility index;raw timeline/thread ZSET 的 membership 不能替代它。`appendAndObservePriorFrontier` 等特殊写路径也必须满足同一合同。admission 前的 queued user work 不取得 visibility position; - ActionSuccessor、AwaitState、TurnExecution 与其他 structured owner 保留 generation、predicate、attempt、predecessor 和 responsibility terminal。delivery terminal 可以触发 owner disposition,但不能替它宣称责任完成; -- 响应气泡的 `processing / completed / failed / canceled / interrupted` 是公开输出结果。`dispatchRefs` 只是从**实际被投递的公开 History 消息**到 Queue/结果事实的持久 derived projection。对 public conversation,这条 History 消息就是 enqueue 时取得 `sourceRecordId` 的同一记录;admission 前它只投影为 Queue row,不承载头像。transport carrier、structured source owner 与始终隐藏的 `private_input` 只提供 provenance/authority,不是公开消息或头像锚点。ref 不复制 outcome、body exposure、handled 或 typed carrier。 +- 响应气泡的 `processing / completed / failed / canceled / interrupted` 是公开输出结果。`dispatchRefs` 是 + **实际被投递的公开 History 消息**到 exact target/response 的 History-owned 因果边:actual dispatch 创建 + `dispatched`,关联 response 终局后推进 `settled`。对 public conversation,这条 History 消息就是 enqueue + 时取得 `sourceRecordId` 的同一记录;admission 前它只投影为 Queue row,不承载头像。transport carrier、 + structured source owner 与始终隐藏的 `private_input` 只提供 provenance/authority,不是公开消息或头像锚点。 + ref 不复制 response outcome、普通未读状态或 typed carrier。 ### 3.4 Source ownership / supersession table @@ -412,22 +421,22 @@ first-pass isolation;一条后续可见 public user message 形成新的投影 | Contract family | 处置 | Canonical fact | 本 RFC 的集成边界 | Acceptance anchors | |---|---|---|---|---| | owner timeline | **superseded at live cutover** | admission 前由 Queue/custody owner 持有可恢复 `sourceRecordId + entryId + payload` 并在 Queue Panel 投影;Chat History 从 admission 起才拥有公开 membership 与 `orderKey` | 一个 Queue row 承担持久回显、reload 与操作入口,并取代现行 owner-facing queued History bubble;admission 复用同一 `sourceRecordId` 进入共同 History,不创建第二条来源消息。Append 若立即赢得 cutover,也会立即完成这一步 | A1, A17–A20, A36, A39, A57 | -| body exposure / handled | **external owner preserved** | Queue custody 的 exact `targetId + invocationId + bodyExposure`;handled/target outcome 由其 owner 以 terminal evidence 推进 | `dispatchRefs.dispatched` 只表示已建立 run/bubble;`seen` 只能在 exact prompt body exposure 后写,`handled` 只能由 owner terminal predicate 写 | A10, A21, A31–A32, A50, A53, A79–A80 | +| provider presentation | **external owner preserved** | provider adapter 对 exact invocation 的 projection receipt | `dispatchRefs.dispatched` 只表示 server 已建立 run/bubble;presentation receipt 不进入 Queue,也不替代 response terminal 或 structured responsibility | A21, A31–A32, A53, A79–A80 | | ordering / priority | **normative Queue contract** | durable Queue entries 的 `position / priority / enqueuedAt` | 保留 `urgent / normal` 两级核心并移除 hidden category rank:priority 由生产者显式给出或默认 normal,不能从 private/source/system category 推断。用户拖拽提交当前 Queue revision 下完整的 visible row order;服务端原子重写这些 rows 的 positions,因此只有显式手动操作能覆盖默认 priority/FIFO。隐藏 rows 不被客户端寻址,仍按自身 priority/FIFO 排序;reservation 只能让 exact row 暂不可重排,不能改变 comparator rank | A3–A5, A11, A15–A17, A35, A69–A70 | | wait / retry / continuation | **external owner** | AwaitState/action lease/task owner 的 predicate、baseline、generation、attempt、expiry 与 continuation | owner 先决定 terminalize-only 还是签发 exact `private_input`。没有 envelope 就不启动 Agent;有 envelope 就只按 owner 给定 target 运行,Queue 不解析 carrier。尚未 admission 的 candidate 发生 eligibility/evidence mutation 时,由既有 post-commit signal 唤醒 typed re-read;terminal Retry 若存在,只能创建新 entry/attempt | A33–A34, A48–A50, A54–A56, A59, A66, A86–A88 | -| routing / custody acceptance | **preserved and fenced at admission** | 当前 membership/capability/availability、owner generation 与 Agent Client acceptance 各自由原 owner 给出 | enqueue `targets` 只是 target intent;client effect 前重新验证、建立 exact owner/invocation binding。targetless fallback 只属于 public head,structured/private work 永不 fallback | A6–A7, A12–A14, A18, A27–A29, A40–A47, A74, A78 | +| routing / custody acceptance | **preserved and fenced at admission** | 当前 membership/capability/availability、owner generation 与 Agent Client acceptance 各自由原 owner 给出 | 普通无 `@` 用户输入在 enqueue 前由服务端解析最近 completed responder/default,并把结果写入 Queue `targets`;source `mentions` 仍保持作者原文事实。client effect 前用同一 resolver 重新验证、建立 exact owner/invocation binding。只有历史/恢复得到的 public targetless row 可在 head recovery fallback;structured/private work 永不 fallback | A6–A7, A11–A14, A18, A27–A29, A40–A47, A74, A78 | | execution / cancel | **derived composition** | domain active-execution service 组合 InvocationRecord、tracker、TurnExecution、managed command/job 等 owner truth | Active Run 只贡献 Agent Client delivery slice;Stop Agent 只 cancel exact Agent Client snapshot,不能命中 managed command/job,也不能直接写 terminal | A22–A25, A27–A29, A60–A65 | -| restart interruption | **preserved direct dependency** | durable running invocation/processing bubble、callback principal、immutable structured owner admission binding 与 Queue custody | startup 先按原 owner/predicate fence 区分 verified、mismatch、insufficient evidence;只有 binding 可验证且 live client 消失的 admitted Agent run 原位收敛为 `interrupted`,同步 owner disposition 与 derived refs。已提交 verdict 只补 apply;未 admission work 不自动重放。live cutover 前移除或隔离所有 selectable terminal-plus-queued legacy projection | A17, A19–A21, A49, A53, A59, A80–A89 | -| model presentation | **normative derived projection** | History/Queue facts、body exposure、active-execution snapshot;动态 context 的 provider-received receipt 仍归 presentation ledger | UI 与 Agent situation packet 从同一 canonical snapshot 映射;用户态 `processing` 表示 exact server-side Agent execution 已开始且尚未终局,不承诺 provider 已接收或 Agent 已看到正文。same-route first-pass isolation 只覆盖同一 public user turn;后续可见 user turn 让其之前的公开输出重新进入正常 window,server-authored causal direct reply 则以 exact source 校验投影,裸 `replyTo` 不扩权。launch/execution 失败仍让同一 bubble 原位 `failed`;`presented` 只由 provider receipt 推进;F153 projection audit 只留 bounded refs/reasons | A22–A23, A30–A32, A53, A67–A68, A76–A85 | -| `dispatchRefs` | **rebuildable derived projection** | `assigned` 来自 exact message-wake Queue membership;`dispatched` 来自 delivered-message↔processing bubble/invocation binding;`settled` 来自 linked terminal record | History owner 可持久化 ref 供读取,但 startup/reconciler 必须能从上述事实重建。失配时 canonical facts 胜出;唯一映射可 CAS 修复,缺失/多义则 fail closed、隐藏 working claim并报警,不猜测;同一 public source record 仅在取得 History membership 后承载 ref,transport carrier、structured owner 与 private input 不能成为替代 UI anchor | A2, A6–A7, A10, A40, A53, A73, A75–A80, A90 | +| restart interruption | **preserved direct dependency** | durable running invocation/processing bubble、callback principal、immutable structured owner admission binding 与 History dispatch refs | startup 先按原 owner/predicate fence 区分 verified、mismatch、insufficient evidence;只有 binding 可验证且 live client 消失的 admitted Agent run 原位收敛为 `interrupted`,同步 owner disposition 与 derived refs。已提交 verdict 只补 apply;未 admission work 不自动重放。live cutover 前移除或隔离所有 selectable terminal-plus-queued legacy projection | A17, A19–A21, A49, A53, A59, A80–A89 | +| model presentation | **normative derived projection** | History/Queue facts、普通未读 cursor、active-execution snapshot;动态 context 的 provider-received receipt 仍归 presentation ledger | UI 与 Agent situation packet 从同一 canonical snapshot 映射;用户态 `processing` 表示 exact server-side Agent execution 已开始且尚未终局,不承诺 provider 已接收或 Agent 已推进普通未读。same-route first-pass isolation 只覆盖同一 public user turn;后续可见 user turn 让其之前的公开输出重新进入正常 window,server-authored causal direct reply 则以 exact source 校验投影,裸 `replyTo` 不扩权。launch/execution 失败仍让同一 bubble 原位 `failed`;`presented` 只由 provider receipt 推进;F153 projection audit 只留 bounded refs/reasons | A22–A23, A30–A32, A53, A67–A68, A76–A85 | +| `dispatchRefs` | **History-owned causal projection** | `dispatched` 来自 delivered-message↔processing response/invocation binding;`settled` 来自 linked terminal record | pending intent 只在 Queue `targets[]`,不提前创建 ref。History owner 持久化 actual dispatch ref;唯一映射可 CAS 修复,缺失/多义则 fail closed、隐藏 working claim并报警,不猜测;transport carrier、structured owner 与 private input 不能成为替代 UI anchor | A2, A6–A7, A10, A40, A53, A73, A75–A80, A90 | ### 3.5 五条 normative laws -**L1 — 每个事实只有一个 owner。** Queue/source custody 拥有未 dispatch 输入与 exact exposure;History 拥有公开 timeline/result;execution owners 拥有 live/terminal;structured owners 拥有责任 generation。其他 surface 只能引用或投影,不能复制并裁决同一事实。 +**L1 — 每个事实只有一个 owner。** Queue 拥有 pending 输入、目标与顺序;History 拥有 actual dispatch、公开 timeline 与 response result;execution owners 拥有 live/terminal;structured owners 拥有责任 generation。其他 surface 只能引用或投影,不能复制并裁决同一事实。 **L2 — 顺序、副作用与 authority selection 只在一个 durable cutover 上改变。** 一条 durable Queue 只有一个稳定 comparator:存在 `position` 的 rows 先按 position,未定位 rows 再按 `urgent → normal → enqueuedAt → id`;同 priority 永远 FIFO,entry kind/source/system category 不产生第三种隐藏优先级。用户拖拽必须携带 `expectedQueueRevision + orderedVisibleEntryIds` 原子替换完整 visible order;不能直接寻址 private rows。任何 normal dispatch/Append/Steer 都先原子赢得 exact Queue cutover、固定 targets/bubble,并让 external structured owner 按每个 target/invocation 提交 immutable admission binding:owner kind、lease id/generation、frozen predicate 或 HEAD、principal/tenant/route。preflight、admission/persist、terminal、same-generation replay 与 startup recovery 都只验证这份 admission-time fence;History/Queue 只引用 `invocationId`,secondary resolver 不得从 carrier 或 current-identity lookup 重新选择“当前 generation”。完成这些提交后才能产生 client side effect。 -**L3 — identity、admission、exposure、handled 不可互相推断。** enqueue targets 只是 intent;dispatch 时重验 membership/capability/availability 与 owner fence;`dispatched` 不等于 provider-presented/seen,`settled` 不等于 handled 或 responsibility completed。每次推进都必须绑定 exact entry/source/target/invocation/generation。 +**L3 — identity、pending intent、actual dispatch、presentation 与 responsibility 不可互相推断。** Queue targets 只是 intent;dispatch 时重验 membership/availability 与 owner fence;静态 guide capability 决定可选动作;`dispatched` 不等于 provider-presented,`settled` 不等于 responsibility completed。每次推进都必须绑定 exact entry/source/target/invocation/generation。 **L4 — 每个 admitted target/invocation 只有一个 durable delivery-result terminal。** client effect 前先有固定 response bubble 与 recoverable callback principal;completed/failed/canceled/interrupted 原位终局同一个 delivery result。message persisted、target invocation admitted、execution terminal、visible reply lineage、body handled 与 structured responsibility settled 是六个不同事实;responsibility terminal/disposition 始终归 external structured owner 的 exact generation/predicate。same-generation replay 返回已经提交的同一 terminal;compensation 不能撤销已提交的 typed verdict,也不能把 append-won/apply-crashed 恢复成第二个 semantic answer。Stop 只请求 exact Agent Client cancel;startup 先验证 admission-time fence,再收敛确已 admission 且 live client 消失的 processing work。 @@ -466,10 +475,10 @@ type MessageFrom = | { kind: 'system'; service: string } type QueueEntryBase = { - id: string + id: string // deterministic from sourceRecordId only; target is not row identity threadId: string from: MessageFrom - targets: string[] + targets: string[] // exact members whose delivery is still pending ownerAuthProvenance: 'strict' | 'compatibility_fallback' | 'unknown' priority: 'urgent' | 'normal' enqueuedAt: number @@ -506,8 +515,8 @@ type ReorderVisibleEntriesCommand = { - `payload` 只回答正文是直接随 entry 保存,还是引用一条已存在的 History message;因此只有 `inline / message_ref` 两种承载方式; - `from` 只回答“谁/哪个系统发出”。每个 id 都位于自己的判别命名空间中,不能把 IM sender、GitHub actor、plugin instance、user 或 Agent 的 id 当成同一种 id; -- `targets` 只回答 enqueue 时的 target intent,不是成员当前可用、capability 合格、已接受 custody 或已经 seen; -- `sourceRecordId` 是 public conversation 在 enqueue commit 时就获得的可恢复 owner identity;它尚未拥有 History membership/orderKey,admission 时复用为公开 `messageId`,不能再生成第二个来源身份; +- `targets` 只保存仍待实际 dispatch 的 target intent,不是成员当前可用、capability 合格或正在执行;actual dispatch 后 exact target 必须离开数组; +- `sourceRecordId` 是 Queue Entry 所代表持久 source record 的 identity;它与 `from`(作者)正交。public conversation 在 enqueue 时尚未拥有 History membership/orderKey,第一次 actual dispatch 时复用该 identity,不能再生成第二个来源身份; - `ownerAuthProvenance` 只记录生产者认证强度;它不替代 owner generation/capability preflight。新的 public/plugin/private producer 必须建立 strict binding,legacy unknown 只能走显式迁移/fail-closed 路径; - `priority` 只有 `urgent / normal`,默认 `normal`;生产者必须显式选择 urgent,不能让 Queue 从 `kind`、source category 或 payload 正文推导; - `kind` 才决定 admission 语义:是否 materialize History message、是否允许 targetless fallback,以及是否对用户可见;它不决定 Queue 优先级。 @@ -517,27 +526,31 @@ type ReorderVisibleEntriesCommand = { `targets` 是 enqueue 时从结构化 mention 得到、当时有效的成员集合: - 能解析到成员时保存 exact ids;Queue UI、队首 busy 检查、多目标 admission 与显式操作都直接使用它; -- public `conversation_input` 无 mention、mention 解析失败或没有有效成员时保存空数组;此处空数组明确表示在实际出队时选择 fallback; +- public `conversation_input` 确实没有 authored mention 时,在 source+Queue 原子 admission 前调用 canonical resolver,保存最近 completed responder/default;source mentions 仍为空。若 authored mention 解析失败或全部无效,则保留 routing warning 与空 target,避免把输入静默改投给未被点名的成员;空数组还允许表示没有任何可用成员或历史/恢复遗留状态; - entry 到达队首时仍要按当前 thread membership 重新验证;只有 `conversation_input` 允许空数组/fallback。`message_wake` 与 `private_input` 都必须有 exact targets,不能把显式目标失效解释成“随便找最近成员”,否则会把定向或私有内容交给错误成员; -- target 相同是共用一次 dispatch 的必要条件,但不是“把消息合成一条”的许可。只有队首开始的兼容输入,或已被同一次未读投影精确覆盖的 wake ref,才可一并取走;每条消息与 entry 的身份、正文和顺序仍然独立。 +- target 相同不允许 Queue 合并 source admission。只有已被同一次 Active Run 未读投影精确覆盖的 wake ref 可以由该 run 一并接管;每条消息与 entry 的身份、正文和顺序仍然独立。 -`conversation_input` 用于 public conversation 的用户、外部 Connector、plugin 或系统输入。它使用 inline payload 保存 Queue row 完整回显所需的正文与附件,并在 enqueue commit 时获得稳定 `sourceRecordId`;但此时没有 History membership/orderKey,Agent 普通 context 也不可见。admission 才把同一 source record materialize 为公开 History message。默认规则是:所有普通来源都构造 `conversation_input`;只有正文已经存在于 History 时使用 `message_wake`,或来源明确给出不可公开的协议输入时使用 `private_input`。`message_wake` 使用 message-ref payload,只引用既有 `messageId/responseMessageId`,不复制正文。`private_input` 也使用 inline payload,但必须有非空 exact `targets`,与其他 kind 一样进入同一 priority Queue;它不创建 History message,也不在用户 Queue Panel 回显。action successor、event-wait wake、registered callback 与 predecessor failure return 只会让 payload 正文不同;QueueEntry 形状、admission、可见性和 terminal 规则完全相同,priority 则由各生产者明确给出而非从这些用途猜测。它们都只在目标 Agent 的 exact input / situation packet 中可见,永不进入 Chat History 或普通 Queue Panel。 +`conversation_input` 用于 public conversation 的用户、外部 Connector、plugin 或系统输入。它使用 inline payload 保存 Queue row 完整回显所需的正文与附件,并在 enqueue commit 时获得稳定 `sourceRecordId`;但此时没有 History membership/orderKey,Agent 普通 context 也不可见。admission 才把同一 source record materialize 为公开 History message。默认规则是:所有普通来源都构造 `conversation_input`;只有正文已经存在于 History 时使用 `message_wake`,或来源明确给出不可公开的协议输入时使用 `private_input`。`message_wake` 使用 message-ref payload,只引用既有 `messageId/responseMessageId`,不复制正文。`private_input` 也使用 inline payload,但必须有非空 exact `targets`,与其他 kind 一样进入同一 priority Queue;它不创建 History message,也不在用户 Queue Panel 回显。action successor、event-wait wake 与 registered callback 只会让 payload 正文不同;QueueEntry 形状、admission、可见性和 terminal 规则完全相同,priority 则由各生产者明确给出而非从这些用途猜测。它们都只在目标 Agent 的 exact input / situation packet 中可见,永不进入 Chat History 或普通 Queue Panel。 History 策略不能从 `from` 单独推断:`from.kind='system'` 既可能是需要公开的 `conversation_input`,也可能是 `private_input`;`from.kind='agent'` 说明发送者身份,但只有 `kind='message_wake' + payload.type='message_ref'` 才表示正文已经存在于 History。合法组合由 `QueueEntry` 的判别 union 固定,dispatch 不再用 sender id 猜消息用途。 -Queue 只保存 `private_input` 的 inline payload,不理解这段正文为何产生,也不解析 generation、predecessor 或成员 session 状态。action-successor 的 `actionSuccessorFence`、event-wait 的 `waitContinuationCarrier` 与 registered callback 自己的 carrier 继续由各自 owner store 解释;owner 若需要 terminal disposition,就在 QueueEntry 之外按 exact entry/invocation 建立自己的 binding。failure return 的生产者只创建一条正文含 exact evidence 的普通 `private_input`,不登记新的 predecessor binding。是否存在 owner binding 是上游协议事实,不是 QueueEntry 字段或 private payload subtype。 +Queue 只保存 `private_input` 的 inline payload,不理解这段正文为何产生,也不解析 generation、predecessor 或成员 session 状态。action-successor 的 `actionSuccessorFence`、event-wait 的 `waitContinuationCarrier` 与 registered callback 自己的 carrier 继续由各自 owner store 解释;owner 若需要 terminal disposition,就在 QueueEntry 之外按 exact entry/invocation 建立自己的 binding。A2A failure 不生成 `private_input`;它原子提交引用 canonical failed response 的 exact `message_wake`,并以 `a2a_failure` 分类禁止递归。是否存在 owner binding 是上游协议事实,不是 QueueEntry 字段或 private payload subtype。 本文图中的 `pre` 只是 failed/interrupted owner disposition 的 nullable **返回结果**:owner 校验 admission 时冻结的 typed carrier、lease/generation 与 predicate 后,可能返回 exact predecessor route,也可能返回 `null`。它不是 QueueEntry、Message 或 Active Run 的通用字段,更不是 `from` 或“这条正文看起来像谁写的”。public input 没有 source binding,因此自然得到 `pre=null`;一条 `from.kind='agent'` 的普通公开消息也不能仅凭发送者自动补造 pre。 Queue Entry 不保存任意数值 priority、attempt、receipt 或运行状态。priority 只允许 `urgent / normal`,默认 normal,同级按 `enqueuedAt` FIFO;kind、source/system category 与 private payload 正文都不能暗中改写它。用户显式重排提交完整 `orderedVisibleEntryIds`,服务端原子重写这些 rows 的 `position`,这是唯一能覆盖默认 priority/FIFO 的普通排序路径。客户端不提交 private ids;未定位的隐藏 rows 仍按 priority/FIFO 形成唯一后缀顺序。commit 后只由上述 comparator 驱动 drain,既不维护第二条 Queue,也不在 dequeue 时临时扫描越过 head。 +多目标仍是**同一条** Queue Entry:`targets=[B,C]`。普通 drain 领取 `[B,C]` 时,在 B/C 的 response/ref 全部持久化后 +一次删除这组 targets,因此 entry 直接消失;Steer 或未读接管只领取 B 时,entry 原位变为 `targets=[C]`。Queue 不为 +B/C 复制 source row,不保存已投递成员数组,也不把 response terminal 写回该 entry。 + 每条输入对应一个独立 Queue Entry: - 每次用户或 Connector 输入是一条 inline payload + 一条 entry; - 每次 `post_message` 是一条独立 History message;需要成员处理时再建一条 message ref entry; - 每次 completed final 原位终局既有 response bubble;只有 canonical final 含有效结构化 target 时才建立引用该 bubble 的 message ref entry,不追加第二条 Agent message; - 每条 `private_input` 来源消息是一条独立 private entry,按显式 priority 与同级 FIFO 入队;它不会复制成公开 History message; -- 相邻且路由形状、解析后 target set 都相同的 public conversation inputs 可以共用一次 dispatch,但会分别 materialize 为独立 History message; +- 相邻且路由形状、解析后 target set 都相同的 public conversation inputs 仍逐 source admission,并分别 materialize 为独立 History message; - 多条 wake 可以由同一次未读投影精确覆盖,但不拼正文、不丢 message/entry 边界; - 拖动重排或删除 entry 是显式用户操作;批量 reorder CAS 成功后,完整 visible order 立即成为新真相。 @@ -545,19 +558,17 @@ Queue Entry 不保存任意数值 priority、attempt、receipt 或运行状态 ```ts type DispatchRef = - | { - targetId: string - phase: 'assigned' - } | { targetId: string phase: 'dispatched' statusMessageId: string + dispatchedAt: number } | { targetId: string phase: 'settled' statusMessageId: string + dispatchedAt: number } type LifecycleMessageMetadata = { @@ -602,17 +613,16 @@ type ResponseBubble = { } ``` -`orderKey` 在消息或响应气泡首次进入 History 时分配,之后永不改变。外部输入在 Queue 阶段已经有 owner-owned `sourceRecordId`,但没有 History membership/orderKey;正常 dispatch admission 复用该 identity 写入 History,并紧接着写入对应的 processing response bubble。bubble 的 `status='processing'` 是 durable **outstanding-result witness**,只说明该 admitted invocation 还没有 delivery-result terminal;它本身不提供 responsibility authorization。只有它、exact live Active Run 与可验证的 admission-time owner fence 同时成立时,UI 才投影“正在处理”,其含义是 server-side Agent execution live。provider-presented、body exposure、seen、handled 与 structured responsibility 仍由各自 owner 独立推进。`completedAt` 只用于耗时与诊断,不能重新排序。 +`orderKey` 在消息或响应气泡首次进入 History 时分配,之后永不改变。外部输入在 Queue 阶段已经有 owner-owned `sourceRecordId`,但没有 History membership/orderKey;正常 dispatch admission 复用该 identity 写入 History,并紧接着写入对应的 processing response bubble。bubble 的 `status='processing'` 是 durable **outstanding-result witness**,只说明该 admitted invocation 还没有 delivery-result terminal;它本身不提供 responsibility authorization。只有它、exact live Active Run 与可验证的 admission-time owner fence 同时成立时,UI 才投影“正在处理”,其含义是 server-side Agent execution live。provider presentation、普通未读 cursor 与 structured responsibility 仍由各自 owner 独立推进。`completedAt` 只用于耗时与诊断,不能重新排序。 `dispatchRefs` 是**实际被投递的公开 History record**到 exact target 结果记录的持久因果边,每个 target 最多一条,并按实际 target set 的稳定顺序保存。这里的 History record 就是正文被交给目标的那条用户消息、成员消息、公开外部/系统通知,或带下一跳 target 的 completed response bubble。public conversation 在 enqueue 时已经取得 `sourceRecordId`,admission 只让同一记录获得 History membership、`orderKey` 与 refs,不另造 source bubble;transport carrier、structured source owner、始终隐藏的 `private_input` 与为目标执行创建的 response bubble 都不是这条输入消息的替代头像锚点: -- `assigned`:被投递消息已经在 History,且同一事务已为该 target 创建 durable `message_wake`;它仍是尚未 actual dispatch 的 Queue 计划,用户 UI 不用头像宣称目标正在处理; - `dispatched`:admission 已把该消息交给解析/重验/fallback 后的 actual target exact run,并创建 `statusMessageId` 指向的 processing response bubble;public conversation input 因为此前尚无 History message,会在 materialize 时直接以 actual target 的这个阶段出现; - `settled`:该 target 的本跳已经产生 canonical terminal;`statusMessageId` 继续指向原 response bubble,或在 explicit target 于 admission 前失效时指向相邻的 `DeliveryFailureResult`。 -阶段只能单调推进。`completed / failed / canceled / interrupted` 等 outcome 只从 `statusMessageId` 指向的 canonical record 读取,不能复制进 ref 后形成第二份结果真相。`settled` 只表示 linked canonical result 已 terminal;它不表示 target 已看到正文、Queue custody 已 handled、ActionSuccessor/AwaitState 已 completed,或整个责任链结束。一个 response bubble 覆盖多条 exact input 时,每条公开被投递消息的对应 ref 可以指向同一个 `statusMessageId`;multi-target input 则由每个 target 的 ref 独立推进。completed response bubble 若随后带 target,它在上一跳是 terminal result、在下一跳同时也是被投递消息,并只在自身 record 上增加下一跳 refs。`private_input` 没有公开被投递 History record,因此不创建 `dispatchRefs` 或用户头像。 +阶段只能单调推进。`completed / failed / canceled / interrupted` 等 outcome 只从 `statusMessageId` 指向的 canonical record 读取,不能复制进 ref 后形成第二份结果真相。`settled` 只表示 linked canonical result 已 terminal;它不表示普通未读 cursor 已推进、ActionSuccessor/AwaitState 已 completed,或整个责任链结束。一个 response bubble 覆盖多条 exact input 时,每条公开被投递消息的对应 ref 可以指向同一个 `statusMessageId`;multi-target input 则由每个 target 的 ref 独立推进。completed response bubble 若随后带 target,它在上一跳是 terminal result、在下一跳同时也是被投递消息,并只在自身 record 上增加下一跳 refs。`private_input` 没有公开被投递 History record,因此不创建 `dispatchRefs` 或用户头像。 -`dispatchRefs` 虽持久化以服务读取性能,仍必须可重建:`assigned` 从 exact `message_wake + target` Queue membership 重建,`dispatched` 从 delivered message identity、processing bubble 的 exact input IDs/target/invocation binding 重建,`settled` 从 linked terminal bubble 或 `DeliveryFailureResult` 重建。History projection owner 负责 reconciliation:唯一映射时用 CAS 修复;canonical facts 缺失、冲突或出现多个候选时 fail closed,移除/隐藏不可信 working claim并写诊断,绝不让 ref 反向终结 Queue、execution 或 structured owner,也不把 provenance carrier 临时投影成一条可见消息。 +pending intent 只存在于 Queue `targets[]`,不会提前创建 ref。`dispatchRefs` 的 `dispatched/settled` 可从 delivered message identity、response bubble 的 exact input IDs/target/invocation binding 与 terminal status 交叉核验。唯一映射时 History owner可 CAS 修复;canonical facts 缺失、冲突或出现多个候选时 fail closed,移除/隐藏不可信 working claim并写诊断,绝不让 ref 反向终结 Queue、execution 或 structured owner,也不把 provenance carrier临时投影成一条可见消息。 `DeliveryFailureResult` 是 Chat History 中的公开终局结果,用于没有 target/invocation、因而不能合法创建 `ResponseBubble` 的 pre-admission failure。它引用 exact Queue entry 与 input message,但不伪造 target、invocation 或 Active Run。进入 Chat History 就表示已经进入聊天面板并成为公开对话事实。只给某个 Agent 的内部输入不能靠隐藏 visibility 状态塞进 History;它必须留在 Queue 的 `private_input` payload,dispatch 时只进入该 Agent 的 exact input / situation packet。 @@ -635,7 +645,7 @@ type ActiveRun = { } ``` -Active Run 保存本轮 exact entry IDs;公开输入另有 `inputMessageIds`,`private_input` 只列在 `privateInputEntryIds`。typed owner disposition 不靠 Active Run 复制 carrier:exact terminal 只用 `invocationId` 调用 existing owner,owner 从 durable invocation binding 找回自己的 typed carrier。需要公开输入的作者、来源或因果信息时从 History 回读。failure return 是 owner disposition 后产生的一条普通 `private_input`;其生产者不建立新的 predecessor binding,因此再次失败时不会递归交回,但 Queue 不需要知道这条 payload 的用途。正常 dispatch 在调用 provider 前登记 Active Run,同一结构承担 admission 后的 occupancy 与显式操作定位。 +Active Run 保存本轮 exact entry IDs;公开输入另有 `inputMessageIds`,`private_input` 只列在 `privateInputEntryIds`。typed owner disposition 不靠 Active Run 复制 carrier:exact terminal 只用 `invocationId` 调用 existing owner,owner 从 durable invocation binding 找回自己的 typed carrier。需要公开输入的作者、来源或因果信息时从 History 回读。A2A failed fail-back 是引用该 History response 的 `message_wake`,不是 private input;dispatching member 的结果观察仍直接回读 History。正常 dispatch 在调用 provider 前登记 Active Run,同一结构承担 admission 后的 occupancy 与显式操作定位。 provider 只需返回是否接受以及 exact execution handle;动作类型由调用方确定。 @@ -696,36 +706,35 @@ Agent post_message / completed final 已存在的 History message action successor / event-wait / registered callback 的私有协议输入 → private_input + inline payload → priority Queue(生产者显式 urgent/normal) - -failed/interrupted disposition 返回的 exact predecessor failure return - → private_input + inline payload → priority Queue(生产者显式 urgent/normal) ``` -这只是不同生产者构造同一种 QueueEntry envelope,不是一个额外业务步骤。入口不能搜索正文关键词,也不能根据 transport、payload 内容、是否存在 predecessor 或当前 thread holder 改写 `kind`。它只检查 Queue 契约,例如 `message_wake` 必须引用同 thread 的既有 History message,`private_input` 必须使用 inline payload 并带 exact targets。action/wait/callback owner 的 typed carrier 留在 owner store;failure return 的 evidence 只是 payload 正文。若上游协议没有产生消息,就没有 Queue entry。 +这只是不同生产者构造同一种 QueueEntry envelope,不是一个额外业务步骤。入口不能搜索正文关键词,也不能根据 transport、payload 内容、是否存在 predecessor 或当前 thread holder 改写 `kind`。它只检查 Queue 契约,例如 `message_wake` 必须引用同 thread 的既有 History message,`private_input` 必须使用 inline payload 并带 exact targets。action/wait/callback owner 的 typed carrier 留在 owner store。若上游协议没有产生消息,就没有 Queue entry。 -CLI output 来自已经运行的 Agent Client:stream 更新既有 response bubble,completed/failed/canceled 走 §7.3–§7.4 的 terminal closure;只有 completed final 或独立 `post_message` 含有效 target 时,才构造 `message_wake + message_ref`。`private_input` 则先进入同一 priority Queue,admission 后才作为 exact 私有输入启动 Agent Client;此前不存在这次 Agent Client 调用。 +CLI output 来自已经运行的 Agent Client:stream 更新既有 response bubble,completed/failed/canceled 走 §7.3–§7.4 的 terminal closure。completed final 或独立 `post_message` 只有含有效 target 时才构造业务 successor `message_wake + message_ref`;exact A2A failed 则不解析正文,而是依据持久 caller lineage 构造一条引用同一 failed response 的幂等 `a2a_failure` control wake。`private_input` 先进入同一 priority Queue,admission 后才作为 exact 私有输入启动 Agent Client;此前不存在这次 Agent Client 调用。 ### 5.2 用户、Connector、定时任务与公开通知:先入 Queue,dispatch 时进入聊天面板 ```text 收到输入 - → 构造 from,并解析结构化 mention 得到 targets(可为空) + → 构造 from,并解析结构化 mention + → 普通无 authored @:在入队前绑定最近 completed responder/default + authored @ 无效:保留 routing warning,允许 targetless compatibility row → 持久化 sourceRecordId + 创建 QueueEntry(conversation_input, inline payload, from, targets) → Queue Panel 直接回显 entry payload → Queue commit 后 requestDrain(threadId) - → 队首按当前 membership 重验 targets;为空时选择合法 fallback,得到 actualTargets + → 队首按当前 membership 重验 stored targets;仅历史/恢复或 warning-bearing targetless row 在 idle 时绑定 fallback → admission 时复用 sourceRecordId 为 messageId、分配 orderKey,并以 actualTargets 的 dispatchRefs=dispatched 写入 Chat History ``` -Queue Panel 是 public conversation input 在排队阶段的唯一用户可见位置,聊天面板只展示已经开始 dispatch 的消息。entry 仍在 Queue 时,输入既不属于 Chat History,也不进入任何 Agent 普通上下文;admission 原子移除 entry、按实际解析出的 target set materialize 一条公开 History message 并创建 response bubble 后,它才同时出现在聊天面板并成为本轮 exact Agent input。此时这条新出现的 input bubble 本身就是被投递消息与头像锚点:每个 actual target 的 ref 都直接为 `dispatched`,不存在另一条“source bubble”。`private_input` 是同一 durable Queue 中的私有 entry,但不在用户 Queue Panel 或聊天面板展示,只在被投递目标的 exact input 中可见,因此也没有公开头像锚点。 +Queue Panel 是 public conversation input 在排队阶段的唯一用户可见位置,聊天面板只展示已经发生至少一次 actual dispatch 的消息。entry 仍只有 pending targets 时,输入既不属于 Chat History,也不进入任何 Agent 普通上下文;第一次 actual target set 被领取时,admission 复用 `sourceRecordId` materialize 一条公开 History message,并为 set 中每个 target 创建独立 response bubble 与 `dispatched` ref。普通 drain 领取本轮完整 exact target set;Steer/未读接管可以只领取 singleton。尚未领取的 siblings 继续留在**同一条** Queue Entry;每次 cutover 只从 `targets[]` 删除其 exact set,并向同一 History message 追加对应 refs。`targets[]` 为空时 entry 删除。`private_input` 是同一 durable Queue 中的私有 entry,但不在用户 Queue Panel 或聊天面板展示,只在被投递目标的 exact input 中可见,因此也没有公开头像锚点。 Queue commit 自身就是外部输入的持久边界。排队阶段的 source record 只提供稳定 identity 与 Queue/custody 恢复,不拥有 History membership/orderKey;admission 复用同一 identity,不为 Queue 回显制造第二条 message。 -目标契约在 live cutover 时取代旧 owner-timeline publication。现行基线先创建 Queue entry,再把同一正文以 `deliveryStatus='queued' + queueCustody` 写入 MessageStore:browser reader 把它放进 owner timeline,cat context 则继续隐藏。这个设计虽然可恢复,却让同一待处理输入同时占有 Queue row 与 owner-only History bubble,并被迫维护三套不同语义:authoring-time timeline position、Queue execution order、target cognition order。用户随后在 Queue 中重排时,History bubble 仍留在原 authoring position;target 看到它的时间又取决于 dequeue,单个 bubble 因而无法直观表达“在队列哪里”和“何时成为共同对话”。 +目标契约在 live cutover 时取代旧 owner-timeline publication。现行基线先创建 Queue entry,再把同一正文以 `deliveryStatus='queued' + queueCustody` 写入 MessageStore:browser reader 把它放进 owner timeline,cat context 则继续隐藏。这个设计虽然可恢复,却让同一待处理输入同时占有 Queue row 与 owner-only History bubble,并被迫维护两套不同顺序:authoring-time timeline position 与 Queue execution order。用户随后在 Queue 中重排时,History bubble 仍留在原 authoring position,单个 bubble 因而无法直观表达“在队列哪里”和“何时成为共同对话”。 -新契约只保留一个 pre-admission 用户表面:Queue acceptance 后,发送者从 Queue Panel 获得完整正文、附件、持久 identity、reload 恢复与 Cancel/Append/Steer/reorder 操作;共同 Chat History 只包含已经 admission 的输入。这样排序事实只在 Queue,conversation order 只在 admission 时生成,target cognition 仍由 body-exposure owner 独立记录,不再用一条 owner-only bubble 横跨三个时间坐标。排队消息在 admission 前不会出现在 Chat History;这是 live cutover 必须一次完成的产品契约,不是兼容性 fallback。 +新契约只保留一个 pre-admission 用户表面:Queue acceptance 后,发送者从 Queue Panel 获得完整正文、附件、持久 identity、reload 恢复与 Cancel/Steer/reorder 操作;共同 Chat History 只包含已经发生 actual dispatch 的输入。这样排序事实只在 Queue,conversation order 只在第一次 actual dispatch 时生成;每目标是否已经投递则只由 History `dispatchRefs` 表达,不再用一条 owner-only bubble 横跨多个时间坐标。排队消息在第一次 actual dispatch 前不会出现在 Chat History;这是 live cutover 必须一次完成的产品契约,不是兼容性 fallback。 -若 App Server 支持 Append 且用户选择立即 Append,Append 先 exact-take entry 并赢得同一 admission cutover,因此消息会立即进入 History 与目标 exact input;这不是提前发布 Queue row 的第二条路径。迁移时,旧 `deliveryStatus='queued'` owner-only records 若尚未 body-exposed,就降为 Queue source record 并从 shared History membership 移除;已经 exposure 或存在公开引用的 record 必须保留原 identity,并走显式 migration/quarantine,不能静默重排既有公开事实。 +若 client 支持引导回复且用户选择「立即发送,引导回复」,服务端从该 source entry 原子取走 exact target,并在同一 cutover 把消息写入 History 与目标 exact input;这不是提前发布 Queue row 的第二条路径。迁移时,旧 `deliveryStatus='queued'` owner-only records 若尚未发生实际投递,就降为 Queue source record 并从 shared History membership 移除;已经拥有 actual dispatch 或外部公开引用的 record 必须保留原 identity,并走显式 migration/quarantine,不能静默重排既有公开事实。 ### 5.3 Agent `post_message` 与 completed final:共用 History ref,写入时机不同 @@ -733,34 +742,32 @@ Queue commit 自身就是外部输入的持久边界。排队阶段的 source re Agent post_message → 解析结构化 mention,得到 targets → 写一条独立 History message - → 若需要成员处理,同一事务写 dispatchRefs=assigned - + 创建 QueueEntry(message_wake, message_ref, from=agent, targets) + → 若需要成员处理,同一事务创建 QueueEntry(message_wake, message_ref, from=agent, targets) → requestDrain(threadId) Agent Client completed final → 对 terminal 采用的 canonical final body 解析一次结构化 mention → 在 terminal transaction 中原位 completed 同一 response bubble - → 若有有效 target,原子写 dispatchRefs=assigned - + 创建 QueueEntry(message_wake, message_ref=responseMessageId, from=agent, targets) + → 若有有效 target,原子创建 QueueEntry(message_wake, message_ref=responseMessageId, from=agent, targets) → 不追加第二条 Agent History message → 释放 exact Active Run 后 requestDrain(threadId) ``` -`post_message` 本身是完整聊天内容,因此立即成为一条独立 History message。completed final 已经属于 admission 时创建的 response bubble,只更新其正文与 terminal status;它不能为了 target routing 再复制成第二条 message。两条路径都只在 canonical 完整正文上解析结构化 target:`post_message` 在发布时解析,final 在 completed terminal 时解析;stream chunk、failed/canceled 残留正文与重复 callback 都不解析。有效 targets、`assigned` refs 与 `message_wake` 必须在同一事务写入;Queue entry 被实际 admission 时再把**这条被投递 History 消息自身**的对应 refs 原子推进到 `dispatched`。因此 completed response bubble 若投给下一跳,既保留上一跳终局,也直接在同一 bubble 下显示下一跳 target 的处理头像,不复制“来源消息”或“投递状态消息”。 +`post_message` 本身是完整聊天内容,因此立即成为一条独立 History message。completed final 已经属于 admission 时创建的 response bubble,只更新其正文与 terminal status;它不能为了 target routing 再复制成第二条 message。两条路径都只在 canonical 完整正文上解析结构化 target:`post_message` 在发布时解析,final 在 completed terminal 时解析;stream chunk、failed/canceled 残留正文与重复 callback 都不解析。有效 targets 与唯一 `message_wake` 必须在同一事务写入;Queue entry 的某个 target 被实际 dispatch 时,再把**这条被投递 History 消息自身**的对应 ref 原子创建为 `dispatched`。因此 completed response bubble 若投给下一跳,既保留上一跳终局,也直接在同一 bubble 下显示下一跳 target 的处理头像,不复制“来源消息”或“投递状态消息”。 没有有效目标时,消息或 completed bubble 只公开给用户,不创建 Queue entry,也不猜测下一只 Agent。独立 Agent message 若由 live invocation 产生,携带 `producerInvocationId`;response bubble 已携带自身 `invocationId/targetId`。这些只是内容因果元数据,不能替代 source owner 的 invocation binding。普通 `message_wake` 的 `payload.messageId` 足以让 dispatch owner 查验是否存在对应 structured handoff;action successor 与 event wait 产生的 `private_input` 也只在 Queue 保存 inline payload,typed carrier 仍留在 owner store。任何 owner 都不得等到 terminal 再从“当前 holder”或聊天正文猜 source。 ### 5.4 targets 在 enqueue 时记录、在队首按 entry kind 确认 -入口解析结构化 mention 并把当时有效的 exact `targets` 写入 Queue Entry;这只形成 `assigned` target intent,不在 enqueue 时猜默认成员。entry 成为队首后先得到 actual target set,再按 `kind` 处理: +入口先解析结构化 mention。普通用户输入没有有效显式 target 时,服务端立即用 canonical conversation resolver 从 Chat History 反向选择最近一条 `status='completed'` 响应的当前可用回复成员;`processing / failed / canceled / interrupted` 都不能成为候选,没有历史成员时才使用服务端默认成员。解析结果与 source record 在同一个 Queue admission 中持久化为 pending `targets`,因此 Queue row、Send response 与后续 Steer 从未依赖 Web 猜测或一个暂时的空数组;source message 的 `mentions` 仍只记录作者真正写出的结构化 mention,不伪造 `@`。entry 成为队首后用同一 resolver 重新验证 stored targets,再按 `kind` 处理: -- `conversation_input`:按当前 thread membership 重新验证 stored targets;若结果为空,只有当该 thread 没有任何 Active Run 时才从 Chat History 反向找到最近一条 `status='completed'` 响应气泡的回复成员,并确认该成员当前仍可用;`processing / failed / canceled / interrupted` 都不能成为 fallback 候选。没有历史成员时才使用服务端默认成员;默认成员也不可用时,走 §7.1 的 pre-admission failure transaction,不永久卡住队首。 -- `message_wake`:它只因 Agent completed final / `post_message` 的显式 target 才进入 Queue。target 在 head 时失效必须走 §7.1 typed pre-admission failure;若其 `payload.messageId` 对应的 structured dispatch owner 返回 predecessor route,则交回 predecessor 决定改投或上升,否则只留下公开结果;不能 fallback 到最近成员。 -- `private_input`:必须携带来源给定的 exact target,不允许 targetless;所有 payload 都走相同的 priority Queue 与 admission,只使用 envelope 显式给出的 `urgent / normal`,不因用途获得隐式优先级。Queue 不读取 payload 来判断它属于 action、wait、callback 还是 failure return。target 失效时留下 internal terminal diagnostic 并移除 entry,不写 History message、不 fallback,也不把私有正文暴露给其他成员。若上游另有 source owner binding,owner 独立校验其 generation/current custody;这不改变 Queue 的处理分支。 +- `conversation_input`:仍可用的 stored targets 保持原顺序;成员删除或 availability 变化导致整组失效时,同一 resolver 重新选择最近 completed responder/default。历史 schema、恢复或外部旧 writer 留下的 public targetless row 也只在 head recovery 走这条规则;默认成员也不可用时,走 §7.1 的 pre-admission failure transaction,不永久卡住队首。 +- `message_wake`:它只因 Agent completed final / `post_message` 的显式 target 才进入 Queue。target 在 head 时失效必须走 §7.1 typed pre-admission failure;原 Agent message 保留公开 failure 结果,dispatching member 之后按 exact source×target 反查;不能 fallback 到最近成员。 +- `private_input`:必须携带来源给定的 exact target,不允许 targetless;所有 payload 都走相同的 priority Queue 与 admission,只使用 envelope 显式给出的 `urgent / normal`,不因用途获得隐式优先级。Queue 不读取 payload 来判断它属于 action、wait 还是 callback。target 失效时留下 internal terminal diagnostic 并移除 entry,不写 History message、不 fallback,也不把私有正文暴露给其他成员。若上游另有 source owner binding,owner 独立校验其 generation/current custody;这不改变 Queue 的处理分支。 -用户/Connector/定时任务等 conversation input 中的裸 `@`、代码片段、未知成员或解析失败只产生 routing warning,并让 `targets=[]`;warning 在 Queue row 中即可见,输入进入 History 时继续随消息保留。 +用户 conversation input 中的裸 `@`、代码片段、未知成员或解析失败只产生 routing warning,不把无效 mention 当 target,也不在 ingress 静默选择 canonical default;若没有其他有效显式 target,该 row 保持 targetless,由既有 Queue-head warning/fallback 语义处理。warning 在 Queue row 中即可见,输入进入 History 时继续随消息保留。Connector/定时任务若代表公开 conversation input 也复用这条入口;typed `private_input` 不得借此 fallback。 -因此只有 `kind='conversation_input'` 才能走 targetless fallback。fallback 一旦在队首解析成功,选中的成员就是这次 dispatch 的 actual target;History materialize、processing bubble、Active Run 与头像 ref 必须使用同一 target set,不能继续显示空 target、失效 mention 或 provenance sender。`private_input` 进入同一 Queue、按 comparator 位置等待且必须 exact-target;Agent message ref 也不能借空 targets 制造一个成员 invocation。public targetless input 不会在其他成员仍运行时猜目标。 +因此只有 `kind='conversation_input'` 能在 admission 重验或历史 targetless recovery 时 fallback。选中的成员就是这次 dispatch 的 actual target;History materialize、processing bubble、Active Run 与头像 ref 必须使用同一 target set,不能继续显示空 target、失效 mention 或 provenance sender。`private_input` 进入同一 Queue、按 comparator 位置等待且必须 exact-target;Agent message ref 也不能借空 targets 制造一个成员 invocation。 ### 5.5 汇合图:单目标、多目标与失败 @@ -782,12 +789,12 @@ sequenceDiagram I->>Q: enqueue payload + targets Note over H: 已有 sourceRecordId;尚无 History membership/orderKey else Agent post_message - I->>H: publish message + assigned refs + I->>H: publish message I->>Q: same transaction enqueues message_wake ref + targets end - S->>Q: peek exact head, wait until all targets admissible + S->>Q: peek exact head and resolve dispatchable target(s) Note over Q,H: one admission transaction - S->>Q: take exact entry / compatible conversation prefix + S->>Q: remove exact target(s); keep pending siblings in same entry S->>H: materialize/reuse delivered messages + actual-target refs=dispatched + processing bubble(s) par target B S->>B: dispatch exact inputs @@ -802,12 +809,10 @@ sequenceDiagram H->>Q: enqueue ref to same C responseMessageId end end - opt a target fails and exact pre exists - S->>Q: enqueue private_input with failure evidence payload - end + Note over S,H: canceled/interrupted only settle exact refs; failed also admits one exact idempotent caller control wake ``` -单 target 与 multi-target 共用这条主链:一条 `@B @C` 消息仍只有一个 Queue entry 和一份公开 input;admission 后才分别拥有 B/C 的 run 与 response bubble。某个 target 失败不会回滚已经被 sibling 接受的运行。 +单 target 与 multi-target 共用这条主链:一条 `@B @C` 消息在 Queue 中始终只有一条 source entry,`targets=[B,C]` 保存尚未投递的成员。初始 enqueue 对完整 target set 原子写入;普通 drain 一次领取当下完整 exact set 并并发建立 B/C 的 receiver/ref,Steer 或未读接管则可以只领取其中一个 target。无论哪种领取方式,每个 target 都拥有自己的 run 与 response bubble;某个 target 的 provider outcome 不会回滚已经被 sibling 接受的运行。 三种顶层 QueueEntry 只在 admission 汇合:`private_input` 是 `priority Queue → exact private input`;public conversation input 是 `priority Queue → History`;`message_wake` 是 `History → priority Queue ref`,其中 Agent Client 的 completed final 先原位终局既有 response bubble,只有解析出有效目标时才让 Queue 引用同一个 `responseMessageId`。不能把它们抽象成“所有消息先写 History”,也不能把 completed final 复制成第二条 Agent message。 @@ -825,7 +830,8 @@ Admission Coordinator 不是 timer 或优先级 scheduler,也不由“任意 H 2. Queue Entry remove 或 reorder; 3. Active Run 终局并被删除; 4. external structured source owner 提交了会改变某条**尚未 admission** Queue candidate 可执行性的 exact typed fact,并通过既有 post-commit event/outbox 发出信号; -5. 进程启动发现 durable Queue 非空。 +5. 进程启动发现 durable Queue 非空; +6. 当前 source 的共享 claim 已关闭:全部 targets 已退役,或失败 target 已恢复 pending,Queue 可重新判定队首。 每个事件在自身提交成功后调用 `requestDrain(threadId)`。第 4 类事件只负责唤醒 coordinator;它不凭 owner terminal 宽匹配删除 Queue/custody/ref/tracking,也不存在可在 admission 前复用的 admission-time binding。drain 必须重新读取 exact source candidate,再由 §7.1 的 Queue revision CAS 决定 admission、关闭或继续 fail closed。所有正常调度、Append、Steer、Cancel 与 Queue 重排都经过同一个 per-thread mutation coordinator。 @@ -846,7 +852,7 @@ async function runDrain(threadId: string): Promise { while (true) { state.dirty = false - await drainExecutableHeads(threadId) + await tryDrainHead(threadId) if (state.dirty) continue @@ -861,36 +867,26 @@ drain 已运行时,新事件不能被“已有任务”简单吞掉;它必 ### 6.3 单一稳定 comparator + 严格 head drain ```ts -async function drainExecutableHeads(threadId: string): Promise { - while (true) { - const head = await queue.peekByComparator(threadId) - if (!head) return +async function tryDrainHead(threadId: string): Promise { + const head = await queue.peekByComparator(threadId) + if (!head) return - const resolution = await resolveHead(threadId, head) - - if (resolution.kind === 'wait_for_idle') return - if (resolution.kind === 'terminal') { - await terminalizePreAdmissionFailure(head, resolution.failure) - continue - } - if (resolution.targets.some(target => activeRuns.has(threadId, target))) { - return - } + const resolution = await resolveHead(threadId, head) - const entries = head.kind === 'conversation_input' - ? await queue.collectCompatibleConversationPrefix({ - head, - routingClass: resolution.routingClass, - resolvedTargets: resolution.targets, - fallbackSnapshot: resolution.fallbackSnapshot, - }) - : [head] - const admitted = await admitExactBatch(entries, resolution.targets) - if (!admitted) continue // 被显式操作或另一 owner 先取走 - - await launchAll(admitted) - // 只等 provider 接受或明确拒绝,不等完整回复。 + if (resolution.kind === 'wait_for_idle') return + if (resolution.kind === 'terminal') { + await terminalizePreAdmissionFailure(head, resolution.failure) + requestDrain(threadId) + return } + if (resolution.targets.some(target => activeRuns.has(threadId, target))) return + + const admitted = await admitExactSource(head, resolution.targets) + if (!admitted) return // 被显式操作或另一 owner 先取走 + + await launchAll(admitted.targets) + // source 的 exact target set 完成 durable handoff 后再 requestDrain; + // 不等完整回复。若下一条仍不可执行,try-drain 无副作用返回。 } ``` @@ -907,31 +903,22 @@ M1 阻塞时 B 不启动。 A 终局 → 删除 Active Run → requestDrain → M1 被处理 → M2 随后启动。 ``` -FIFO 约束同 priority、未手动定位 rows 的 dispatch 顺序,不要求所有 client 串行执行。M1 被 provider 接受后,drain 可以继续启动 M2;A 与 B 可以并发运行。 +FIFO 约束同 priority、未手动定位 rows 的 dispatch 顺序,不要求所有 client 串行执行。M1 的 exact target set 完成 durable handoff 后,drain 可以继续尝试 M2;A 与 B 可以并发运行。若 M2 与仍 active 的 M1 争用同一 target,这次尝试直接返回,M1 终局会再次触发。 Queue 没有 `urgent / normal` 之外的数值等级、kind/system-category prefix 或第二条隐藏队列。Queue UI 只渲染可见 rows;拖拽完成后不是只发送一个含糊的“移动 V3”,而是发送当前 revision 下完整的可见顺序。后端验证集合后把这些 rows 原子写为 `position=0..n-1`;有 position 的 rows 按 position 排在未定位 rows 前,因此用户手动排序是唯一可以覆盖默认 priority/FIFO 的普通路径。隐藏 private rows 不出现在命令中,仍按其显式 priority 与 FIFO 保持唯一相对顺序。 例如 comparator 当前投影为 `[V1, C1, V2, C2, V3]`,其中 `C1/C2` 为未定位隐藏 rows;UI 显示 `[V1,V2,V3]`。用户把 `V3` 拖到 `V1/V2` 之间后提交完整列表 `[V1,V3,V2]`,服务端在同一 revision 原子写入 positions,唯一结果为 `[V1,V3,V2,C1,C2]`。这不是从可见锚点猜隐藏行应落在哪一侧,而是现行 comparator 的明确规则:手动定位集合在前,未定位集合在后;两组内部各有唯一排序。若拖拽期间 Queue 新增、删除或 eligibility 改变,revision/集合校验失败,前端刷新后重试。需要立即作用于当前执行的行为仍必须是带 exact entry 的 Append/Steer,不能由 private kind 暗中获得。 -### 6.4 兼容队首批次:一次 dispatch,不合并消息 - -只有 public `conversation_input` 参加队首批次;`private_input` 始终作为 exact 私有输入单独 admission,Agent `message_wake` 依靠 §8.3 的实际未读投影消除重复 wake。`collectCompatibleConversationPrefix` 只取得从当前队首开始的最长兼容前缀: - -1. 每个 entry 仍是独立消息,顺序连续且没有被用户显式操作; -2. routing class 相同:要么都是显式 targets,要么都是 targetless;不能仅因为 fallback 恰好等于某条显式 target 就混批; -3. 按队首时刻解析后的 exact target set 完全相同;连续 targetless entries 使用同一个 fallback 快照; -4. 所有目标都可 admission;遇到第一个非 conversation input、不同 routing class、不同 target set、不可解析项或操作边界立即停止; -5. conversation inputs 分别生成 History message,不拼正文; -6. 每个 target 只创建一个 Active Run 和一个 response bubble,`inputEntryIds/inputMessageIds` 保存完整独立列表。 +### 6.4 一次 drain 一条 source:不合并消息 -因此连续三条 `M1/M2/M3 → B` 可以一次拉起 B,但 History 中仍是三条输入,Queue UI 仍可在 admission 前分别重排、删除或 Steer。batch 是一次 client 调用的输入集合,不是新领域对象,也不改变“一条输入一个 entry”。 +因此连续三条 `M1/M2/M3 → B` 始终由三次 source admission 处理;History 中是三条输入,Queue UI 也可在 admission 前分别重排、删除或 Steer。M1 交给 B 后,M2 的 try-drain 会因 B 仍 active 而无副作用返回;B 终局后既有 completion 信号再次尝试 M2。任何 provider 内部优化都不能把多个 source Queue Entry 合并成一个持久领域对象。 ### 6.5 为什么不会静默积压 - enqueue、remove、reorder 后都有 post-commit `requestDrain`; -- 可执行 head 会在 drain 循环中被处理,直到 Queue 空,或 head 被 Active Run / source-owner evidence 明确阻塞; +- 每次 try-drain 只处理一个 head;成功 cutover、pre-admission terminal、enqueue/reorder/remove 与 Active Run 终局都会再次发出普通 drain 信号,直到 Queue 空或 head 被明确阻塞; - 若 head 只因 target occupancy 被阻塞,至少存在一个阻塞它的 Active Run;该 run 终局时必然再次 `requestDrain`; -- targetless head 只等到最后一个 Active Run 删除,同一个终局事件会立即重新触发; +- 普通无 `@` 输入已经带着 canonical fallback target 入队;若该 target 正忙,就由它的终局事件立即重新触发 drain。只有历史/恢复,或带无效显式 `@` 与 routing warning 的 targetless head,仍等待安全的 head-time recovery; - 若 structured head 因 `insufficient_evidence` 被阻塞,它不是“可执行 head”;exact source owner 的后续 evidence/eligibility mutation 通过第 4 类 post-commit 信号再次触发 typed read,不靠 timer 猜测; - drain 运行期间到达的新事件置 dirty bit,不会落在 owner 退出窗口; - 持久提交后、调用 `requestDrain` 前进程退出,由启动扫描重新触发。 @@ -940,17 +927,17 @@ Queue 没有 `urgent / normal` 之外的数值等级、kind/system-category pref ### 6.6 多目标消息 -一条 `@B @C` 仍是一个 Queue Entry。只有 B、C 都空闲,且相关 structured source owners 都能按 target 建立 exact invocation binding 时才 admission;若兼容前缀包含多条 conversation input,则每条分别创建公开 History message,再为 B、C 各创建一个 Active Run 与响应气泡,并发调用 provider。 +一条 `@B @C` 在 Queue 中始终是一条 entry,`targets=[B,C]` 只表示尚未实际投递的目标。初始 enqueue 对完整 target set 原子写入,不产生半条 source 或 ghost target。普通 drain 只有在本轮 exact target set 都可 admission 时才领取整组;B/C 各自写 durable response/ref 后,在同一个 source claim 内独立删除自己并启动 provider。Steer 或未读接管领取 singleton B 时,同样把 entry 原位更新为 `targets=[C]`,C 留给后续 drain。 -这是严格 FIFO 下的 all-or-none admission。某个 target 启动失败不会取消已经被其他 target 接受的 sibling;每个 target 的响应气泡独立终局。 +all-or-none 只约束初始 source row 的写入与普通 drain 对本轮 frozen target set 的领取;actual cutover 按 target 独立提交。B 的 receiver/ref durable 后原子删除 B 并启动 B,原 claim 继续保护尚未完成 handoff 的 C;若 C admission 失败,只把 C 恢复 pending,不中断或重投 B。每个 actual target 独立拥有 invocation、响应气泡与 terminal,某个 target 执行失败不会取消 sibling。 ## 7. Admission、响应气泡与运行终局 ### 7.1 Pre-admission failure 关闭 exact entry,不制造假 run -两类公开失败发生在合法 invocation 形成之前:public targetless input 找不到可用 fallback/default;Agent `message_wake` 的显式 target 已失效。它们不能创建要求 `targetId + invocationId` 的 `ResponseBubble`,也不能把 entry 留着反复重放。`private_input` 的 exact target 失效时也必须关闭 exact Queue entry,但只留 internal terminal diagnostic,不写 `DeliveryFailureResult` 或 History message。若另行登记的 source owner binding 已 stale,由 owner 在同一 cutover 前独立拒绝;Queue 不从 payload 推断或校验 typed carrier。 +两类公开失败发生在合法 invocation 形成之前:conversation input 的 stored target 在 admission 重验后失效且找不到 completed/default fallback(也包括历史 targetless recovery);Agent `message_wake` 的显式 target 已失效。它们不能创建要求 `targetId + invocationId` 的 `ResponseBubble`,也不能把 entry 留着反复重放。`private_input` 的 exact target 失效时也必须关闭 exact Queue entry,但只留 internal terminal diagnostic,不写 `DeliveryFailureResult` 或 History message。若另行登记的 source owner binding 已 stale,由 owner 在同一 cutover 前独立拒绝;Queue 不从 payload 推断或校验 typed carrier。 -`terminalizePreAdmissionFailure` 在 per-thread coordinator 中调用一个持久事务: +`terminalizePreAdmissionFailure` 在 per-thread coordinator 中调用一个持久事务。对多目标 entry,它只移除 failure 对应的 exact pending target;仅当 `targets[]` 为空时才删除 entry: ```ts async function terminalizePreAdmissionFailure(entry, failure) { @@ -970,45 +957,44 @@ async function terminalizePreAdmissionFailure(entry, failure) { reason: failure.reason, body: failure.userFacingBody, }, - privateReturns: failure.predecessorReturns ?? [], ownerDiagnostic: failure.ownerDiagnostic, }) } ``` -事务必须同时验证并移除 exact entry、确定 `inputMessageId`、写一条 `DeliveryFailureResult`,以及提交 source owner 已经返回的 failure-evidence private input / internal diagnostic(若有)。若失败的是已有 Agent History message 的 `message_wake`,同一事务还必须把对应 target 的 `assigned` ref 推进到 `settled`,并让 `statusMessageId` 指向该 `DeliveryFailureResult`。正常 drain 的 `expectedSelection` 要求它仍是 head;Append/Steer 的 `expectedSelection` 要求 exact selected entry/revision 仍成立,不能借失败路径绕过另一个已赢得 take 的 owner: +事务必须同时验证 exact entry/target、从 `targets[]` 移除该 pending target、确定 `inputMessageId`,并写一条 `DeliveryFailureResult` 或 internal diagnostic。若失败的是已有 Agent History message 的 `message_wake`,失败结果直接引用原消息;此前没有 actual dispatch,所以不存在需要“推进”的中间 ref。Agent-authored source 在同一持久提交后登记 exact caller observation,但不创建 Queue work。正常 drain 的 `expectedSelection` 要求它仍是 head;显式操作则以实时 Queue/History join 跳过已经投递的 target,不能借失败路径重复处理另一个已赢得 cutover 的 target: - `conversation_input` 在同一事务中先 materialize 为公开 History message,紧接着分配 failure result 的 `orderKey`;两者在聊天面板相邻,不能只有失败而丢失用户原输入; - `message_wake` 引用的 message 保持原 `orderKey`,failure result 在 terminal transaction 时取得新的 `orderKey` 并引用原 `payload.messageId`; -- exact take 失败则什么都不写;事务成功后 entry 已终局,不能再次进入 drain 或产生 client side effect; +- exact target mutation 失败则什么都不写;事务成功后该 target 已终局,不能再次进入 drain 或产生 client side effect;entry 中其他 pending siblings 不受影响; - source owner 明确判定 binding/candidate 已替换时,只消费仍由 Queue 持有的 exact entry 并让对应 owner 记录 diagnostic;若 owner read 是 `insufficient_evidence`,则不消费 entry,保持 pending 并 fail closed。两者都不能伪造旧 generation 的成功 disposition,也不能靠解析 private payload 重建 binding。 source owner 在 admission 前提交 terminal、replacement 或新的可验证证据时,改变的只是这条 unadmitted candidate 的 typed 判定。owner 的既有 post-commit event/outbox 必须携带可定位该 Queue candidate 的 exact source identity 并触发 `requestDrain(threadId)`;coordinator 随后重读 owner fact,只有 `entryId + Queue revision + typed candidate` 全部仍匹配时,才通过上述 pre-admission transaction 整条关闭或 admission。same fact 重放只会遇到 entry 已不存在并 no-op;旧 generation 的 event 不能关闭 fresh candidate。若 event 与 Queue 位于不同 store,durable owner event 是信号真相,Queue CAS 是消费真相,不新增 retirement ledger或 timer fallback。 -一旦 admission transaction 提交,Queue entry 已经消失;此时 source owner 的 terminal/disposition 只随 §7.4 收敛 processing bubble、refs 与 owner 自己的责任事实,绝不存在“failed target 仍在原 Queue 等 Retry”的正常状态。若历史数据出现同一 target 同时 `failed/terminal` 与 Queue member,它是 §13.1 的迁移异常,不是运行时分支。 +一旦某 target 的 admission transaction 提交,该 target 已从 Queue `targets[]` 消失;entry 只有在没有其他 pending sibling 时才删除。此后该 target 的 terminal/disposition 只随 §7.4 收敛 processing bubble、refs 与 owner 自己的责任事实,绝不存在“failed target 仍在 Queue 等 Retry”的正常状态。若历史数据出现同一 target 同时 `failed/terminal` 与 Queue pending,它是 §13.1 的迁移异常,不是运行时分支。 -私有 entry 走同一个 exact take/CAS 边界,只是结果写 internal diagnostic 而不写 History。需要 terminal disposition 的 source owner 从自己的 invocation binding 找回 typed carrier;failure-evidence private input 的生产者不登记新的 predecessor binding。两者在 Queue 中仍是完全相同的 `private_input`。两条路径仍然只有 Queue membership 与 Chat History 两个业务真相源;`DeliveryFailureResult` 是 History message 的一种,不是新 lifecycle ledger。 +私有 entry 走同一个 exact take/CAS 边界,只是结果写 internal diagnostic 而不写 History。需要 terminal disposition 的 source owner 从自己的 invocation binding 找回 typed carrier。A2A target failure 不生成 private input;它使用 exact `a2a_failure` message wake 引用 History result。两条路径仍然只有 Queue membership 与 Chat History 两个业务真相源;`DeliveryFailureResult` 是 History message 的一种,不是新 lifecycle ledger。 ### 7.2 Admission 是唯一运行 cutover -`admitExactBatch` 在 per-thread coordinator 内完成: +`admitExactSource` 在 per-thread coordinator 内完成: -1. 再次确认 exact entry 列表仍是从当前队首开始的兼容前缀; +1. 再次确认这条 exact entry 仍是当前 comparator head,并以实时 Queue `targets[]` × History `dispatchRefs` 计算仍待投递的 exact targets; 2. 重新确认 stored targets 或同一 targetless fallback 快照;对 `message_wake` 验证 `payload.messageId`,对 `private_input` 只验证 inline payload 与 exact target,不按正文内容分流;若 entry 另有 source owner,则 owner 从自己的 typed state 产生 exact preflight candidate,冻结 owner kind、lease/generation、predicate/HEAD 与 principal/tenant/route; 3. 为每个 resolved actual target 生成 `invocationId + responseMessageId + startedAt`,并让相关 source owner 准备把 exact entry/dispatch 与该 frozen decision 绑定到该 invocation;同 HEAD 但 generation 已替换也属于 stale; -4. 持久化尚未激活的 exact callback principal;principal mint/persist 失败时整批 entries 保持 Queue 中,不能报告 accepted; -5. 一个持久事务原子完成:existing owners CAS 验证 preflight candidates 仍 current,并提交 immutable admission bindings;exact take 整个前缀;每条 conversation input 复用 `sourceRecordId` 为公开 `messageId` 并分配 `orderKey`,只为 admission 时重验/回退所得的 resolved actual targets 直接写 `dispatched` refs;每条 message wake 分别验证 `payload.messageId`,把对应 actual target 的 `assigned` ref CAS 推进到 `dispatched`;`private_input` 不写 History message/ref,只返回 exact 私有输入;随后为每个 actual target 写一条引用完整 `inputEntryIds/inputMessageIds` 的 `processing` ResponseBubble,把各 ref 的 `statusMessageId` 固定到该 bubble,并激活 callback principals; +4. 持久化尚未激活的 exact callback principal;principal mint/persist 失败时该 source entry 保持 Queue 中,不能报告 accepted; +5. 一个协调 fan-out 按固定顺序完成:existing owners CAS 验证 preflight candidates 仍 current,并提交 immutable admission bindings;conversation input 在第一次 actual dispatch 时复用 `sourceRecordId` 为公开 `messageId` 并分配 `orderKey`,后续 sibling dispatch 复用同一 History message;message wake 验证 `payload.messageId`;每个 actual target 并发写一条引用本 source 的 `inputEntryIds/inputMessageIds` 的 `processing` ResponseBubble,并直接创建指向该 bubble 的 `dispatched` ref;该 target 随即在共享 source claim 内从 `targets[]` 原子删除并激活自己的 callback principal,空数组时删除 entry;`private_input` 不写 History message/ref,只返回 exact 私有输入; 6. 在调用 provider 前创建 Active Run; 7. 调用 provider,明确得到 accepted 或 failure。 第 5 步的 processing bubble 是最小的 durable `admitted, delivery result outstanding` witness;它属于 Chat History,不是持久 Active Run、responsibility authorization 或第四个业务状态面。typed carrier 与 immutable admission binding 留在各自 existing owner;Queue/History 不复制 owner state。startup 只可用 bubble 的 exact `invocationId` 查询并验证原 binding,不能从 History、carrier 或 current identity 反推/重选 source。 -callback principal 只有在第 5 步 admission commit 成功时才激活。若 exact-prefix take 或任一 owner binding CAS 失败,整个事务没有 Queue/History/owner 副作用,尚未激活的 principal 可以直接丢弃;它从未授权 client callback,也不算一次 admitted run。下一轮由同一 owner 对原 preflight candidate 做 typed 判定:若已被替换,则用 CAS 走 §7.1 terminal;若证据不足,则 entry 保持 pending 并 fail closed。任何分支都不能从 carrier/current identity 选择新 generation,也不能把 entry 留成未受约束的可重放工作。 +callback principal 只有在第 5 步 Queue cutover 成功时才激活。若 preflight 或 owner binding 在任何 receiver 建立前失败,Queue/History 均不改变,尚未激活的 principal 直接丢弃;一旦部分 receiver/ref 已 durable,恢复就以 `sourceRecordId × targetId` join History:已有 ref 的 target 只做幂等 Queue 清理并收敛其 response,缺失 ref 的 target 恢复 pending,绝不整组盲重投。正常 Steer 并发不把整批用户操作报成 stale:服务端重读 live `targets[]` 与 History refs,已经投递的 target 直接跳过,只对仍 pending 且合法的选择提交 cutover。structured owner fence 不匹配仍按 typed conflict/fail-closed 处理。任何分支都不能从 carrier/current identity 选择新 generation,也不能把 entry 留成未受约束的可重放工作。 ```ts -async function admitExactBatch(entries, targets) { +async function admitExactSource(entry, targets) { const sourceBindings = await structuredSources.prepareExactInvocationBindings({ - entries, + entries: [entry], targets, }) if (sourceBindings.kind === 'terminal') { @@ -1016,17 +1002,15 @@ async function admitExactBatch(entries, targets) { return null } - const prepared = await prepareAdmissions(targets, entries) + const prepared = await prepareAdmissions(targets, [entry]) await principals.persistAll(prepared) - const admission = await stores.takePrefixAndMaterializeAdmission({ - expectedEntryIds: entries.map(entry => entry.id), - payloads: entries.map(entry => entry.payload), + const admission = await stores.takeSourceAndMaterializeAdmission({ + expectedEntryId: entry.id, + payload: entry.payload, targets: targets, responses: prepared.map(toProcessingBubble), - dispatchRefTransitions: prepared.flatMap(item => - entries.map(entry => toDispatchedRef(entry, item)) - ), + dispatchRefTransitions: prepared.map(item => toDispatchedRef(entry, item)), sourceBindingCommits: sourceBindings.commits, activatePrincipalIds: prepared.map(item => item.principalId), }) @@ -1037,7 +1021,7 @@ async function admitExactBatch(entries, targets) { return prepared.map(item => { const run: ActiveRun = { - threadId: entries[0].threadId, + threadId: entry.threadId, targetId: item.targetId, invocationId: item.invocationId, responseMessageId: item.responseMessageId, @@ -1067,7 +1051,7 @@ processing bubble(固定 id/orderKey) 成功但没有 CLI 正文时,也必须把同一气泡终局为可理解的“处理完成但没有额外回复”,不能留下永久空气泡。 -stream chunk 只更新显示内容,绝不触发 target 解析或 enqueue。只有 Agent Client 返回的 canonical `completed` final 才解析一次结构化 `@`;若存在有效目标,terminal transaction 同时在该 response bubble 上写 `assigned` refs,并提交指向它的 `message_wake`。`failed / canceled / interrupted` 的残留输出以及重复 terminal callback 都不能制造 successor wake。 +stream chunk 只更新显示内容,绝不触发 target 解析或 enqueue。只有 Agent Client 返回的 canonical `completed` final 才解析一次结构化 `@`;若存在有效目标,terminal transaction 提交指向该 response bubble 的唯一 `message_wake`,pending targets 留在该 Queue entry;真正投递时才创建 `dispatched` refs。`failed / canceled / interrupted` 的残留输出以及重复 terminal callback 都不能制造 successor wake。 failed/canceled 不新建第二条 chat message,也不把“已取消”“token 耗尽”等系统文字拼进 Agent 正文。terminal transaction 保留已累计 body,并在同一 record 上写 `status + typed reason`;前端把 reason 渲染成气泡内的状态 footer/chrome,例如“已取消;以上为取消前生成的内容”或“因 token 上限中断”。没有任何正文时仍显示同一个 status-only bubble。这样一轮运行只有一个输出身份,半截内容可读,系统状态也不会伪装成 Agent 新说的一句话。 @@ -1091,9 +1075,6 @@ async function onRunTerminal(run, terminal): Promise { invocationId: run.invocationId, terminal, }) - const predecessorReturns = isFailureLike(terminal) - ? sourceDisposition.predecessorReturns - : [] const finalized = await stores.finalizeResponseApplyDispositionsAndEnqueueFollowups({ responseMessageId: run.responseMessageId, @@ -1106,7 +1087,6 @@ async function onRunTerminal(run, terminal): Promise { targetId: run.targetId, statusMessageId: run.responseMessageId, })), - predecessorReturns, }) if (!finalized) return 'stale' @@ -1120,29 +1100,31 @@ async function onRunTerminal(run, terminal): Promise { } ``` -live Agent Client 对外只有 `completed / failed / canceled` 三类 terminal outcome。startup recovery 合成的 `interrupted` 复用同一个函数,但在 outcome branching 中按 failure-like 处理。不存在 `other` follow-up:`completed` final 只有在有效 target 存在时写引用同一 `responseMessageId` 的 `message_wake` entry;`failed/interrupted` 只有在 source owner 返回 exact predecessor 时才写回;owner 查询为空或返回 `null` 时直接跳过;`canceled` 不写 follow-up。 +live Agent Client 对外只有 `completed / failed / canceled` 三类 terminal outcome。startup recovery 合成的 `interrupted` 复用同一个函数。不存在 `other` follow-up:`completed` final 只有在有效 target 存在时写引用同一 `responseMessageId` 的 `message_wake` entry;`failed` 为 exact A2A caller 写一条幂等 `a2a_failure` message wake;`interrupted/canceled` 不自动创建 predecessor Queue work。 -terminal transaction 必须以 `expectedInvocationId` 找到该 invocation 在各 existing owner 中的 immutable admission binding,让每个 owner 用原 lease/generation/frozen predicate CAS 提交 terminal disposition,同时原位更新 bubble、把所有公开 exact input 对应该 target 的 ref 推进到 `settled`,并提交上述 exact follow-up。若 completed final 产生下一跳,当前 bubble 的输出 status 与其新建的 outbound `assigned` refs 也在这笔事务一起提交。某个 owner 可以据此完成责任、关闭当前 attempt、记录失败后继续等待,或返回 predecessor;delivery kernel 不把 `completed/canceled` 硬编码成所有 owner 的 responsibility terminal。任何 outcome 都不能让同一 Queue work 自动复活。普通 public input 若没有 source binding,owner 查询返回空集,bubble 与 refs 仍正常终局。 +terminal transaction 必须以 `expectedInvocationId` 找到该 invocation 在各 existing owner 中的 immutable admission binding,让每个 owner 用原 lease/generation/frozen predicate CAS 提交 terminal disposition,同时原位更新 bubble、把所有公开 exact input 对应该 target 的 ref 推进到 `settled`,并提交上述 exact follow-up。若 completed final 产生下一跳,当前 bubble 的输出 status 与其新建的唯一 outbound message-ref Queue entry 也在这笔事务一起提交;pending targets 尚不产生 ref。某个 owner 可以据此完成责任、关闭当前 attempt或记录失败后继续等待;delivery kernel 不把 `completed/canceled` 硬编码成所有 owner 的 responsibility terminal,也不把 owner disposition 转译成普通 A2A predecessor work。任何 outcome 都不能让同一 Queue work 自动复活。普通 public input 若没有 source binding,owner 查询返回空集,bubble 与 refs 仍正常终局。 各 owner 的 namespace 与 generation 独立:无关成员后续 `hold_ball`、另一个 event wait 或 thread 展示 holder 的变化不能改写 action-successor/TurnExecution binding,也不能让 exact A2A success 变成 `holder_mismatch/source_missing`。若 owner CAS 不匹配,整个 terminal commit fail closed,bubble 保持 durable outstanding witness;startup/retry 以 bubble 的 `invocationId` 重试同一个 typed disposition,而不是从 bubble 复制 carrier、猜 source 或追加第二条结果。 -response bubble terminal、typed owner dispositions 与该 outcome 的 exact follow-up entries 必须在同一个持久事务中提交;否则进程可能在几次写入之间退出,留下“用户看到了 completed final,但目标没有 wake”,或“用户看到了失败,但 exact predecessor 永远没收到 owner 决定的通知”的半终局。这笔 durable transaction 与随后删除 in-memory exact Active Run 共同组成 run closure,但不能颠倒:事务成功后才执行“删除 exact Active Run → requestDrain”。若提前释放 run,owner CAS 失败时会丢掉 outstanding work;若先触发 drain 再释放 run,drain 会看到 busy 后退出且可能再也没有信号。 +response bubble terminal、typed owner dispositions、completed final 的显式-target follow-up,以及 failed A2A 的 exact caller fail-back 必须在同一个持久事务中提交;否则进程可能在几次写入之间退出,留下“用户看到了 terminal,但必要 wake 永久缺失”的半终局。interrupted/canceled 没有隐式 follow-up;failed 的 Queue-only control carrier引用同一 History response,重放按 response identity 收敛。这笔 durable transaction 与随后删除 in-memory exact Active Run 共同组成 run closure,但不能颠倒:事务成功后才执行“删除 exact Active Run → requestDrain”。若提前释放 run,owner CAS 失败时会丢掉 outstanding work;若先触发 drain 再释放 run,drain 会看到 busy 后退出且可能再也没有信号。 terminal API 以 `invocationId + exact admission fence` 幂等:same-generation duplicate replay 必须返回已提交的同一 delivery terminal、owner disposition 与 follow-up identities,不重新解析 final、追加第二个 wake 或产生第二次 typed verdict。若 external owner 的 verdict/event append 已提交而 projection/apply 在 crash 前未完成,recovery 只重放同一 verdict 对同一 bubble/ref 的 apply;compensation 不能 cancel 已提交的 verdict、换用 current generation,或把 append-won/apply-crashed 状态变成另一个 completed/failed semantic answer。不同 fence 或冲突 terminal 都是 stale/conflict,不得覆盖 canonical commit。 -launch failure 走同一终局路径:把已经存在的 response bubble 更新为 `failed`,source owner 返回 exact predecessor 时原子追加 predecessor return,删除 run,再 requestDrain。结果不会因为 client 没有输出而静默消失。 +launch failure 走同一终局路径:把已经存在的 response bubble 更新为 `failed`,提交已有 structured owner +disposition(若有)与 exact A2A caller fail-back,删除 run,再 requestDrain。caller 由 Queue control carrier 及时 +读取该 failed response;同一 response 仍是唯一公开结果。 -### 7.5 一跳终局与 exact predecessor +### 7.5 一跳终局与 caller observation 每个 target 的 response bubble 独立终局;它只闭合当前 input → target 这一跳,不递归等待目标后来又发起的工作: ```text A → B -B completed → source owner 按自己的 typed generation/predicate 提交 success disposition;delivery result 闭合,不以新消息重新唤醒 A -B canceled → source owner 按自己的 typed generation/predicate 提交 cancel disposition;delivery result 闭合,不以新消息重新唤醒 A -B failed → exact source owner 记录 failure/return disposition;公开 failed bubble;owner 返回 predecessor 时私下交回,由其决定改投或上升 -B interrupted→ exact source owner 记录 interrupted;因系统不重放,公开结果;owner 返回 predecessor 时按 failure-like 规则交回 +B completed → delivery result 闭合;A 下一次自然 invocation 可观察 completed,不自动唤醒 A +B canceled → delivery result 闭合;A 下一次自然 invocation 可观察 canceled,不自动唤醒 A +B failed → 公开 failed bubble;A 下一次自然 invocation 可观察 failed,不自动唤醒 A +B interrupted→ 公开 interrupted 结果;A 下一次自然 invocation 可观察 interrupted B completed final 本身含有效 @D → 原位终局 B 的同一 response bubble,并为同一 responseMessageId 建 Queue ref;不复制第二条 Agent message @@ -1150,18 +1132,21 @@ B completed 后又 post_message @D → 新建独立的 B → D history message + Queue ref ``` -一条 input 同时投给 B/C 时,B completed 与 C failed 可以同时成立:B 的 exact invocation binding 接收 success disposition,C 的 binding 接收 failure disposition 并在 owner 返回 pre 时交回 predecessor,不能用 aggregate failure 覆盖 sibling。一次 run 同时覆盖来自 A/C 的多个 structured dispatch 时,各 owner binding 分别按自己的 generation/predicate 提交状态变更;failure return 再按 exact predecessor 去重,各交回一次。 +一条 input 同时投给 B/C 时,B completed 与 C failed 可以同时成立:两条 exact source×target ref 各自指向自己的 terminal response,不能用 aggregate failure 覆盖 sibling。A 后续被自然唤起时从自己的 outbound dispatch view 分别反查两个结果,只清理实际进入该 turn 且成功提交的 exact item revision。 + +### 7.6 结果观察与显式 continuation + +普通 A2A terminal 只改写 canonical History response 与 source 上的 exact target ref;仅 failed outcome 依据持久 exact caller lineage 原子创建一个引用该 response 的 `a2a_failure` Queue control wake,不从正文、author 或当前 holder 猜 predecessor。dispatching member 的进程内 outbound dispatch view 以 `(owner, thread, caller, sourceMessageId, targetId)` 聚合自己提交的 Queue targets、已提交 Steer 增删和 actual-result 变化。item 保存 monotonic revision/presentedRevision,以及 Queue/History 已无法反查的 firstAddedBy、最新 selectionChange 与 selectionChangedBy(initial/steer/queue_withdrawal/unknown);当前进程首次看到遗留 remove/actual 事件且无法证明最初来源时,firstAddedBy=unknown。这些最小解释事实不参与执行裁决。view 不改写 source `dispatchRefs`,也不持久化为新账本。 -### 7.6 失败交回 +`a2a_failure` control wake 是对既有 response 的调度引用,不是失败方发起的新业务 dispatch;admission 不得为 +失败方登记新的 outbound caller-view item。否则失败方下一 turn 会错误观察到“自己向 predecessor 发起了一条 pending +消息”,把控制边重新包装成业务状态。 -是否需要 failure return 只看 source owner 对 exact `invocationId` 提交 disposition 后返回的 `pre`,不看 History author/source 字段,也不从 `run.inputMessageIds` 猜: +这条链按 source 隔离、按 target 结算:`M1→B` 与 `M2→B` 各自拥有 dispatchRef 与 view item;即使其它显式机制让一个 response 引用了两条 input source,也不能把两条 source 的 caller 状态合成一个 attempt。反之,`M1→B/C` 只有一条 Queue row,B/C 仍分别拥有自己的 ref/result。dispatchRef 关联已经发生的投递或投递失败事实,不表示 target 预期;仅凭 ref 存在不能断言 provider 已收到消息。Queue mutation 成功后只 touch 相应 view item;actual admission/terminal 不复制结果到 view,而在 caller 下一次自然 invocation 时用 canonical ref/response fingerprint 与上次呈现基线比较,发现变化才分配新 revision 并注入。 -- owner 查询为空或返回 `pre=null`:用户、Connector、scheduled、公开通知没有 structured source binding;公开 failed bubble 已经给出结果,直接进入共同 closure; -- `pre` 存在:source owner 已用自己的 typed carrier + generation 验证 exact predecessor,系统向同一 priority Queue 追加一条普通 `kind='private_input'` entry,`targets=[predecessorId]`,inline payload 带 exact input/failure 证据,并显式给出 `urgent / normal`(未给则 normal);它不进入 Chat History,只在 predecessor 被 dispatch 时进入 exact input / situation packet; -- 多个 owner bindings 返回同一 predecessor 时,本轮只交回一次;不同 predecessor 各收到自己的 exact evidence; -- 该 failure-evidence private input 的生产者不建立新的 predecessor binding,再次失败时不会递归生成交回树。 +每次自然 invocation 先对所有保留 view items 反查当前 Queue + source ref + canonical response,校验候选快照仍有效并按 canonical fingerprint 变化推进 revision,然后只把 revision>presentedRevision 的变化放入 prompt。因此 executing 已确认后,即使没有 Queue mutation,response 单独转 terminal 也会被下一 turn 发现。投影开始时冻结 includedRevision;异步查询期间 revision 变化时重读或保留待核对,不能用最新 revision 确认旧正文。pending/open 成功呈现后推进 presentedRevision 且保留 item,未变化不重复;terminal 或未实际投递的 removed target 只有在该 exact revision 实际进入最终 prompt、caller invocation 成功 completed 且 current revision 仍匹配时才 compare-and-clear。旧 prompt 对 rev2 的确认不能清理期间新写入的 rev3;item 清理后同 key 重建使用更高 revision/epoch;同一 caller 继续发送新的 source 只追加新 source×target items,不覆盖旧 source。 -failure return 不是第四种 QueueEntry,也不是 `private_input` 的 subtype,更不拥有 subtype-specific priority 字段;它只是 payload 正文不同,使用所有 QueueEntry 共用的 `urgent / normal` 字段与 comparator,未显式给出时默认 normal。 +需要“结果一到就续跑”时,必须由 hold/eventWait/action-successor 等显式 owner 产生 continuation。target 自主发出的 `@caller` 是一条新的普通 dispatch,不是 terminal 的隐式回报。 ## 8. Agent 未读上下文与顺序一致性 @@ -1179,7 +1164,7 @@ Queue Panel 是 admission 前的 staging view,不属于 Chat History 排序。 ### 8.2 ordering barrier -`status=processing` 的 response bubble 是 cursor barrier。Queue 中的 conversation input 尚未进入 History,因此不参与 cursor,也不需要伪造一个“前端可见、Agent 不可见”的 History 状态。已有、将被投递的 Agent History message 上的 `assigned` ref 也不构成一条新的未读消息。 +`status=processing` 的 response bubble 是 cursor barrier。Queue 中尚未发生首次 actual dispatch 的 conversation input 不在 History,因此不参与 cursor,也不需要伪造一个“前端可见、Agent 不可见”的 History 状态。已有、将被投递的 Agent History message 的 pending targets 只在 Queue 中,不构成一条新的未读消息或 History ref。 Agent 可以从被投递 History 消息的 `dispatchRefs` 与关联 bubble/thread snapshot 投影中知道“哪个成员正在处理这条消息”,但这只是 situation summary,不创建 read receipt,也不让持久 cursor 越过 processing bubble并把它当作正文已读。气泡终局后,下一次上下文在原位置读取完整正文,再推进 cursor;failed、canceled 与 interrupted 因此都会作为 canonical terminal 被后续 Agent 看见。 @@ -1191,12 +1176,12 @@ Agent 可以从被投递 History 消息的 `dispatchRefs` 与关联 bubble/threa 1. 走现有未读 cursor 取得可见上下文; 2. 将本次 admission 的 `inputMessageIds` 作为必选 exact inputs; -3. 记录每个 target 本轮实际 `projected/exposed` 的 message IDs; -4. 对仍在 Queue 中的 `message_wake` 做精确覆盖检查:只有该 entry 的所有 target 都在本次 admission 中、各 target 的实际投影都包含其 `payload.messageId`,且该 message 对应的 structured dispatch(若有)仍可由 owner 绑定到本次 exact invocations,它才是 fully covered; -5. 在 client side effect 前,用一个持久事务原子完成:owner CAS 提交 covered dispatch 的 invocation bindings;take 所有 fully covered wakes;把每条 wake 的 `entryId/messageId` 附到各 target 当前 processing bubble 与 Active Run; +3. 记录每个 target 本轮实际 `projected` 的 message IDs; +4. 对仍在 Queue 中的 `message_wake` 做精确覆盖检查:某个 target 的实际投影包含其 `payload.messageId`,且该 message 对应的 structured dispatch(若有)仍可由 owner 绑定到本次 exact invocation时,该 target 已被本轮实际投递;不要求同 entry 的 sibling targets 同时 covered; +5. 在 client side effect 前,用一个持久事务原子完成:owner CAS 提交 covered dispatch 的 invocation binding;从 source entry 的 `targets[]` 删除 exact covered target,空数组时删除 entry;为该 target 创建 `dispatched` ref,并把 `entryId/messageId` 附到当前 processing bubble 与 Active Run; 6. conversation input 与 `private_input` 不在 History,绝不能靠未读覆盖提前删除。 -例如 A→B、C→B 的两条消息都已公开,B 的本轮未读投影同时包含两者时,两条 wake 都可被同一次运行覆盖,B 不重复启动;两条 History message 仍然独立,且 B 失败时两个 dispatch owners 都能从其 durable invocation bindings 返回 A/C 的 exact predecessor route。窗口外或未被所有 target 覆盖的 wake 继续留在原队列位置,不能凭“可能读过”猜测清理。 +例如 A→B、C→B 的两条消息都已公开,B 的本轮未读投影同时包含两者时,两条 wake 都可被同一次运行覆盖,B 不重复启动;两条 History message 仍然独立,且 B 终局时两条 source×B ref 都指向同一个 canonical response。若 B failed,A/C 各获得一条 exact、幂等、引用同一 response 的 fail-back control wake;若 B 是其他终态,则二者在后续 invocation 由 view 反查。窗口外或未被所有 target 覆盖的 wake 继续留在原队列位置,不能凭“可能读过”猜测清理。 混合 wake 与 public conversation input 时仍只用这一条规则,不按排列另加分支: @@ -1206,33 +1191,33 @@ Agent 可以从被投递 History 消息的 `dispatchRefs` 与关联 bubble/threa 最后一种不是后项绕过队首 dispatch,而是删除一条已经被当前运行实际满足的 wake。conversation input 没有进入 History,不能被同一规则顺带取走。 -这项覆盖是 Queue wake 的已满足判定,不是把消息正文合并,也不允许后面的 conversation input 绕过队首进入本轮。structured dispatch owner 拒绝 invocation binding 的 wake 不能作为 covered wake 删除;它留在原位,轮到 exact head/selected action 时走 §7.1。禁止“只删 wake、不提交 owner binding、不附 input IDs”的半提交;否则后续 terminal 无法让 exact owner 判断是否需要 predecessor return。 +这项覆盖是 Queue wake 的已满足判定,不是把消息正文合并,也不允许后面的 conversation input 绕过队首进入本轮。structured dispatch owner 拒绝 invocation binding 的 wake 不能作为 covered wake 删除;它留在原位,轮到 exact head/selected action 时走 §7.1。禁止“只删 wake、不提交 owner binding、不附 input IDs”的半提交;否则后续 terminal 无法让 exact owner 提交自己的 typed disposition,也无法让普通 caller 精确反查 source×target 结果。 ## 9. Append、Steer、Cancel queued 与 Stop running | 用户动作 | Queue 操作 | Active Run / client 结果 | |---|---|---| | 正常等待 | 只由 drain 处理队首 | target busy 时等待终局事件 | -| Append | coordinator 取出选中的 public entry | 追加给 exact target set 的现有 Active Runs,不新建 run | -| Steer / Immediate | coordinator 取出选中的 public entry | 取消 exact target set 中仍 live 的旧 runs;若 entry 是 live producer invocation 产生的 Agent wake,也精确取消该 source run;随后为完整 target set admission 新 runs | -| Cancel queued | coordinator 删除选中 entry;若为 `message_wake`,同一事务移除尚未 dispatch 的 `assigned` refs | 不影响任何 Active Run | +| 立即发送,引导回复 | coordinator 从 source entry 取出选中的 pending target | 弹窗只按静态 client capability 决定是否可选;服务端确认时若存在 exact active parent invocation 则追加到该 run:`exact_active_turn` 精确投递到当前可见轮次,`queued_internal_turn` 追加到同一运行的下一内部轮次;不支持时 action 禁用,无 active parent 时保留为 `next_work` | +| 立即发送,中断回复 | coordinator 从 source entry 取出选中的 pending target | 取消该 exact target 仍 live 的旧 run;若 entry 是 live producer invocation 产生的 Agent wake,也精确取消该 source run;随后只为该 target admission 新 run | +| Cancel queued | coordinator 删除选中 entry 或 pending target;actual dispatch 前没有需要删除的 History ref | 不影响任何 Active Run | | Stop 指定 Agent | Queue 不参与,也不改变自动 drain | 只选择该 Agent 在操作边界仍 live 的 exact run,调用对应 Agent Client `cancel(exact invocation)`;client 正常回调 canceled terminal 后释放 run | | Stop thread 全部活动 Agent | Queue 不参与,也不改变自动 drain | 只对操作边界的全部 live Agent Client runs 做 exact snapshot,逐一调用各 Agent Client cancel;managed command/job/wait 不在集合中;各自正常 terminal closure 后释放 | -Queue row 可见只表示它仍待 admission,不授予 Immediate/Steer/Append。服务端从同一 snapshot 计算一个不持久化的 action projection:`entryId + Queue revision + 完整 stored target set + source-owner preflight verdict + exact Active Runs + client capabilities`。只有完整 entry 的操作前置条件成立时,才提供对应 action;命令端点必须重验完全相同的 snapshot,并在整条 entry 的 exact take 成功后才产生 client side effect。`private_input` 不对用户显示,只走正常 drain。 +Queue row 可见只表示它仍有 pending work,不授予 Steer。静态 guide capability 由 configured Agent Client 声明并随成员信息逐成员投影;exact active parent、pending/terminal 状态与 membership/availability 则来自当前 canonical snapshot。共享 capability predicate 把 `exact_active_turn` 和 `queued_internal_turn` 都归为「可引导当前 invocation」,但不会把后者说成精确同轮读取。Steer modal 的候选集合为 thread participants、stored routed targets 与 fallback 的并集去重;fallback 不在 Web 重算,而是读取 message ingress 与 Queue admission 共用 resolver 的只读投影:最近一条已完成 lifecycle 回复的实际 target,无此记录才取全局默认回复者。History 中已经存在 dispatch ref 的 target 显示已投递并禁选。打开 modal 不写 membership;命令端点确认时重读 live join,跳过已经投递或当前不可用的成员;新选择的可用成员只在确认时持久加入 thread,并在 exact pending-target cutover 成功后才产生 client side effect。`private_input` 不对用户显示,只走正常 drain。 -这份 projection 不引入新的 lifecycle 对象,也不把 execution outcome 复制回 Queue。正常模型中 Queue entry 在 provider execution 前已经被移除,所以 action reducer 不接受 `failed + queued` target,也不把 terminal attempt 的 Retry 伪装成旧 Queue action。若 external custody owner 支持 Retry,它对 terminal result 创建一次新的 attempt / Queue work,不能复活或部分 claim 原 entry。snapshot 已变化时命令返回 typed conflict 与当前 canonical Queue/action projection;UI 使旧确认失效并刷新,不能自动重复相同命令。 +这份 projection 不引入新的 lifecycle 对象,也不把 execution outcome 复制回 Queue。正常模型中 actual target 在 provider execution 前已经从 `targets[]` 删除,所以 action reducer 不接受同一 target 的 `failed + pending`,也不把 terminal attempt 的 Retry 伪装成旧 Queue action。若 external custody owner 支持 Retry,它对 terminal result 创建一次新的 attempt / Queue work,不能复活原 target。普通 Steer snapshot 变化由服务端 live join 吸收;只有 structured owner fence、source identity 或 mutation authority 真冲突时才返回 typed conflict。 Stop Agent 是 typed Agent execution control,不是 Queue control,也不是“取消 thread 内一切工作”。指定 Agent 的 Stop 只选择该 target 当前的 exact Agent Client invocation;thread 级 Stop all Agents 则在同一个 per-thread coordinator 临界区快照当前全部 `kind='agent_client'` Active Runs。managed command/job、独立 child execution 与 registered wait 不属于该集合。两种 Stop 都只对 snapshot 中仍 live 的 exact Agent Clients 发 `cancel(invocationId)`,不自行伪造 terminal,也不提前删除 Active Run。Agent Client 必须把 provider/session-specific cancellation 收敛为该 invocation 唯一的 `canceled` callback;callback 再按 §7.4 原位终局 bubble、让 existing owners disposition 该 invocation 的 typed bindings、释放 exact run。Stop 请求之后到 terminal commit 之前的迟到 stream 由 client fence;terminal commit 之后的任何 chunk/final callback 都是 stale no-op。已终局的 stale run 不在 snapshot 中,操作边界之后新启动的 invocation 也不得被旧 Stop 误杀。 批量 Stop 的 coordinator 临界区只负责 snapshot 与逐一发出 exact cancel,不持锁等待 provider 或 Agent Client 返回。发出 cancel 后即可释放 coordinator owner;随后每个 canceled terminal callback 独立完成自己的 durable closure,并把同一个 drain dirty bit 置位 / 调用 `requestDrain(threadId)`,幂等合并后保证最终至少再运行一轮 drain。Stop 不删除、不重排、不 take Queue entry,也不写 `paused`;因此 Queue 会按原 comparator 顺序继续出队。下一条 entry 即使仍以刚停止的 Agent 为 target,也会创建新的 response bubble 与 invocation,这是新的工作,不表示旧 Stop 失败。若未来增加“暂停队列”,它必须拥有独立的 Queue 级显式操作、持久策略与 Resume 语义,并重新证明 startup/drain liveness;不在本文范围内。 -显式操作也保持“一条 multi-target message 是一个 entry”的边界: +显式操作遵循“一个 source entry、多个 pending targets”的边界: -- entry 已有 targets 时,操作对象是重新验证后的完整 exact target set;不能只取走其中一个 target、把其余 target 留成隐式残片; -- targetless entry 选择 Append/Steer 时,由用户选择一个 exact target,并在 take transaction 中固定; -- multi-target Append 只有在每个 target 都有 expected Active Run 时才可 take;multi-target Steer 对每个 target 的当前快照做一次 all-or-none cutover,idle target 不需要取消,busy target 原位 canceled; -- 若用户只想作用于 multi-target entry 的一部分,必须显式取消并以新的 target set 重发;协议层不偷偷拆 entry。 +- 已有 routed targets 时,modal 默认选中仍 pending 的成员;用户可增删目标,并为每个选中成员分别选择 guide 或 interrupt; +- 历史/恢复 targetless entry 的首目标绑定与新增 siblings 必须在同一 `targets[]` mutation 完成,不能留下无目标 ghost entry;新 ordinary no-mention ingress 在初始原子 admission 中已经完成首目标绑定; +- 打开 modal 不写状态;确认时服务端重读 live Queue entry 与 History refs。弹窗打开后已经由未读/其他 dispatch 接管的成员直接跳过;仍 pending 的选择一次原子刷新 `targets[]`,随后每个 target 独立执行,一个 target 的 provider failure 不取消或回滚 sibling; +- 已移出 thread 或不可用的 target 从本次 live selection 排除;原本不在 thread 的新选择成员只在确认时加入。用户取消一个已经投递的成员没有追溯副作用,不得撤销 History ref、响应气泡或 terminal。 Append 与 Steer 的持久 cutover 分别是: @@ -1350,15 +1335,15 @@ async function steerSelected(entryId, selectedTargets) { } ``` -`takeSelectedMaterializeAndAttachInputToRuns` 的原子范围是“验证 exact selected entry、完整 target set 与所有仍为 processing 的 expected invocations + 让 structured source owner 提交 exact invocation binding + 移除 entry + 把 conversation input 写入 History,或验证并复用 message ref + 把被投递消息 refs 推进到 `dispatched` 并指向各 target 的现有 response bubble + 把 input entry/message IDs 持久附到每个 bubble”。Append 不创建新 response bubble;现有 bubbles 已是 outstanding witnesses。只有该事务 winner 才能调用 adapters。某个 target 拒绝时,系统在一个带 invocation + owner-generation 校验的事务中,从该 target 仍为 processing 的 bubble 移除 exact input IDs、让 owner terminalize 对应 binding、把该 delivered-message ref 以独立 failure result 收敛为 `settled`,再更新 Active Run;其他已经接受的 target 不回滚。若事务后进程退出,startup 会按 processing bubble 的 `invocationId` 查询 owner bindings并收敛为 interrupted,不会丢掉一次可能已经发生的 client side effect。 +`takeSelectedMaterializeAndAttachInputToRuns` 的原子范围是“验证 exact selected entry、仍 pending 的 target set 与所有仍为 processing 的 expected invocations + 让 structured source owner 提交 exact invocation binding + 从 entry 删除本次 actual targets(保留其他 siblings)+ 首次投递时把 conversation input 写入 History,或验证并复用既有 message ref + 创建被投递消息的 `dispatched` refs并指向各 target 的现有 response bubble + 把 input entry/message IDs 持久附到每个 bubble”。Guide 不创建新 response bubble;现有 bubbles 已是 outstanding witnesses。只有该事务 winner 才能调用 adapters。某个 target 拒绝时,系统在一个带 invocation + owner-generation 校验的事务中,从该 target 仍为 processing 的 bubble 移除 exact input IDs、让 owner terminalize 对应 binding、把该 delivered-message ref 以独立 failure result 收敛为 `settled`,再更新 Active Run;其他已经接受的 target 不回滚。若事务后进程退出,startup 会按 processing bubble 的 `invocationId` 查询 owner bindings并收敛为 interrupted,不会丢掉一次可能已经发生的 client side effect。 -`takeSelectedMaterializeAndCutoverResponses` 的原子范围是“验证 exact selected entry、完整 target set 与各 target 的 running/idle 快照 + 提交新 entry 的 source owner bindings + 移除 entry + materialize public input,或验证并复用 message ref + target 旧 bubbles 原位 canceled、旧 inputs 的 refs 原位 `settled`、按其 invocation IDs 提交 old binding dispositions + 若 History message 的 exact `producerInvocationId` 仍 live,则 producer bubble、refs 与 bindings 也原位 canceled/settled/dispositioned + 为每个 target 创建新的 processing bubble + 新 input refs 进入 `dispatched` + 激活 principals”。只有该事务 winner 才能更新 Active Runs 并产生 cancel/`dispatch(force=true)` side effects;进程在事务后退出时,新 bubbles 仍由 startup 通过 durable invocation bindings 收敛为 interrupted,旧 providers 的迟到 callbacks 只会命中已终局的旧 invocations。 +`takeSelectedMaterializeAndCutoverResponses` 的原子范围是“重读 exact source entry 与 History dispatch facts、跳过已经投递的 targets、验证仍 pending 的选择与各 target 的 running/idle 快照 + 提交新 source owner bindings + 原子刷新 entry 的剩余 `targets[]` + 首次投递时 materialize public input,或验证并复用 message ref + target 旧 bubbles 原位 canceled、旧 inputs 的 refs 原位 `settled`、按其 invocation IDs 提交 old binding dispositions + 若 History message 的 exact `producerInvocationId` 仍 live,则 producer bubble、refs 与 bindings也原位 canceled/settled/dispositioned + 为每个 actual target 创建新的 processing bubble + 新 input refs 进入 `dispatched` + 激活 principals”。只有该事务 winner 才能更新 Active Runs 并产生 cancel/`dispatch(force=true)` side effects;进程在事务后退出时,新 bubbles 仍由 startup 通过 durable invocation bindings 收敛为 interrupted,旧 providers 的迟到 callbacks 只会命中已终局的旧 invocations。 Append 不会自动发生,也不会把两条 History message 合成一条。它只是把新 exact input 加入选定 target set 的现有 Active Runs;已有 response bubbles 继续保持原位置。 普通 `post_message @B` 只会入队,绝不自动取消发送者。只有用户对该 Agent wake 显式 Steer,且消息携带的 exact `producerInvocationId` 此刻仍 live,才同时取消 source run 与完整 target set 中仍 live 的 old runs;不能按 `from.catId` 猜测并取消发送者的较新 invocation。用户 Steer 一条普通用户/Connector 输入时没有 source run,只取消 targets 中仍 live 的 old runs;thread 内其他非目标 run 继续执行。 -无 target 输入选择 Append/Steer 时,用户在 UI 中选择 exact target;这个选择只在原子 take 中固定,不需要在 Queue Entry 中提前持久化一个稍后绑定的 target override。 +历史/恢复的无 target row 选择 Append/Steer 时,用户在 UI 中选择 exact target;这个选择只在原子 take 中固定,不需要在 Queue Entry 中持久化第二份 target override。新 ordinary no-mention input 已在初始 Queue admission 中持久化 canonical default,不走这条兼容路径。 ### 9.1 client capability 边界 @@ -1367,7 +1352,7 @@ Append 与 Steer 仍走同一个 client dispatch contract,`force` 只是行为 - 支持运行中追加的 client 把 `force=false` exact input 交给 expected existing invocation;Append 不能取消该 run; - 支持 steer 的 client 在 `force=true` 时中断或干扰 exact old invocation,并接受新 input; - client 不支持某个提示时,可以使用自身明确声明的默认投递语义,或返回 typed failure;“忽略提示”绝不能表示静默丢消息; -- capability 只是上述 server action projection 的一个输入,不是 authority。UI 只能把 projection 中可用的操作呈现为可提交;direct/stale 请求仍由服务端按同一 Queue revision、完整 target set 与 exact run/candidate preconditions 返回 typed result,展示层不能自行放宽。 +- capability 只是上述 server action projection 的一个输入,不是 authority。UI 只能把 projection 中可用的操作呈现为可提交;direct/stale 请求仍由服务端按该操作的 exact mutation fence(重排使用 Queue revision,Guide/Steer 使用 live source entry + History dispatch join)、当前 pending target set 与 exact run/candidate preconditions 返回 typed result,展示层不能自行放宽。 ## 10. 失败与重启 @@ -1380,13 +1365,13 @@ Append 与 Steer 仍走同一个 client dispatch contract,`force` 只是行为 | QueueEntry 之外登记的 source owner candidate 在 admission 前明确 mismatch | owner diagnostic;不从 payload/current identity 重建 carrier | owner fence + Queue revision CAS 移除 exact obsolete entry;绝不启动 client | | source owner candidate 在 admission/startup 时证据不足 | 显式 blocked diagnostic;不合成 terminal | exact entry 保持 pending;不 take、不 fallback、不启动 client | | conversation input 无有效 target 且 default 也不可用 | 同一事务 materialize public input + adjacent `DeliveryFailureResult(no_available_target)` | exact entry 被处理,继续下一条 | -| message ref 的显式 target 在 head 时失效 | 保留原 Agent message + `DeliveryFailureResult(invalid_explicit_target)`;source owner 返回 predecessor 时追加含 failure evidence 的 private input | exact entry 被处理;绝不 fallback | +| message ref 的显式 target 在 head 时失效 | 保留原 Agent message + `DeliveryFailureResult(invalid_explicit_target)`;dispatching member 后续从 History 观察该 exact failure | exact entry 被处理;绝不 fallback | | callback principal mint/persist 失败 | 未 accepted;对应 Queue view 显示诊断 | exact entry 留在 Queue,可重试 | | provider launch 失败 | 原 processing bubble → failed | 释放 run,继续下一条 | | provider 执行失败 | 原 processing bubble → failed | 释放 run,继续下一条 | | provider 被取消 | 原 processing bubble → canceled | 释放 run,继续下一条 | | conversation-input Queue entry 被取消 | Queue row 消失或显示已取消;不创建 Chat History message | 删除 entry,不调用 provider | -| message ref entry 被取消 | 已发布的 Agent message 保持不变;同一事务移除尚未 dispatch 的 `assigned` refs | 删除 entry,不调用 provider | +| message ref entry 被取消 | 已发布的 Agent message 保持不变;没有 actual dispatch 的 target 不产生 ref | 删除 entry,不调用 provider | run failure bubble 至少携带 `targetId + invocationId + inputEntryIds + inputMessageIds + typed reason`。pre-admission `DeliveryFailureResult` 至少携带 `sourceEntryId + inputMessageId + requestedTargets + typed reason`。这些是结果自身的因果元数据,不组成 receipt ledger;typed source carrier 与 generation 留在 existing owner 的 invocation binding 中。 @@ -1401,10 +1386,10 @@ run failure bubble 至少携带 `targetId + invocationId + inputEntryIds + input 随后处理每条仍为 `processing` 的 response bubble。它已经 admission,startup 只能以 exact `invocationId` 读取 immutable admission binding 与已有 terminal/verdict: - 若同一 binding 的 delivery terminal / typed verdict 已提交,只补齐同一 terminal 的 projection/apply 并返回 canonical result; -- 若 binding 可验证、terminal 尚未提交且 exact live client 已消失,在一个持久事务中把 bubble 原位终局为 `interrupted / runtime_restart`、把所有公开 exact inputs 的 refs 推进到 `settled`、提交原 generation/predicate 的 owner dispositions,并在 owner 返回 pre 时追加正文含 failure evidence 的普通 `private_input` entries;不重放 provider; +- 若 binding 可验证、terminal 尚未提交且 exact live client 已消失,在一个持久事务中把 bubble 原位终局为 `interrupted / runtime_restart`、把所有公开 exact inputs 的 refs 推进到 `settled`并提交原 generation/predicate 的 owner dispositions;不重放 provider,不隐式生成 predecessor work; - 若 binding 证据不足,bubble 保持 outstanding,隐藏 live/working projection并 fail closed;不得改用 current generation 或合成一个猜测 terminal。 -只有上述已经 admission 且 live client 消失的 work 才成为 `interrupted`。Active Runs 从空内存开始,不重建、不猜 target、不自动重放。必须先收敛所有可判定的旧 processing bubbles,并把 evidence-insufficient work 保持显式 blocked,才能对无阻塞的 durable Queue thread 调用 `requestDrain`;否则空内存会把旧 target 误判为 idle。`interrupted` 是 failure-like delivery terminal:source owner 返回 predecessor 时唤醒它决定改投或上升,owner 查询为空或返回 `null` 时直接闭合;该系统生产者不为 failure-evidence private input 建立新的 predecessor binding,因此不递归交回。三种 crash window 都有明确结果: +只有上述已经 admission 且 live client 消失的 work 才成为 `interrupted`。Active Runs 从空内存开始,不重建、不猜 target、不自动重放。必须先收敛所有可判定的旧 processing bubbles,并把 evidence-insufficient work 保持显式 blocked,才能对无阻塞的 durable Queue thread 调用 `requestDrain`;否则空内存会把旧 target 误判为 idle。`interrupted` 是 failure-like delivery terminal,它在 History 原位闭合;dispatching member 下一次自然 invocation 按 exact source×target 反查到该结果,不为此自动创建 Queue work。三种 crash window 都有明确结果: - admission transaction 前退出:entry 仍在 Queue;startup 重新验证原 decision,verified 才继续,mismatch 终局 exact obsolete entry,insufficient evidence 保持 pending; - Queue take / exact inputs + processing bubble/immutable admission bindings commit 后、provider launch 前退出:public inputs 已 materialize、private input 与 typed owner bindings 已固定;在 binding 可验证时,同一 bubble 变为 interrupted,existing owners 收到 exact terminal; @@ -1414,13 +1399,18 @@ exact callback principal 在 canonical terminal 前不能因静默 TTL 变成 `u ## 11. 用户可见模型 +> **Dispatch 可视化规范归属**:头像三态、response 引用、terminal/retry 与默认信息密度的唯一 +> normative source 是 [`F117 Phase C`](../features/F117-message-delivery-lifecycle.md#phase-c-dispatch-可视化唯一规范normative2026-08-31)。 +> 本节只保留 conversation-delivery 的架构事实与 surface 边界;不得据此另建 receipt dock、placeholder +> 或按消息来源分叉的前端状态机。若历史文字与 F117 Phase C 冲突,以 F117 Phase C 为准。 + 前端只展示七个直接事实: ```text Queue row → 输入已持久化并获得 source identity,等待 dispatch delivered public History message → 正文已进入聊天面板并被交给 actual target delivered-message target avatars → 哪些 actual targets 正在处理、哪些已经结束本跳 -processing response bubble → exact server-side Agent execution live,尚未终局 +response partial bubble → exact execution 已产生可见正文,但尚未终局 terminal response bubble → 已完成、失败、取消或被重启中断 delivery failure result → admission 前已确定无法投递,没有伪造运行 terminal without link → exact execution/result 已终局,但没有可证明的可见回复 lineage @@ -1428,14 +1418,16 @@ terminal without link → exact execution/result 已终局,但没有可 - public conversation input 由 Queue row 直接渲染;它已经有 owner-owned `sourceRecordId`,但还没有 History membership/orderKey; - private input 是 durable Queue entry,但只进入内部 ordered-Queue 投影,不在普通 Queue Panel 或 History 渲染; -- admission 后 Queue row 消失,输入与 processing bubble 一起进入聊天面板; -- 每条实际被 dispatch 的公开 History 消息都在**自身气泡**下按 actual target 的稳定顺序渲染 `dispatchRefs`:`assigned` 仍是尚未 actual dispatch 的 Queue 计划,不用头像宣称正在处理;`dispatched` 在 exact Active Run 可验证时使用现有处理头像的动态效果;`settled` 显示静态头像。multi-target 的头像分别推进,互不覆盖; +- 第一次 actual dispatch 后输入进入聊天面板,同时建立并渲染 canonical processing response lifecycle 行;Queue entry 只在 `targets[]` 为空时消失,尚未投递的 siblings 可以继续留在同一行; +- 每条实际被 dispatch 的公开 History 消息都在**自身气泡**下按 actual target 的稳定顺序投影 F117 Phase C 定义的头像两阶段:`dispatched` 只有在 exact Active Run 可验证时显示动态头像;`settled` 静态保留头像。pending target 没有头像;multi-target 分别推进、互不覆盖;头像不复制 outcome; - 该规则与发送者无关:用户消息、成员 `post_message`、公开外部/系统通知都使用自己的消息气泡;completed response bubble 若把 canonical final 投给下一跳,也由这个既有 bubble 承载下一跳头像。public conversation 的 source record 在 admission 后就是这条 History message,只有其 admission 前的 Queue 投影不承载头像;transport carrier、structured source owner、始终隐藏的 `private_input` 与新建的 processing response bubble 都不是上一条 delivery 的替代锚点; -- 头像提示只给出成员与阶段/结果:`B 正在处理`、`B 处理完成`、`B 处理失败`、`B 已取消`、`B 已中止`;其中“正在处理”明确表示 B 的 server-side Agent execution 已开始且尚未终局,不承诺 provider 已接收正文或 B 已 seen/handled。具体正文与 reason 仍由 `statusMessageId` 指向的 response bubble / delivery failure record 承载; +- 头像只回答“谁正在处理/处理过”:动态表示 exact server-side Agent execution 尚未终局,静态表示本跳已结束;它不承诺普通未读 cursor 或 provider presentation 已推进,也不显示成功/失败/取消 badge。具体 outcome、正文与 reason 只由 `statusMessageId` 指向的 canonical response bubble / delivery failure record 承载; - target/default 或独立 source owner binding 在 admission 前失效时,Queue row 消失;公开输入(若尚未入 History)与 `DeliveryFailureResult` 在一个事务中可见,私有输入只留下 internal diagnostic;不会出现假 member bubble; - 已经发布的独立 Agent `post_message` / owner successor 保持原 History 位置,Queue row 只是它的待 dispatch 引用; -- response bubble 在运行开始时出现,stream 与 final 使用同一 id;completed final 含有效 target 时,Queue row 也只引用这一个 bubble; -- 用户可见的 Queue rows 只按服务端从 exact Queue revision、完整 target set、source-owner verdict、Active Runs 与 client capabilities 导出的 action projection显示 Cancel queued/Append/Steer;row 可见本身不授权操作,不可见 private entry 不接受这些 UI 操作; +- processing 只渲染 admission 已创建的 canonical response lifecycle 行(脉冲头像 + capability tip/最小动效), + 不创建独立 system/provider 空气泡;正文、stream 与 final 使用同一 id 原位升级;completed final 含有效 + target 时,Queue row 也只引用这一个 bubble; +- 用户可见的 Queue rows 只按服务端从 exact mutation fence、完整 target set、source-owner verdict、Active Runs 与 client capabilities 导出的 action projection 显示 Cancel queued/Append/Steer;row 可见本身不授权操作,不可见 private entry 不接受这些 UI 操作; - Stop Agent 可以选择一个 Agent 的 exact Agent Client run,或快照 thread 当前全部 Agent Client runs;它不改变 Queue 顺序与自动 drain,也不取消 managed command/job/wait; - 拖动可见 Queue row 后,UI 只提交 `expectedQueueRevision + orderedVisibleEntryIds`,即同一 snapshot 中完整的 visible row 顺序;API 校验 revision、完整 visible set 与 eligibility 后原子写入 `position=0..n-1`。positioned visible rows 位于未定位 hidden rows 前,hidden rows 只按自身 priority/FIFO 排序且不被 UI 直接寻址;任何并发变化都整批 typed conflict; - 不展示用户已读/未读、receipt processing、attempt aggregate、thread-wide paused 或无对象 Continue。 @@ -1443,7 +1435,15 @@ terminal without link → exact execution/result 已终局,但没有可 ### 11.1 用户与 Agent 共用同一工作状态投影 -“某成员正在处理这条消息”不能由 Queue row、旧对话文本或一个孤立字段单独猜出。用户 UI 与 Agent situation/context summary 必须读取同一份 domain snapshot:被投递 History 消息对 actual target 的 ref 为 `dispatched`、关联 bubble 为 `processing`,并且 active-execution owner 返回匹配的 exact Agent Client run 时,才在**该被投递消息气泡下**投影动态头像/“正在处理”;这里的 processing 是粗粒度 server-execution 状态,不是 provider receipt、body exposure、seen 或 handled 的同义词。任一 canonical read 不完整就 fail closed,不用 ref、carrier 或 source owner 单独补齐。ref 为 `settled` 时,从关联 canonical record 读取 completed/failed/canceled/interrupted,并在同一消息气泡下投影静态头像与对应提示。provider launch 或 execution 失败必须让关联 response bubble 原位 `failed` 并保留已有 partial body/typed reason,不能让被投递消息头像永久 spinning,也不需要向普通用户再拆“启动中/连接中”。`assigned` 只说明 Queue 已有定向计划,`dispatched` 只说明 admission 已发生;两者都不表示成员已经看到 exact body。Agent situation packet 的 `presented` receipt 只在 provider adapter 确认收到对应 projection 后写入,也不反向推进 `dispatchRefs` 或 structured responsibility。 +“某成员正在处理这条消息”不能由 Queue row、旧对话文本或一个孤立字段单独猜出。用户 UI 与 Agent situation/context summary 必须读取同一份 domain snapshot:被投递 History 消息对 actual target 的 ref 为 `dispatched`、关联 response 为 `processing`,并且 active-execution owner 返回匹配的 exact Agent Client run 时,才在**该被投递消息气泡下**投影动态头像;这里的 processing 是粗粒度 server-execution 状态,不是 provider receipt 或普通未读 cursor 的同义词。任一 canonical read 不完整就 fail closed,不用 ref、carrier 或 source owner 单独补齐。ref 为 `settled` 时,从关联 canonical record 读取 completed/failed/canceled/interrupted,并按 F117 Phase C 静态保留头像;outcome 只在 terminal response/轨迹提示表达。provider launch 或 execution 失败必须让关联 response bubble 原位 `failed` 并保留已有 partial body/typed reason,不能让被投递消息头像永久 spinning,也不需要向普通用户再拆“启动中/连接中”。pending target 只存在于 Queue `targets[]`,不产生头像或 ref;`dispatched` 表示 actual delivery 已发生。Agent situation packet 的 `presented` receipt 只在 provider adapter 确认收到对应 projection 后写入,也不反向推进 `dispatchRefs` 或 structured responsibility。 + +同一条“用户是否需要看到”的边界也适用于系统提示:routing fail-open、未分类 provider warning、route-syntax、verdict-without-pass 与 void-hold 都是内部诊断或 Agent correction,只进入 telemetry、live transient status、下一 turn prompt 或私有 feedback;它们不创建公开 History 气泡。只有 canonical 业务结果、确实改变投递的 rejected receipt,或明确标记 `presentation=user_action_required` 的可操作提示才进入用户对话。这个判定按 typed presentation contract 完成,不按中文文案做字符串屏蔽。 + +2026-09-15 的完整 warning producer census 见 F117「回复与引用」:ACP 与 Antigravity 的三处自动重试信号只走 +`transient_status`;Codex item error 与 OpenCode token-usage 缺失保持未分类内部诊断;当前没有生产 +`user_action_required` 的真实 producer。oversized native rollover 是 response-owned `session_rollover_lifecycle`, +透明续跑不另提示,失败由 canonical response terminal 表达。新增 warning 若需要进入对话,必须由 producer 明确 +选择 `user_action_required` 并说明用户可执行动作,不能让 renderer 从 message 文案猜测。 API 启动时必须先完成 §10.2 的 owner-fence validation、canonical terminal apply、interrupted/source-owner disposition/ref 收敛,才能把无阻塞 thread 标为 ready、提供新的 Agent context 或接受新的 dispatch。重启后 Active Runs 为空,旧 processing bubbles 只有在 exact binding 可验证且 live client 消失时才原位 interrupted;已提交 terminal 只补同一 apply,证据不足则保留 outstanding/blocked 并隐藏“正在工作”。`hold_ball` 或已注册外部等待可以在没有任何 Active Run 时合法存在;它是独立的结构化等待事实,不得伪装成成员正在工作。若其上游协议产生 `private_input` 来源消息,主生命周期只把该 envelope 封装进同一 priority Queue,不需要新增持久责任账本。 @@ -1452,59 +1452,49 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi | 责任 | 主要代码位置 | 目标改造 | |---|---|---| | QueueEntry 封装 | message/Connector/scheduled callback routes | 生产者直接给出 `kind + payload + from + targets + priority`;入口只校验合法组合。priority 仅为 `urgent / normal`,缺省 normal;不从 kind/source/payload 推断,也不调用 existing owner 或 Agent Client | -| 输入持久化 / owner timeline | `packages/api/src/routes/messages.ts` + queued-message custody owner | public conversation 在 enqueue commit 时持久化稳定 `sourceRecordId + entryId + payload/custody`;不提前获得 History membership/orderKey;commit 后 requestDrain。admission 复用该 identity materialize 被投递 History 消息,并按 actual targets 直接建立 `dispatched` refs;source record/carrier 不形成另一条 UI anchor,也不制造中间 `assigned` 状态 | +| 输入持久化 / History materialization | `packages/api/src/routes/messages.ts` + MessageStore | public conversation 在 enqueue commit 时由 Queue 持久化稳定 `sourceRecordId + entryId + inline payload`;不提前获得 History membership/orderKey;commit 后 requestDrain。第一次 actual dispatch 复用该 identity materialize 被投递 History 消息,并按 actual targets 直接建立 `dispatched` refs;source record/carrier 不形成另一条 UI anchor | | Queue | `packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts` | durable priority QueueEntry;payload 只保留 inline/message_ref 两种承载,`from` 使用判别结构,顶层 `kind` 只有 conversation-input/message-wake/private-input;唯一 comparator 为 `position → urgent/normal → enqueuedAt → id`。显式 reorder 以 `expectedQueueRevision + orderedVisibleEntryIds` 原子刷新完整 visible positions;kind/source/system category 不授予 hidden rank | | Admission Coordinator | `packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts` | 唯一 requestDrain、dirty-bit single owner、commit 后严格 comparator head、启动恢复;消费 Queue mutation、run terminal 与 external source-owner eligibility 的既有 post-commit signal,不持有 timer 或另一套排序策略,private input 单独 admission | -| Agent 路由 | `packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts` | enqueue 时解析 exact targets;targetless fallback 留到 head execution | -| body exposure / handled | `queued-message-custody.ts` + `QueuedMessageCustodyCoordinator.ts` | 保留 exact target/invocation body exposures、seen 与 handled/target outcome;provider prompt exposure 后才写 seen,terminal predicate 后才写 handled;`dispatchRefs` 与 bubble 不得替代或反向推进这些 facts | -| 未读 / provider presentation | `route-helpers.ts` + context presentation mapper/ledger | 复用 delivery cursor、visibility/window 与 projected/exposed ids;processing barrier + exact input。同一 public user turn 内保持 same-route output isolation;后续可见 user turn 前的输出恢复普通投影,服务端 causal direct reply 以 exact source author 校验,不把所有 replyTo 变成 target。只有 provider adapter 已接收 projection 后才写 content-free presented receipt;不从 render、admission 或 `dispatchRefs` 提前消费。窗口审计最多记录 16 个 message refs + bounded reasons,不写正文 | -| admission / 响应发布 | `packages/api/src/domains/cats/services/agents/routing/route-serial.ts` | Agent Client effect 前让 existing owners CAS 提交 immutable exact admission binding(owner kind + lease/generation + frozen predicate/HEAD + principal/tenant/route);同一 cutover take Queue、materialize/reuse delivered message、固定 response bubble、激活 callback principal,并把 delivered-message exact target ref 创建/推进为 `dispatched`。stream snapshot 原位更新;pre-admission mismatch 写 exact result/diagnostic,insufficient evidence 保持 pending;terminal 按 invocation 原子提交 typed owner dispositions、delivered-message refs、completed-final wake/assigned refs 与 predecessor return | +| Agent 路由 | `packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts` | enqueue 时解析 explicit targets 或 canonical no-mention fallback;head execution 用同一 resolver 重验,历史 targetless row 才在此恢复 | +| 未读 / provider presentation | `route-helpers.ts` + context presentation mapper/ledger | 普通未读 cursor 只描述 History 读取边界;provider presentation receipt 只描述 exact prompt projection 已被 adapter 接收。二者都不写回 Queue,不创建 Queue seen/handled/attempt 状态,也不替代 actual dispatch 或 response terminal | +| context visibility | `route-helpers.ts` + context presentation mapper | 复用 delivery cursor、visibility/window 与 projected ids;processing barrier + exact input。同一 public user turn 内保持 same-route output isolation;后续可见 user turn 前的输出恢复普通投影,服务端 causal direct reply 以 exact source author 校验,不把所有 replyTo 变成 target。窗口审计最多记录 16 个 message refs + bounded reasons,不写正文 | +| admission / 响应发布 | `packages/api/src/domains/cats/services/agents/routing/route-serial.ts` | Agent Client effect 前让 existing owners CAS 提交 immutable exact admission binding(owner kind + lease/generation + frozen predicate/HEAD + principal/tenant/route);同一 cutover 从 Queue `targets[]` 删除 actual targets、materialize/reuse delivered message、固定 response bubble、激活 callback principal,并创建 delivered-message exact target 的 `dispatched` ref。stream snapshot 原位更新;pre-admission mismatch 写 exact result/diagnostic,insufficient evidence 保持 pending;terminal 按 invocation 原子提交 typed owner dispositions、delivered-message refs、completed-final 的显式 target wake,以及 failed A2A 的 exact idempotent caller control wake | | History 顺序 / dispatch-ref projection | `redis-message-append.ts` + `redis-message-frontier-append.ts` + `RedisMessageStore.ts` + startup reconciler | 普通 append 与 freshness frontier append 在各自同一 Redis 原子边界为可发布消息分配唯一 visibility position;raw timeline 不能先于或替代 visibility membership。queued user work 延迟到 admission。admission 复用 sourceRecordId 并为 delivered input/bubble 连续分配 orderKey;更新不重排;每个 actual target 的 ref 只允许单调 CAS。reconciler 从 Queue membership、delivered message/bubble input binding 与 terminal result 重建;唯一映射可修,缺失/多义 fail closed 并报警 | | Active execution | `active-execution-service.ts` + InvocationRecord/Tracker/TurnExecution/managed-command owners | domain service 组合完整性标记与 typed execution kinds;Active Run 只保存 Agent Client exact run + responseMessageId + input IDs,不复制 source carrier。Stop Agent 只筛选/cancel Agent Client kind;owner read 不完整时不宣称 idle/working | -| Agent wake | `packages/api/src/routes/callback-a2a-trigger.ts` + terminal path | 每条 `post_message` 独立写 History;Agent Client completed final 复用 response bubble;两者解析出有效 target 时原子建立唯一 message-ref entry 与每目标 `assigned` ref;structured dispatch owner 用 message identity 建立 invocation binding | +| Agent wake | `packages/api/src/routes/callback-a2a-trigger.ts` + terminal path | 每条 `post_message` 独立写 History;Agent Client completed final 复用 response bubble;两者解析出有效 target 时原子建立唯一 message-ref entry。实际 target dispatch 时才创建 History ref;structured dispatch owner 用 message identity 建立 invocation binding | | Structured source owners | existing `TurnExecution`、action-successor、event-wait stores/services | 保留各自 typed carrier、lease 与 predicate;preflight 产生 candidate,admission CAS 提交 immutable exact entry/target/invocation fence。unadmitted candidate 的 terminal/replacement/evidence mutation 以既有 durable post-commit event 唤醒 Queue coordinator,由后者重读 typed fact 并 exact CAS;admitted work 的 terminal/replay/startup 只按 invocation 找回原 binding,并让 owner 以同一 generation/predicate 提交或返回既有 disposition/predecessor;callback principal 与 carrier 都不能替代 lease authority;owner namespace 互不覆盖 | -| Queue / execution 控制 | `packages/api/src/routes/queue.ts` + existing invocation cancellation route | 服务端从 exact Queue revision、完整 target set、source-owner verdict、Active Runs 与 client capabilities 导出并消费同一 action projection。Append/Steer/Cancel queued 与 Stop running 进入同一个 per-thread coordinator;Queue action 对完整 entry all-or-none,先 exact take 后 Agent Client effect,并与对应 ref 创建、推进、删除保持原子;取消尚未 dispatch 的 Agent wake 时删除其 `assigned` refs;Stop 只快照并 cancel exact Agent Clients,不直接写 terminal / 释放 run | +| Queue / execution 控制 | `packages/api/src/routes/queue.ts` + existing invocation cancellation route | 服务端逐成员投影静态 guide capability,并从单一 source entry 的 pending `targets[]`、History dispatch refs、source-owner verdict 与 Active Runs 导出/消费当前动作。Steer 确认时重读 live join,跳过已投递成员并原子刷新剩余 targets,随后每个 actual target 独立 guide/interrupt;Cancel queued 与 Stop running 仍进入同一个 per-thread coordinator。取消尚未 dispatch 的 Agent wake 只删除 pending target/entry;Stop 只快照并 cancel exact Agent Clients,不直接写 terminal / 释放 run | | Agent Client / provider adapter | `packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts` 及 adapters | adapter 创建 exact Agent Client;client 内部吸收 session/compact/handoff/re-trigger/cancel 差异,对外只暴露 accepted/failure、同 invocation 的 stream callback、唯一 completed/failed/canceled terminal callback 与 exact cancel;Stop cancel 必须停止后续 stream 并正常产出 canceled terminal | -| UI | `packages/web/src/components/QueuePanel.tsx` 及消息气泡 | Queue row 直接渲染 inline payload 或 message-ref preview,并只把服务端 exact action projection 中的操作呈现为可提交;typed stale/conflict 使旧确认失效并刷新 canonical snapshot,不自动重复。聊天面板只渲染 History;stream/final 同一气泡;failed/canceled 保留 partial body,并用气泡内 status footer 呈现 structured reason,不追加第二条 chat。每条实际被 dispatch 的公开 History 消息都在自身气泡下按 actual-target `dispatchRefs` 投影头像:`assigned` 不宣称 processing,`dispatched + exact live run` 复用现有处理头像动效,`settled` 静态显示并只提示处理结果;不得把 carrier/source owner/另造状态消息作为锚点。exact terminal 缺少可证明 visible lineage 时显示 terminal-without-link diagnostic,不补造 bubble/ref | +| UI | `packages/web/src/components/QueuePanel.tsx` 及消息气泡 | Queue row 直接渲染 inline payload 或 message-ref preview,并只把服务端 exact action projection 中的操作呈现为可提交;typed stale/conflict 使旧确认失效并刷新 canonical snapshot,不自动重复。聊天面板只渲染 History;dispatch presentation 完整遵循 F117 Phase C:canonical processing response 行承载头像/tips 并由 stream/final 原位升级,不保留独立 receipt dock、system/provider 空气泡、消息来源分叉或 legacy render fallback。failed/canceled 保留 partial body 与 structured reason,不追加第二条 chat。exact terminal 缺少可证明 visible lineage 时显示 terminal-without-link diagnostic,不补造 bubble/ref | -现有 Queue custody 的 body exposure/seen/handled/attempt、ActionSuccessor/AwaitState、TurnExecution、typed verdict、active-execution composition 与 presentation receipt 都是本 RFC 明确保留的 external owner,不能作为“重复 ledger”删除。Queue / History / Active Run 是 conversation-delivery kernel 的 Occam boundary;不得再增加通用 WorkUnit/Receipt/Settlement ledger。需要 supersede 的是它们在 UI/调度上的替代写入、category-derived Queue rank、Queue pause/fallback;实施时按 §3.4 disposition 逐项迁移,不通过兼容层让两个 owner 同时裁决同一 fact。 +普通未读 cursor、ActionSuccessor/AwaitState、TurnExecution、typed verdict、active-execution composition 与 presentation receipt 都是本 RFC 明确保留的 external owner,不能作为“重复 ledger”删除。Queue / History / Active Run 是 conversation-delivery kernel 的 Occam boundary;Queue 本身不再另存 exposure/seen/handled/attempt/terminal。不得再增加通用 WorkUnit/Receipt/Settlement ledger,也不得通过兼容层让两个 owner 同时裁决同一 fact。 ## 13. 实施顺序 -1. 先用行为测试锁定 §3.5 五条 laws、QueueEntry 封装、`position → urgent/normal → FIFO` 单一 comparator、完整 visible-order batch reorder 与 stale-revision fail-closed、严格 head drain、targetless fallback、typed source disposition、body exposure/handled 隔离、`dispatchRefs` 可重建性、drain dirty bit 与响应气泡终局; +1. 先用行为测试锁定 §3.5 五条 laws、QueueEntry 封装、`position → urgent/normal → FIFO` 单一 comparator、完整 visible-order batch reorder 与 stale-revision fail-closed、严格 head drain、targetless fallback、typed source disposition、Queue pending-only 边界、`dispatchRefs` 可重建性、drain dirty bit 与响应气泡终局; 2. 让所有 message/Connector/plugin/scheduler/system 生产者提交 `kind + payload + from + targets + priority + ownerAuthProvenance`;priority 缺省 normal,urgent 必须显式给出;public conversation 同时获得稳定 sourceRecordId。入口只校验判别组合;structured owner 在签发 private envelope 前自行决定是否需要 Agent。CLI completed final 只在 terminal transaction 内创建 message-ref wake; -3. 把 QueueEntry 的 payload 收敛为 inline/message_ref 两种承载,把 sender 收敛为 namespaced `MessageFrom`,并把顶层 entry kind 收敛为 conversation-input/message-wake/private-input;私有协议差异只体现在 inline payload 正文与 Queue 外 owner binding;实现默认 normal、显式 urgent、同级 FIFO、完整 visible-order batch reorder 与 public conversation 兼容前缀批次; -4. 调整 public conversation 入口为 Queue-first;独立 Agent `post_message` / completed final 直接复用其 History message,并与 message-ref wake 原子写入每目标 `assigned` ref;targetless fallback 只留给 public head; +3. 把 QueueEntry 的 payload 收敛为 inline/message_ref 两种承载,把 sender 收敛为 namespaced `MessageFrom`,并把顶层 entry kind 收敛为 conversation-input/message-wake/private-input;私有协议差异只体现在 inline payload 正文与 Queue 外 owner binding;实现默认 normal、显式 urgent、同级 FIFO、完整 visible-order batch reorder 与逐 source try-drain; +4. 调整 public conversation 入口为 Queue-first;普通无 `@` 输入在原子 enqueue 前绑定服务端 canonical fallback、但不伪造 source mention;独立 Agent `post_message` / completed final 直接复用其 History message并原子写入唯一 message-ref wake;actual dispatch 时才为目标创建 History ref;历史 targetless fallback 只留给 public head recovery; 5. 建立 pre-admission terminal transaction 与 admission transaction:前者 exact take + History failure result + 已存在 ref 的 `settled`,后者 existing-owner invocation binding + exact take + materialize/reuse 被投递 History 消息 + processing bubble + callback principal + actual-target ref 的 `dispatched`; 6. 把 Active Run 收敛为 Agent Client exact invocation、responseMessageId 与三组 exact input IDs,并作为 domain active-execution service 的一个 typed input;managed command/job/child execution 继续由各自 owner 参与完整投影;typed source carrier 只留在 owner 的 invocation binding; 7. 把所有调度触发收敛到 `requestDrain`,包括 structured source owner 对 unadmitted candidate 的 exact eligibility/evidence post-commit signal;由 coordinator 重读 typed owner fact 并竞争 Queue CAS,删除 timer 型正确性兜底; -8. 接通现有未读 cursor、Queue custody exact body exposure 与 provider presentation receipt:processing barrier + exact input;Agent situation summary 从 canonical Queue/History/execution snapshot 派生,provider adapter 确认接收后才记 presented; -9. 让 Agent Client 封装成员内部 session/compact/handoff/re-trigger/cancel,并证明跨这些边界仍保持同一 invocation/callback principal;把现有 DraftStore snapshot 与正式 MessageStore append 收敛为同一 response bubble,completed canonical final 解析 target 后把这个既有 bubble 作为下一跳被投递消息,原子创建同 bubble message ref + `assigned` refs;failed/canceled/interrupted 保留 partial body + structured reason,同时按 invocation 提交 typed owner dispositions 与上一跳 delivered-message ref `settled`; -10. 接入服务端 exact full-entry action projection 与显式 Append/Steer/Cancel queued/Stop Agent;验证 UI/API 读取并重验同一 Queue revision + 完整 target set + owner/run/capability preconditions,Queue action 先整条 take 后 side effect、对应 refs 同事务迁移,并验证 Stop 只 cancel typed exact Agent Client、绝不命中 managed command/job,由 client 正常回调 canceled terminal,不暂停也不删除 Queue; -11. 保留 Queue custody 的 exposure/seen/handled/attempt、structured owner、active-execution 与 presentation ledger,以及 Queue 的 `urgent / normal` 两级 priority;删除的只是 category-derived rank、Queue pause、ref 反向裁决和多层 timer/fallback。新增 dispatch-ref reconciler 只修 derived projection,不能改写 canonical owners; -12. 在隔离环境跑完整验收矩阵后执行 quiescent live cutover:先把 content-free `lifecycleWriterEpoch` CAS guard dark-land 到所有 legacy/new writer transaction;语义激活前停止 legacy 新 admission、收敛或持久化在途事务、撤出未受 guard 保护的 binary,再由独占迁移 lease 完成 `legacy → migrating` writer fence、reconciliation 与 `migrating → live` 提交;不得让两套 lifecycle 并行裁决 live work。 +8. 接通现有未读 cursor 与 provider presentation receipt:processing barrier + exact input;Agent situation summary 从 canonical Queue/History/execution snapshot 派生,provider adapter 确认接收后才记 presented;这些事实不写回 Queue; +9. 让 Agent Client 封装成员内部 session/compact/handoff/re-trigger/cancel,并证明跨这些边界仍保持同一 invocation/callback principal;把现有 DraftStore snapshot 与正式 MessageStore append 收敛为同一 response bubble,completed canonical final 解析 target 后把这个既有 bubble 作为下一跳被投递消息并原子创建 message-ref wake;failed/canceled/interrupted 保留 partial body + structured reason,同时按 invocation 提交 typed owner dispositions 与上一跳 delivered-message ref `settled`; +10. 接入服务端 exact source-entry action projection 与显式 Guide/Steer/Cancel queued/Stop Agent;验证 UI/API 读取并重验同一 exact mutation fence + live pending target set + History dispatched set + owner/run/capability preconditions(重排使用 Queue revision;Steer 在确认时重读 live join并跳过已投递 target),Queue action 先原子删除 exact pending targets 后产生 side effect,并验证 Stop 只 cancel typed exact Agent Client、绝不命中 managed command/job,由 client 正常回调 canceled terminal,不暂停也不删除 Queue; +11. 保留普通未读 cursor、structured owner、active-execution 与 presentation ledger,以及 Queue 的 `urgent / normal` 两级 priority;删除 Queue custody exposure/seen/handled/attempt/terminal、category-derived rank、Queue pause、ref 反向裁决和多层 timer/fallback。新增 dispatch-ref reconciler 只修 derived projection,不能改写 canonical owners; +12. 在隔离环境跑完整验收矩阵后激活 schema v2:先让所有 Queue mutation 携带 schema/version CAS,再幂等完成 v1→v2 source grouping 与 canonical-owner reconciliation;旧 writer 不能在 v2 key 上恢复 per-target/terminal Queue projection,不得让两套 lifecycle 并行裁决 live work。 ### 13.1 Dark landing 与单次语义激活 -实现按三个可独立验证、但不能各自激活 live semantics 的切片推进: - -1. **schema/reducer + shadow comparison**:先落新 binding/result/recovery reducer 与新旧结果对比,只读观测,不启用第二个 writer; -2. **admission cutover**:producer、exact Queue take、immutable owner binding、callback-principal activation、History materialization 与 processing bubble 是一个不可拆的 durable cutover; -3. **terminal/recovery cutover**:bubble delivery terminal、typed owner disposition、successor/predecessor enqueue、same-generation replay、append-won/apply-crashed compensation 与 startup freshness 是另一个不可拆的 durable cutover。 - -三个切片可以分别 dark-land,但只有 admission 与 terminal/recovery 都具备并通过 A1–A90 后,live semantics 才一次激活。dark landing 可以沿用 rolling deployment;**live activation 不能是 rolling writer handoff**。激活前置版本必须先把 content-free、monotonic `lifecycleWriterEpoch` CAS guard 部署到每一条 legacy/new admission、terminal 与 migration write transaction;epoch 只裁决哪代 writer 可提交,不复制 Queue、execution 或责任事实,因此不是第四个 lifecycle ledger。任何没有该 guard 的 binary 都不具备 cutover 资格,必须先从流量与进程集合撤出。随后执行 quiescent writer fence: - -1. legacy fleet 先停止接受新的 root ingress/admission,但继续让已接收事务提交 canonical terminal,或留下足以让已 dark-land terminal/recovery reducer 接管 exact callback 的持久 outstanding witness;新 ingress 在 activation 完成前保持 blocked,不绕到新 writer; -2. 等所有已开启的 legacy lifecycle transaction 退出,并撤出所有未受 epoch guard 保护的 binary;无法证明任一 transaction/binary 已退出时,cutover fail closed; -3. operator/cutover controller 在 canonical store 取得独占 migration lease,以 expected epoch 原子提交 `legacy → migrating`。从这一刻起,所有仍携带 legacy epoch 的 admission/terminal/migration commit 都 typed conflict,不能在 clean scan 后补写; -4. controller 在同一 lease 与 `migrating` epoch 下执行下述 invariant reconciliation;只有 clean scan 成功才 CAS 提交 `migrating → live`。失败或 crash 保持 `migrating + ingress blocked`,只能幂等续做同一 reconciliation,不能恢复 legacy writer 或双写; -5. 只有要求并验证 `live` epoch 的新 fleet 才重新开放 ingress。受 guard 保护但版本陈旧的 binary 即使被误启动,也因 expected epoch 不匹配拒绝 lifecycle write。 - -因此 scan 与 activation 之间不存在仍有提交权限的 legacy transaction;正确性来自 durable epoch CAS,不依赖进程观察、timer 或周期重扫。激活后 legacy resolver 只能保留为 diagnostic/shadow comparison;它不得继续为 live work 重选 current generation、补写 authority 或充当第二 writer。 +Queue schema v2 的迁移目标只有一个:把旧的 per-target Queue records 收敛为“一条 source entry + pending `targets[]`”,同时把已经不属于 Queue 的运行/终局事实留给其 canonical owner。 -迁移不得删除现有 Chat History、Queue custody 或 structured owner truth。旧 Queue 记录若绑定的是已公开 Agent/terminal message,转换为 `kind='message_wake' + message_ref`,并按其 exact targets 在同一迁移事务补 `assigned` refs。现行 user/Connector queued record 虽已写入 MessageStore 并对 owner timeline 可见,但只要仍是 `deliveryStatus='queued'`、没有 cat body exposure、没有外部 message permalink/reference,迁移就保留原 message identity 为 `sourceRecordId`,把正文转入 `kind='conversation_input' + inline`,并撤销 admission 前的 History membership/orderKey;这不是删除用户数据,而是把同一 owner-owned record 从 owner-only timeline 投影迁到 Queue Panel。若 queued record 已被 cat exposure、外部引用或其他公开事实观察到,则不能静默改写历史:保留原 History identity、转为 message-ref wake,或 fail closed 留下迁移诊断。对具有可靠 inputMessageIds/responseMessageId 的旧 live work,可以补指向该 processing bubble 的 `dispatched` refs;无法证明 source、target 与 canonical result 唯一对应时只保留诊断并隐藏 working claim,不伪造 ref。旧裸 sender id 必须结合原字段/transport 迁移进明确 `MessageFrom`;不能确认命名空间或认证强度的记录保留诊断,不猜成 Agent/strict。不能可靠转换的运行投影保留 owner truth 与诊断,但不恢复成 Active Run。 +1. 以 `sourceRecordId`(旧数据缺失时使用可证明的 source message identity)分组;同一 source 的 queued/短暂 claimed records 合并为一个 v2 entry,targets 去重并保持稳定顺序; +2. 新 entry 继承该 source 最早的 `enqueuedAt` 与明确的手工 `position`;payload、`from`、kind 或 source identity 冲突时 fail closed,不猜测合并; +3. 旧 `processing/terminal/seen/handled/failed` Queue projection不导入 v2 Queue。若 History response、dispatch ref 或 structured owner 已有 canonical fact,保留那些事实并移除 Queue 选择面; +4. 已公开 Agent/completed-response source 转换为 `message_wake + message_ref`;仍未实际投递的 user/Connector source 保留原 identity 为 `sourceRecordId` 与 inline payload,第一次 actual dispatch 时再取得 History membership; +5. 每个 v2 write 都携带 schema/version CAS。迁移幂等重放只能得到同一 entry/targets/order;旧 writer 不能在 v2 key 上重新写回 per-target row 或 Queue terminal receipt。 -`lifecycleWriterEpoch` 已从 `legacy` CAS 为 `migrating`、且独占 migration lease 生效后,live cutover 才能做 invariant reconciliation:只把“全部 target 都仍是纯 pre-admission pending,且没有任何 admitted/terminal delivery fact”的 legacy entry 导入新 Queue。若同一 target 同时有 Queue membership 与 processing/failed/completed/canceled/interrupted result,保留 canonical History/owner terminal 并删除其 selectable Queue projection;若一个 legacy multi-target row 混有 terminal 与 pending sibling,或证据不能唯一证明整条 entry 仍可执行,则整条 row 进入 non-selectable migration diagnostic,不自动拆成 per-target Queue 残片。后续 Retry/重发(如产品提供)创建新的 entry/attempt,绝不复活旧 row。cutover controller 只有在同一 lease 与 expected `migrating` epoch 下证明不存在 selectable `terminal + queued` row,才能 CAS 提交 `live` epoch;clean scan 不能脱离 writer fence 单独充当激活证据。 +迁移不得删除既有 Chat History 或 structured owner truth。无法唯一证明 source identity、payload、target 集合或公开 History lineage 的旧记录保留诊断并 fail closed;Retry/重发只能创建新的 entry/attempt,不能复活旧 terminal work。 ## 14. 已知异常如何闭合 @@ -1512,9 +1502,9 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi |---|---|---| | Queue 有消息但没有 Agent 执行 | 分散 trigger 丢失、source-owner evidence 改变未唤醒 drain,或在 busy 检查后无再触发 | 五类事件统一 requestDrain;run release 与 unadmitted owner eligibility/evidence post-commit signal 都是 mandatory trigger;dirty bit 封闭退出窗口 | | 正常消息与 Steer 竞争失败 | 正常推进和用户控制使用不同调度入口 | 全部 Queue mutation 进入同一 per-thread coordinator,只有 exact take winner 产生 side effect | -| Queue row 可见但 UI 展示了当前不可提交的 Append/Steer | 把可见性或 client capability 误当成 command authority | 服务端从 exact revision、完整 target set、owner verdict、Active Runs 与 capability 导出并消费同一 action projection;stale conflict 使旧确认失效并刷新,不自动重复 | -| legacy 数据同时出现 terminal/failed 与 Queue pending,或 multi-target row 只有部分 target 仍 pending | 旧补丁把 admission、attempt outcome 与 Queue membership 并成可部分恢复的状态机 | cutover 前 invariant reconciliation 保留 canonical terminal;整条异常 row 不可选,不拆 per-target 残片;明确的 Retry/重发只能创建新 entry/attempt | -| reconciliation clean scan 后、live activation 前 legacy writer 又写入 selectable terminal-plus-queued row | 把无坏行快照误当成 writer fence,允许 rolling deployment 中旧实例继续提交 | writer transaction 预先 dark-land `lifecycleWriterEpoch` CAS;quiescent cutover 先阻断新 legacy admission、退出未受 guard 保护的 binary/旧 transaction,再在独占 migration lease 内提交 `legacy → migrating`、reconcile、`migrating → live`。旧 epoch commit 一律 conflict | +| Queue row 可见但 UI 展示了当前不可提交的 Guide/Steer | 把可见性或 client capability 误当成 command authority | 服务端从单一 source entry、History refs、owner verdict、Active Runs 与静态 capability 导出并消费同一 action projection;确认时重读 live join,已投递 target 直接跳过 | +| legacy 数据同时出现 terminal/failed 与 Queue pending,或旧 per-target rows 只剩部分 target pending | 旧补丁把 admission、attempt outcome 与 Queue membership 并成可部分恢复的状态机 | schema v2 migration 按 source 合并仍 pending 的 targets;canonical terminal 不导入 Queue;歧义数据不可选并留诊断;Retry/重发只能创建新 entry/attempt | +| schema v2 已启用但旧 writer 又写入 per-target/terminal Queue record | 旧实例未受 schema/version fence 约束 | 所有 v2 mutation 使用 schema/version CAS;旧形状无法写入 v2 key,迁移重放只得到同一 source entry | | Queue row 被误当作聊天消息 | source identity、Queue visibility 与 History membership 被混成一个事实 | enqueue 持久化 sourceRecordId/Queue payload;conversation input 只在 admission 时获得 History membership/orderKey,并复用同一 identity | | `private_input` 被当作 targetless public message | QueueEntry 封装时丢失 entry kind | 原样保留 `private_input + exact target` 并写同一 priority Queue;不 materialize History message,绝不 member fallback | | A2A client 已成功但 source 无法 disposition | structured source 没有在 admission 绑定 exact invocation,terminal 又依赖可被无关 hold 改写的 thread holder | action/wait/dispatch owner 保留自己的 typed carrier,admission CAS 建立 exact invocation binding;terminal 以 invocation 找回同一 generation 并让该 owner 按 predicate 提交 disposition,owner namespace 隔离 | @@ -1526,15 +1516,15 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi | 重启或异常终局后被投递消息下的目标头像永远闪烁 | terminal/startup 只收敛 response bubble,没有同事务收敛 delivered-message ref | terminal 与 startup 都在 thread ready 前把对应 `dispatched` ref 推进为 `settled`,再由 linked canonical result 在同一被投递消息下投影终态 | | 前端 A→B,Agent 却读成 B→A | response 在 final append 时才分配位置 | input/bubble 在 admission 时固定 orderKey;processing 是 cursor barrier;final 原位更新 | | client 失败但没有回复 | final 才创建 message,失败路径没有共同出口 | admission 先创建 processing bubble,所有 terminal 原位更新 | -| completed final 含 `@` 后重复出现两条 Agent message,或目标没有被唤起 | 把 Agent Client final 当成独立 `post_message`,或把 History terminal 与 Queue enqueue 分成两次提交 | final 只原位终局既有 response bubble;有效 target 的 message ref、每目标 `assigned` ref 与 terminal 原子提交并引用同一 `responseMessageId` | +| completed final 含 `@` 后重复出现两条 Agent message,或目标没有被唤起 | 把 Agent Client final 当成独立 `post_message`,或把 History terminal 与 Queue enqueue 分成两次提交 | final 只原位终局既有 response bubble;有效 targets 与唯一 message-ref Queue entry 随 terminal 原子提交并引用同一 `responseMessageId`;actual dispatch 时才建 ref | | 成员 compact / session handoff 后出现第二个 run、第二个气泡或 Queue wake | provider session 生命周期泄漏成主生命周期状态 | Agent Client 把内部 continuation 归一到同一 invocation/responseMessageId;只有协议可见的 canonical output 能触发 History/Queue 变化 | | Stop 后气泡仍 processing,或 provider cancel 语义不一致 | 把 provider/session 取消细节泄漏给主生命周期,或让 Stop 自己伪造 terminal | Agent Client 的 exact cancel 是业务契约:停止 stream,并且只回调一次 canceled;主生命周期继续复用普通 terminal closure | | failed/canceled 后出现“半截 Agent 消息 + 第二条错误/取消 chat” | streaming draft 与 terminal diagnostic 各自成为一条聊天真相 | 保留同一 bubble 的 partial body;status/reason 作为 bubble chrome/折叠详情,不伪装成第二条 Agent/system 对话 | -| 根据 `pre/owner` 是否存在改写消息 kind | 把 owner disposition 结果误当成来源消息用途 | entry kind 由生产者给定;`pre` 只是 failed/interrupted disposition 的 nullable 返回结果,不参与 QueueEntry kind 选择 | +| 根据 predecessor/owner 是否存在改写消息 kind | 把 owner disposition 结果误当成来源消息用途 | entry kind 由生产者给定;failed A2A 只按持久 exact caller lineage 生成固定 `a2a_failure` control wake,不从 owner disposition/body/current holder 推导新 work | | 重启后 admitted work 静默消失或被无条件重放 | Active Run 只在内存、processing witness 被误当成 authority,或 recovery 重选 current generation | History processing bubble 是 outstanding-result witness;startup 先验证 immutable admission fence:已有 terminal 补 apply,可验证且 client 消失才 interrupted,证据不足保持 outstanding/blocked | -| targetless 消息错误投给忙碌成员 | ingest 时过早猜 fallback | entry 保存空 targets;到队首且 thread idle 后才选择最近活跃成员/default | -| 连续消息被拼成一条,无法单条操作 | Queue 把 dispatch batching 误实现成正文/entry 合并 | 一 message 一 entry;兼容前缀只共用一次 dispatch,History 与 Queue 身份不合并 | -| private work 暗中越过 Queue comparator,或 failure return 获得隐藏优先级 | 把 `private_input` 的协议用途误当成 Queue priority | failure return 与其他 kind 共用显式 `urgent / normal`(缺省 normal)与同级 FIFO;normal drain 只看 comparator head。用户显式 reorder 原子刷新完整 visible positions;private rows 不被客户端寻址,仍按自身 priority/FIFO 保持唯一相对顺序 | +| 无 `@` 消息没有 canonical default,或错误投给任意 active member | ingest 留空让 Web/后续分支猜 target,或把执行活跃误当最近完成回复 | ordinary user ingress 在原子 enqueue 前调用同一服务端 resolver,选择最近 completed response target/default 并持久化;source mentions 仍为空。admission 重验同一 target intent,忙则排队;Guide 只有该 exact target 存在 active parent 且 carrier 声明 capability 时才 Append。Web 只读同源投影,不把发言新近性叫作执行活跃 | +| 连续消息被拼成一条,无法单条操作 | Queue 把多个 source 合成一次 admission | 一 message 一 entry、一次 try-drain 一条 source;History 与 Queue 身份都不合并 | +| private work 暗中越过 Queue comparator | 把 `private_input` 的协议用途误当成 Queue priority | action/wait/callback work 共用显式 `urgent / normal`(缺省 normal)与同级 FIFO;normal drain 只看 comparator head。用户显式 reorder 原子刷新完整 visible positions;private rows 不被客户端寻址,仍按自身 priority/FIFO 保持唯一相对顺序 | ## 15. 验收矩阵 @@ -1544,7 +1534,7 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi | 验收 / 反证簇 | 由哪些 laws 推出 | 覆盖范围 | |---|---|---| -| source identity、History membership、target fence、body exposure/handled 与 presentation 必须分离 | L1 + L3 + L5 | A1–A2, A6–A10, A12–A14, A17–A21, A30–A34, A36–A41, A45–A50, A53–A59, A63, A71–A90;§16 中 owner/custody/ref/presentation 互相代写或推断的状态 | +| source identity、History membership、pending target、actual dispatch、普通未读与 presentation 必须分离 | L1 + L3 + L5 | A1–A2, A6–A10, A12–A14, A17–A21, A30–A34, A36–A41, A45–A50, A53–A59, A63, A71–A90;§16 中 Queue/ref/cursor/presentation 互相代写或推断的状态 | | Queue 顺序、batch/Append/Steer/Cancel 的 exact cutover 与 side-effect/authority fence | L1 + L2 | A3–A5, A8–A18, A26–A29, A35, A37, A42–A44, A51–A52, A57–A58, A61–A64, A69–A70, A73–A75, A81, A84, A86–A89;§16 中跨 head、hidden priority、authority 重选、先 side effect 后 take、重复 launch、UI 发明 eligibility 的状态 | | admitted run、delivery terminal、owner disposition、Stop 与 restart recovery | L1 + L4 + L5 | A4–A7, A10, A17–A25, A27–A30, A33–A34, A40–A50, A53, A55–A68, A76–A90;§16 中无 durable result、半终局、stale callback、错误 cancel/replay/compensation、terminal 与 Queue 并存及虚假 working claim | @@ -1555,25 +1545,25 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi | ID | 场景 | 必须满足 | |---|---|---| | A1 | 用户发送 `@B`,B 繁忙 | 一个 `conversation_input` entry 保存 inline payload + `from={kind:'user',...}` + `targets=[B]`;Queue Panel 回显;History 中尚无该输入 | -| A2 | A `post_message @B`,B 繁忙 | 独立消息立即公开并成为待投递消息;一个 entry 与一个 B 的 `assigned` ref 原子留下;尚未 actual dispatch 时不显示“正在处理”头像;A 不被自动取消 | +| A2 | A `post_message @B`,B 繁忙 | 独立消息立即公开并成为待投递消息;一个 message-ref entry 保存 `targets=[B]`;尚未 actual dispatch 时没有 ref/头像;A 不被自动取消 | | A3 | 队首 `M1→A`、次条 `M2→B`,A 繁忙 | 不跳过 M1;B 不提前启动 | | A4 | A 终局释放最后一个 blocker | 同一终局路径 requestDrain;M1 随即被处理,不依赖 timer | | A5 | M1 被 provider 接受后 M2 可执行 | drain 继续启动 M2,不等待 M1 完整回复 | -| A6 | 一条用户消息 `@B @C` | 一个 entry 保存 `[B,C]`;B/C 全空闲后 atomically materialize 这一条被投递 input + 两个独立 response bubble + 两个分别指向其 bubble 的 `dispatched` refs;B/C 头像都挂在该 input bubble 下并独立显示处理中 | +| A6 | 一条用户消息 `@B @C` | Queue 中始终只有一条 source entry、`targets=[B,C]`;普通 drain 冻结并并发 dispatch 整组 B/C,B/C 各自在 receiver/ref durable 后从同一 claim 独立退役。两个独立 response bubble/ref/头像各自终局;一个 admission 失败只恢复该 pending target | | A7 | B 启动成功、C 启动失败 | B 继续;C 原位 failed;B/C refs 分别保持 `dispatched`/推进为 `settled`,两者不相互覆盖 | -| A8 | 连续三条相同 target 用户消息 | 三条独立 entry 作为兼容队首前缀一次 dispatch;分别生成三条公开 History message,不拼正文 | +| A8 | 连续三条相同 target 用户消息 | 三条独立 entry 逐 source admission;前一条占用 target 时后一条 try-drain 无副作用返回,终局信号再试;分别生成三条公开 History message,不拼正文 | | A9 | 用户显式 Append 第二条 | 选中 entry 先 materialize,再加入 exact run;两条 History message 仍独立 | -| A10 | A→B 与 C→B 的独立消息都在 B 本轮实际未读投影 | 两条 History message/entry/ref 身份独立;两条 wake 被原子移除并附到 B bubble/run;两个 refs 都从 `assigned` 推进为指向该 bubble 的 `dispatched`;B 只启动一次 | -| A11 | 用户消息无 target,thread 有 Active Run | targetless head 等待,后续显式 target entry 不越过 | -| A12 | 最后一个 Active Run 终局 | targetless head 只选择最近一条 `completed` 响应的当前可用回复成员;排除 processing/failed/canceled/interrupted;没有则 default | -| A13 | public conversation input 的 mention 无效或成员已删除 | Queue row 保留 warning;按仍有效 targets 或 targetless fallback 继续,不永久卡 head;admission 后消息气泡只为最终 actual targets 建 refs/头像,不保留失效 mention 的假 processing claim | +| A10 | A→B 与 C→B 的独立消息都在 B 本轮实际未读投影 | 两条 History message/entry 身份独立;两个 wake 的 B target 被原子移除并附到 B bubble/run;两个 `dispatched` refs 都直接指向该 bubble;B 只启动一次 | +| A11 | 用户消息无 target,thread 有 Active Run | enqueue 前由同源 resolver 选择最近 `completed` responder/default 并写入 `targets`;若该 exact member active 且作者选择 Guide、carrier 支持则 Append,否则作为该成员的普通 pending work 排队;不保持空 target,也不选择任意 active member | +| A12 | no-mention target 在入队后、admission 前失效 | admission 用同一 resolver 重验:只选择最近一条 `completed` 响应的当前可用回复成员;排除 processing/failed/canceled/interrupted;没有则 default。历史 targetless row 也只走此恢复路径 | +| A13 | public conversation input 的 authored mention 无效或成员已删除 | Queue row 保留 warning;仍有效 targets 原样保留;全无效时 ingress 不静默改投,targetless row 由 Queue-head canonical completed/default fallback 继续且不永久卡 head;source mentions 不伪造 fallback,admission 后消息气泡只为最终 actual targets 建 refs/头像,不保留失效 mention 的假 processing claim | | A14 | 普通正文包含 `@` | 不产生结构化 target | | A15 | 两个调度事件同时到达 | 一个 drain owner;第二个只置 dirty;不会重复 dispatch | | A16 | 事件在 drain 退出窗口到达 | release-owner 临界区观察 dirty,至少再运行一轮 | | A17 | Queue commit 后、requestDrain 前进程退出 | startup scan 重新触发,消息不静默积压 | | A18 | admission 前 principal persist 失败 | entry 与 inline payload 仍在 Queue;History 无 ghost message;没有 client side effect | -| A19 | crash after Queue take before provider launch | input、processing bubble 与 `dispatched` delivered-message refs 已原子写入;startup 将 bubble 原位 interrupted、refs 原位 `settled`;source owner 返回 predecessor 时原子交回,否则直接闭合;不重放 | -| A20 | crash after provider accepted before final | 同一 bubble 原位 interrupted 并保留已持久化 partial body;delivered-message refs 同事务推进为 `settled`;source owner 返回 predecessor 时原子交回;不追加第二条结果 | +| A19 | crash after Queue take before provider launch | input、processing bubble 与 `dispatched` delivered-message refs 已原子写入;startup 将 bubble 原位 interrupted、refs 原位 `settled`;dispatching member 后续从 History 观察该结果;不重放 | +| A20 | crash after provider accepted before final | 同一 bubble 原位 interrupted 并保留已持久化 partial body;delivered-message refs 同事务推进为 `settled`;dispatching member 后续从 History 观察该结果;不追加第二条结果 | | A21 | detached exact run 长时间静默并跨 API restart | callback principal 不因静默 TTL 变 unknown;迟到 terminal 幂等识别 | | A22 | provider launch 抛错 | processing bubble 原位 failed;delivered-message refs 原位 `settled`;run 删除;requestDrain | | A23 | provider 执行失败且没有正文 | processing bubble 原位 failed、delivered-message refs 原位 `settled`;不能永久留空 | @@ -1586,52 +1576,52 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi | A30 | A 先开始、B 后开始、B 先完成 | UI、最终 History、Agent context 都保持 A bubble → B bubble | | A31 | cursor 遇到 processing bubble;同一 public user turn 的 first-pass route 先后产生其他猫输出,随后用户又发一条可见消息点名目标猫 | processing bubble 不越过,terminal 后在原位置读正文再推进;same-route output 在原 user turn 内保持隔离,但后续 user turn 形成新边界,其之前的公开输出不得静默消失。server-authored causal direct reply 可按 exact source author 投影,只有 replyTo 而没有 causal carrier 的输出仍保持隔离 | | A32 | admission input 位于更早 processing barrier 之后 | materialize 后作为 exact input 注入;不错误推进普通 cursor | -| A33 | run 失败且 source owner 返回 exact predecessor | 公开 failed;owner 先按自己的 typed carrier + generation 校验,再向 exact predecessor 追加一条正文含 failure evidence 的 Queue-only `private_input`;不按 History author 猜;failure return 不进入 History,只进 predecessor 的 exact input | -| A34 | 上述 failure-evidence private input 再次失败 | 生产者不建立新的 predecessor binding,不递归创建交回树;Queue 不靠 payload subtype 特判 | +| A33 | B run 失败 | 公开 failed response 是唯一终态;同一提交为 exact caller A 建立一条引用该 response 的幂等 `a2a_failure` Queue control wake,A 的 prompt 读取该 canonical response;控制载体自身失败不递归 | +| A34 | A 观察到 B failed 后决定改投 C | A 显式发出一条新的普通 dispatch;不复活或改写 A→B 的 terminal lifecycle | | A35 | Queue 中存在隐藏 private rows,用户 reorder 可见 Queue rows 或 remove 一个 visible row | UI 携带读取时的 `expectedQueueRevision + orderedVisibleEntryIds`;服务端校验 revision、完整 visible set 与 eligibility 后,在一个事务中写入 `position=0..n-1` 并 requestDrain。remove 只删除 exact visible row。private rows 不可由 UI 寻址,仍按自身 priority/FIFO 排序;revision/集合变化时整次 typed conflict,不 partial-write、不猜位置 | | A36 | 用户/Connector 输入仍在 Queue | Queue API/UI 能从 inline payload 完整回显正文与附件;稳定 `sourceRecordId` 已存在,但没有 History membership/orderKey,Agent 普通 context 不可见 | -| A37 | 两条相邻 public conversation inputs 的 routing class 与 targets 相同 | 可共用一次 dispatch,但仍是两个独立 entry/message;target 相同不能触发正文拼接 | -| A38 | 正文含 predecessor failure evidence 的 private input 仍在 Queue | 用户 Queue Panel 与 Chat History 都不可见;target Agent dispatch 时从 exact situation packet 读取;Queue 形状与其他 private input 相同 | +| A37 | 两条相邻 public conversation inputs 的 routing class 与 targets 相同 | 仍逐 source admission;target 相同不能触发 Queue 合批或正文拼接 | +| A38 | A→B terminal 已在 History,A 尚未再次自然唤起 | A 的进程内 outbound dispatch view 保留 exact source×B revision;Queue 不新增 row,History 不新增 result message | | A39 | Connector/定时任务输入仍在 Queue | Queue inline payload 可完整回显并保留 sourceRecordId;`from` 分别保留 external connector / system scheduler 命名空间;admission 才获得 History membership/orderKey | -| A40 | Agent message ref 的 target 在队首前被删除 | 保留原 Agent message并写 public typed target failure;source owner 返回 predecessor 时给它排正文含 failure evidence 的 private input,否则不排;绝不投给 fallback | -| A41 | 上述 failure-evidence private input 的 exact target 被删除 | 与任何 private input 相同,entry 以 internal diagnostic 结束;不写 History、不交给其他成员、不 fallback | -| A42 | conversation input 后紧跟相同 target 的 message ref/private input | conversation batch 在 kind 边界停止;message wake 只按实际未读覆盖,private input 单独 admission | -| A43 | 用户查看一条 `@B @C` entry,B/C 都在运行且完整 Steer preconditions 成立 | 服务端 action projection 才提供整条 Steer;一个原子 cutover take 整条 entry、终局 B/C 旧 bubbles、为 B/C 建新 bubbles;不拆出 per-target Queue 残片 | -| A44 | 用户查看一条 `@B @C` entry,B 有 Active Run、C 没有 Active Run | 服务端 action projection 不提供 Append;绕过 UI 的 direct/stale 请求返回 typed precondition conflict + 当前 canonical projection且无 side effect,entry 整条留在 Queue;不能只 Append 给 B 后静默丢掉 C | +| A40 | Agent message ref 的 target 在队首前被删除 | 保留原 Agent message并写 public typed target failure;dispatching member 后续从 History 反查该 failure;绝不投给 fallback | +| A41 | 上述 target failure 已 terminal,dispatching member 该 turn 失败 | exact view item revision 保留,下一次自然 invocation 再次投影;不交给其他成员、不 fallback | +| A42 | conversation input 后紧跟相同 target 的 message ref/private input | 三条 entry 各自 admission;message wake 只按实际未读覆盖,private input 单独且不公开 | +| A43 | 用户查看一条 `@B @C` source,B/C 都仍 pending,并分别选择 B=interrupt、C=guide;确认前 B 已被未读接管,用户另选 D 并取消 C | 服务端重读 live Queue+History,跳过已投递的 B;取消 C 只从 pending set 删除,D 在同一 source entry mutation 中加入并投递。若 source caller 是 Agent,其 runtime view 在 mutation 提交后记录 C removed 与 D added;B 的既有 dispatch 不撤销;各 target provider outcome 独立 | +| A44 | 用户查看一条 `@B @C` source,B 有 Active Run、C 没有 Active Run | B 可 guide/interrupt;C 没有当前回复可引导,guide 禁用。确认时若某 target 已投递或不可用,服务端从 live selection 跳过它并继续处理其他仍 pending targets;新选的可用成员只在确认时加入 thread | | A45 | B completed 后又 `post_message @D` | A→B 当前一跳保持闭合;另建 B→D 独立 History message 与 Queue ref | -| A46 | B completed、C failed,二者来自同一 multi-target input | B bubble 保持 completed;C bubble 独立 failed;C 的 source owner 返回 predecessor 时只交回该 predecessor,不影响 B | +| A46 | B completed、C failed,二者来自同一 multi-target input | B bubble 保持 completed;C bubble 独立 failed;dispatching member 后续分别读到两个 exact target 终态 | | A47 | target client 不支持 Append/Steer 提示 | 明确使用已声明的默认投递语义或返回 typed failure;Queue input 不能静默丢失 | -| A48 | failed/interrupted bubble 与 predecessor return 提交之间进程退出 | 两者属于一个持久事务,只能同时出现或都不出现;不会留下不可恢复的半终局 | +| A48 | failed/interrupted bubble 提交后、dispatching member 观察前进程退出 | failed 的 response 与 exact caller control wake 原子提交,重启后 Queue 继续驱动;interrupted 只保留 History terminal。新 process generation 不自动重建旧 observation,只给出有界 process-start 说明,结果可按需从 History 读取 | | A49 | startup 同时发现 processing bubbles 与非空 Queue | 先按 exact admission fence 收敛旧 bubbles:已提交 terminal 只补 apply,binding 可验证且 live client 消失才原位 interrupted,证据不足保持 outstanding/blocked;所有可判定项完成后才 requestDrain,不因 Active Runs 为空提前启动冲突 run | -| A50 | B 一轮精确覆盖 A→B 与 C→B 后失败 | durable bubble 引用两条 input entry/message IDs,两个 dispatch owners 都绑定同一 invocation;failed terminal 按各 owner 返回的 exact pre 给 A/C 各排一次正文含 failure evidence 的 private input;不能只交回队首 predecessor,也不能从 author 猜 | +| A50 | B 一轮精确覆盖 A→B 与 C→B 后失败 | durable bubble 引用两条 input entry/message IDs;A/C 各自保留 exact source×B observation,后续自然 invocation 反查同一 failed terminal;不能只更新队首 source,也不能从 author 猜 | | A51 | Queue 为 `wake(A→B), conversation(user→B)` | B 本轮只能精确处理已公开的 A→B;conversation input 继续留在 Queue,不能提前进入 History | | A52 | Queue comparator 顺序为 `conversation(user→B), wake(A→B)`,或 `wake(C→B), conversation(user→B), wake(A→B)` | 只移除本轮实际投影覆盖并附到 bubble/run 的 wakes;中间 conversation input 不被跨越 materialize,Queue comparator 顺序保持 | | A53 | API 重启前 B 有 processing bubble,重启后用户或 Agent 查询工作状态 | startup 收敛完成前 thread 不对外 ready;binding 可验证且 live client 消失时 B bubble 为 interrupted、对应 refs 为 `settled`、Active Run 不存在。binding 证据不足时保留 outstanding/blocked 并隐藏 working claim;两种结果都不得宣称 B 仍在工作 | | A54 | 来源提交 CI/review/approval/wait wake 的 `private_input` envelope | Queue envelope 只携带 inline payload + exact target + 显式 `urgent / normal`(缺省 normal);action fence / wait carrier / callback carrier 留在各自 owner store,入口不解析 payload、不调用 Agent Client、不写 History message、不选择最近成员,也不因 private kind/source category 获得优先级 | | A55 | B 正在处理带 exact A2A/action owner binding 的 work,期间无关 C 调用 `hold_ball`,随后 B completed | C 的 hold 只修改自身 owner namespace;B terminal transaction 仍按 exact invocation binding 让该 source owner 提交 disposition,不出现 `holder_mismatch/source_missing` | | A56 | private input 在 Queue 外登记的 source owner binding 于 admission 前缺失或已被新 generation 替换 | owner 在 client effect 前拒绝 binding;pre-admission private terminal transaction 移除 exact entry 并留下 diagnostic,不能从 payload 重建 carrier,也不能变成可重复唤起的 work | -| A57 | public targetless input 到队首,最近 completed member 与 default 都不可用 | 一个事务 materialize 原输入,再紧邻写 `DeliveryFailureResult(no_available_target)` 并移除 exact entry;没有假 invocation/bubble | -| A58 | Agent message ref 的显式 target 在队首前失效 | 原 Agent message 保持原位;terminal transaction 追加 `DeliveryFailureResult(invalid_explicit_target)`;仅在 source owner 返回 predecessor 时追加正文含 failure evidence 的 private input,并移除 entry;绝不 fallback | +| A57 | conversation input admission 重验(含历史 targetless recovery)时最近 completed member 与 default 都不可用 | 一个事务 materialize 原输入,再紧邻写 `DeliveryFailureResult(no_available_target)` 并移除 exact entry;没有假 invocation/bubble | +| A58 | Agent message ref 的显式 target 在队首前失效 | 原 Agent message 保持原位;terminal transaction 追加 `DeliveryFailureResult(invalid_explicit_target)` 并刷新 exact caller view item,再移除 entry;绝不生成 hidden wake,绝不 fallback | | A59 | admission 已提交 immutable source owner bindings 并写 processing bubble,进程在 terminal 前退出 | startup 以 bubble 的 invocationId 查询原 lease/generation/frozen predicate;无 committed terminal 且 live client 消失时把 exact source terminalize 为 interrupted,已有 terminal 则只补 apply;不从 thread holder/current identity 猜 source,不追加第二份结果 | | A60 | A/B 同时 active,用户 Stop thread 全部活动 Agent | coordinator 快照 A/B 的 exact invocations并分别调用两个 Agent Clients cancel;每个 client 的 canceled callback 独立原位终局对应 bubble并释放对应 run;Queue entry 不删除、不重排 | | A61 | Stop 后 Queue 仍有下一条 `M→A` | Stop 完成后 requestDrain;M 按 comparator 队首创建新的 A bubble/invocation;不得把 Queue 隐式设为 paused,也不得把新 invocation 当作旧 run 误杀 | -| A62 | B 的 Agent Client canonical completed final 含有效 `@D` | B 的同一 response bubble 原位 completed;terminal transaction 原子创建引用同一 `responseMessageId` 的 message ref 与 D 的 `assigned` ref;不追加第二条 Agent message;stream chunk 与重复 callback 不 enqueue | +| A62 | B 的 Agent Client canonical completed final 含有效 `@D` | B 的同一 response bubble 原位 completed;terminal transaction 原子创建引用同一 `responseMessageId`、`targets=[D]` 的 message-ref entry;不追加第二条 Agent message;stream chunk 与重复 callback 不 enqueue;D actual dispatch 时才建 ref | | A63 | 用户/Connector/公开通知来源提交 `conversation_input` envelope | 入口直接封装为 QueueEntry,priority 缺省 normal;它没有 structured source binding,不能因此改写 kind、跳过 Queue 或直接写 History | | A64 | 用户 Stop exact B run,底层 provider cancel RPC 失败或迟到 | 差异被 B Agent Client 吸收:client fence 后续 stream,并仍只产生一次 canceled callback;主生命周期由该 callback 原位终局同一 bubble、释放 exact run、继续 drain;任何迟到 provider chunk/final 都是 stale no-op | | A65 | B 的成员运行时在处理中 compact context、roll over session、内部 handoff continuation 或 re-trigger | 主生命周期始终只有同一 B Active Run、`invocationId`、callback principal 与 response bubble;不新增 Queue/History 状态;后续 stream/final 继续更新同一 bubble | -| A66 | terminal 分别为 completed 无有效 target、failed 且 owners 均返回 `pre=null`、canceled | 三者都不创建 Queue follow-up;有 structured source binding 时由 owner 在 durable terminal transaction 中按 exact generation/predicate 提交 disposition,无 binding 时为空操作;commit 后释放同一 Active Run 并 requestDrain;不存在第四条 `other` outcome | +| A66 | terminal 分别为 completed 无有效 target、非 A2A failed、canceled | 三者都不创建 Queue follow-up;exact A2A failed 则只创建幂等 `a2a_failure` caller control wake。有 structured source binding 时由 owner 在 durable terminal transaction 中按 exact generation/predicate 提交 disposition,无 binding 时为空操作;commit 后释放同一 Active Run 并 requestDrain;不存在第四条 `other` outcome | | A67 | Agent 连续 stream append chunk、replace snapshot,期间 F5 | UI 与 History 投影始终只有 admission 时的同一 `responseMessageId/orderKey`;持久层保存最新累计 snapshot,不产生每-chunk History rows、不重复拼接正文 | | A68 | Agent 已 stream 半段正文后 token 耗尽或被用户取消 | 同一 bubble 保留半段正文;分别终局为 `failed/token_limit` 或 `canceled/user_cancel`,delivered-message refs 同事务推进为 `settled`,状态作为 bubble footer/折叠详情显示;不追加第二条 chat,不从 partial body 解析 target | | A69 | 未定位的 Queue rows `N1, N2` 均为 normal,来源随后提交 normal `private_input C` | enqueue commit 后三者的 FIFO comparator 顺序为 `[N1, N2, C]`;若 C 显式为 urgent,则按两级 priority 排到未定位 normal rows 前。C 不因 private kind/source category 插队,轮到 comparator head 时才作为私有输入启动普通 Agent Client 主链;不写 History message、不 fallback | | A70 | comparator 当前投影为 `[V1,C1,V2,C2,V3]`,其中 `C1/C2` 是未定位隐藏 rows;用户把 `V3` 拖到 `V1/V2` 之间 | UI 提交同一 revision 下完整 visible 顺序 `[V1,V3,V2]`;服务端原子写 positions 后唯一 comparator 结果为 `[V1,V3,V2,C1,C2]`。`C1/C2` 不被客户端寻址并继续按自身 priority/FIFO 排序;同一个 revision + ordered visible ids 不能得到另一结果,revision/visible set 已变化时整批 conflict | | A71 | IM sender id 或 GitHub actor id 与某个 catId 字符串相同 | `from.kind='external'` 的 connector/sender 命名空间保持不变;路由器绝不把它解析成 Agent sender | | A72 | `from.kind='system'` 分别提交公开通知与 private input | 公开通知用 `kind='conversation_input'`,admission 时 materialize;私有协议输入用 `kind='private_input'`,不创建 History message;不能仅凭 from 推断可见性 | -| A73 | Agent `post_message` / completed final 需要唤起成员 | Queue payload 都是唯一 `message_ref`;正文只存在于原 History message,不复制到 Queue;message-ref entry 与每目标 `assigned` ref 原子创建 | +| A73 | Agent `post_message` / completed final 需要唤起成员 | Queue payload 是唯一 `message_ref`,一条 entry 保存全部 pending targets;正文只存在于原 History message,不复制到 Queue;actual dispatch 前没有 History ref | | A74 | envelope 组合非法,例如 `message_wake + inline`、`conversation_input + message_ref` 或 `private_input` 的 `targets=[]` | enqueue 原子拒绝且不创建 Queue/History 状态,不用运行时猜测修正 | -| A75 | 用户取消尚未 admission 的 Agent `message_wake` | exact Queue entry 与该 wake 尚未 dispatch 的 `assigned` refs 同事务删除;原 History 正文保留;不制造“已取消处理”头像或结果 | -| A76 | 用户/Connector 公开消息出队并显式路由给 B/C | materialize 时直接在这条被投递消息上创建 B/C 的 `dispatched` refs 并分别链接两个 processing bubbles;不经过可观察的 `assigned`;两个头像都在该消息气泡下复用现有处理动效 | +| A75 | 用户取消尚未 admission 的 Agent `message_wake` | exact Queue entry/target 删除;原 History 正文保留;因为没有 actual dispatch,所以不删除或伪造 ref/头像/结果 | +| A76 | 用户/Connector 公开消息出队并显式路由给 B/C | 每个 actual target 投递时直接在这条消息上创建自己的 `dispatched` ref 并链接 processing bubble;首次投递 materialize message,后续 sibling 复用它;两个头像都在该消息气泡下复用现有处理动效 | | A77 | 同一被投递消息的 B completed、C failed | 两个 refs 各自推进为 `settled`;同一消息气泡下两个头像分别停止并静态显示;提示只分别为“B 处理完成”“C 处理失败”,详细结果仍只来自各自 linked canonical bubble | -| A78 | Agent `message_wake @B` 在 admission 前发现 B 无效 | 原消息保留;`assigned` ref 与 Queue exact take 同事务推进为指向 `DeliveryFailureResult` 的 `settled`;不创建 processing bubble、Active Run 或 fallback target | +| A78 | Agent `message_wake @B` 在 admission 前发现 B 无效 | 原消息保留;Queue exact target 与 `DeliveryFailureResult` 同事务提交;失败结果直接引用原消息,不创建 processing bubble、Active Run、fallback target 或虚假 dispatch ref | | A79 | A 的 server-side Agent execution 正在处理先前公开输入,provider receipt 尚未知,B 随后获得 situation/context summary | B 从该被投递 History 消息的 A `dispatched` ref、关联 processing bubble 与 exact Active Run 得到粗粒度“A 正在处理”;用户也只在这条消息气泡下看到 A 动态头像。该文案只表示 server execution live,不是 provider receipt/read receipt,不推进普通 cursor;A 失败时关联 bubble 原位 failed,终局后 B 只读一次原位 terminal bubble | | A80 | startup 发现 delivered-message ref 为 `dispatched`、关联 bubble 仍 processing | thread 对外 ready 前验证 exact admission fence;binding 可验证且 live client 消失时 bubble 原位 interrupted、ref 原位 `settled`。证据不足时 ref/bubble 保持 outstanding 但不投影动态头像;不得借 ref 自身提供 recovery authority | | A81 | preflight 冻结 `HEAD=H, generation=7`,admission persist 前同一 owner 以相同 HEAD 替换为 generation 8 | generation 7 的 admission CAS 失败;不 take Queue、不建 bubble、不激活 principal、不调用 provider。owner 明确 mismatch 后只 CAS 关闭 exact obsolete entry;resolver 不因 HEAD 相同而改绑 generation 8 | @@ -1642,8 +1632,14 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi | A86 | generation 7 structured source candidate 仍在 Queue,owner 在 admission 前提交 matching `terminalize_only` 或 replacement | owner 的既有 durable post-commit event 触发 requestDrain;coordinator 重读 exact typed fact,以 entry + Queue revision + candidate CAS 整条关闭并写既有 result/diagnostic;不建 bubble、不调用 provider。重复 event no-op,generation 7 event 不能关闭 generation 8 candidate | | A87 | structured head 因 `insufficient_evidence` 保持 pending,owner 后来提交足以判为 `admit`、`terminalize_only` 或 `mismatch` 的新证据 | exact evidence-change post-commit signal 触发 requestDrain;新 typed read 只走正常 admission 或 §7.1 exact closure。等待期间它不是可执行 head,不靠 timer/fallback,也不绕过 comparator | | A88 | Queue entry 已完成 admission cutover,随后 provider launch/execution failed | 原 Queue entry 在 provider effect 前已经不存在;同一 processing bubble 原位 failed,refs/owner disposition 正常收敛。系统不得生成 `failed + queued` row;Retry/重发若被 owner 授权,只能创建新的 entry/attempt | -| A89 | live cutover 时存在 failed-only legacy Queue row,或同一 multi-target row 中 terminal B + pending C;另一个旧实例试图在 clean scan 后补写 legacy projection | 所有 writer transaction 已带 `lifecycleWriterEpoch` CAS。先阻断新 legacy admission、退出未受 guard 保护的 binary/已开启 transaction,再取得独占 migration lease并提交 `legacy → migrating`。lease 内保留可证明的 canonical terminal/owner facts;旧 row 整体不可选并删除/隔离 Queue projection,mixed/歧义数据留下 migration diagnostic,不自动拆 target。clean scan 后同 lease CAS `migrating → live`;旧实例的 legacy-epoch commit typed conflict。任一 guard/transaction/scan 条件不成立时保持 `migrating + ingress blocked` | +| A89 | schema v1 中同一 source 有 B/C 两条 pending rows,另有已 terminal 的 D row;迁移重放两次 | v2 按 `sourceRecordId` 把 B/C 合成一条 `targets=[B,C]` entry并继承顺序;D 不导入 Queue;第二次迁移得到同一 entry。payload/source 冲突则 fail closed 留诊断 | | A90 | 用户消息、Agent `post_message`、公开外部/系统通知、带下一跳 target 的 completed response bubble 分别触发 dispatch;其中一条 public input 通过 fallback 选中 D | 四类公开消息都以自身 History record 作为唯一 delivery-avatar anchor;public input 复用 enqueue 时的同一 `sourceRecordId`,fallback 后只为 actual target D 建 ref。每个 target 只有在 `dispatched + processing bubble + exact live Active Run` 同时成立时头像动态闪烁,target terminal 后同一头像按 linked result 静态终局;admission 前的 Queue 投影、transport carrier、structured source owner、始终隐藏的 `private_input` 与另造状态 bubble 都不承载这组头像 | +| A91 | Agent A 提交 M1→B/C/D 并全部进入 durable Queue;B 随后在 actual admission 前失效,D 已实际投递,Steer 再移除 C 并加入 E | A 的 runtime outbound view 分别保留 B=`delivery_failure`、C=`not_delivered/withdrawn`、D=History actual result、E=pending/actual result;若 B 在 durable Queue admission 前已被 routing preflight 拒绝,则改由本次发送结果立即返回 typed fail-back且不登记 view;source `dispatchRefs` 仍只含实际结果,Queue/History 语义不变,不为 C/E 创建持久 receipt | +| A92 | A 的 prompt 已包含 M1→C removed@rev2;该 invocation 完成前另一操作把 C 重新加入为 rev3 | A 成功 completed 只确认 rev2;compare-and-clear 发现 current revision=3 后保留 item,下一自然 turn 再呈现 C 的最新 pending/actual 状态 | +| A93 | M1→B/D 尚未终结时,A 又提交 M2→B/D | outbound view 同时拥有四个 exact source×target items;M2 不覆盖 M1,任一 target terminal/withdrawn 只日落实际成功观察的 item revision | +| A94 | A 连续两个自然 turn 之间,M1→D 仍为同一个 pending/open revision | 第一个成功 turn 推进 presentedRevision 但保留 item;第二个 turn 不重复注入 D,直到 Queue/History 变化使 revision 前进 | +| A95 | 投影已读到 C removed@rev2,异步查询期间 C 被重新加入为 rev3 | 本轮不得把 removed 正文标成 rev3;重读后投影最新状态,或保留 rev3 待下一 turn。无论本轮是否成功,都不能确认/清理未实际呈现的 rev3 | +| A96 | withdrawn item 已成功清理,随后同一个尚未 actual dispatch 的 source×target 被重新选择并进入 view | 新 item 使用严格更高 revision/epoch;旧 invocation 的 includedRevision 永远不能命中新 item。已 actual dispatch 的 source×target 仍不得因此重新执行 | ## 16. 必须保持不可能的状态 @@ -1655,23 +1651,23 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi - `conversation_input/private_input` 因 failed/interrupted disposition 最终是否返回 `pre` 而被互相改写,或 public input 因没有 structured source binding 而绕过 Queue; - `private_input` 因 payload 内容被分成额外 subtype/调度分支,或 typed carrier 被复制进 QueueEntry、从聊天正文、author、thread holder 反推; - 用户/Connector conversation input 在 admission 前已经获得 History membership/orderKey 或进入 Agent 普通 context;预分配 owner-owned sourceRecordId 不属于该违规; -- Agent History message 已提交 `message_wake` 却缺少相同 exact targets 的 `assigned` refs,或 `assigned` ref 在 Queue entry 已取消/被 take 后仍无对应单调迁移; +- Agent History message 已提交 `message_wake` 却没有一条携带完整 pending `targets[]` 的唯一 Queue entry,或 actual dispatch 前已经出现 ref; - message-ref entry 指向不存在或不属于同一 thread 的 message,或 external/plugin/system id 被按 Agent id 解析; - Queue 非空、队首可执行、没有 Active Run,也没有 drain owner; - external structured owner 已提交会改变 exact unadmitted candidate 可执行性的 durable typed fact,却没有通过既有 post-commit signal 触发 `requestDrain`;或 drain 直接相信 event payload 而不重读 owner fact / 竞争 Queue revision CAS; - commit 后,后面的 entry 在正常 drain 中绕过被 busy target 阻塞的 comparator head; -- 两个 owner 从同一个 Queue Entry 启动两次 provider; +- 两个 owner 从同一个 source entry 的同一 pending target 启动两次 provider; - dispatch batching/coalesce 改写、拼接或覆盖两条独立消息的身份; -- targetless input 在其他 Active Run 尚存时提前猜成员; +- 普通无 `@` user input 被以空 `targets` 持久化,或 ingress/admission 绕过同源 resolver 猜成员;resolver 目标没有 exact active parent invocation / guide capability 时仍执行 Guide; - client side effect 已发生,但 History 中没有固定 response bubble; - client side effect 已发生,但实际被投递的公开 History message 对该 exact target 没有指向固定 response bubble 的 `dispatched` ref,或 ref/头像在 public source record 取得 History membership 前提前出现,或错误挂在 transport carrier、structured source owner、private input、sender provenance 或另造状态消息上; - client side effect 已发生,但 structured source owner 尚未绑定 exact entry/target/invocation 与 admission 时冻结的 owner kind、lease/generation、predicate/HEAD、principal/tenant/route; -- `lifecycleWriterEpoch` 已是 `migrating/live`,legacy-epoch admission/terminal/migration transaction 仍可提交;或 reconciliation clean scan 未受同一 migration lease + expected `migrating` epoch 保护,就被当作激活证据; -- live cutover 后,同一 `entryId + targetId` delivery attempt 既是 selectable Queue member,又已有 processing/failed/completed/canceled/interrupted bubble;non-selectable migration diagnostic 不属于 Queue member; +- schema v2 Queue 已启用,legacy writer 仍可写入 per-target row、Queue terminal/receipt 或覆盖 v2 source entry; +- live cutover 后,同一 `entryId + targetId` delivery attempt 既仍在 Queue `targets[]`,又已有 processing/failed/completed/canceled/interrupted bubble;non-selectable migration diagnostic 不属于 Queue member; - preflight candidate 与 admission persist 之间 generation 已替换,系统却因 HEAD/carrier 相同而改绑 current generation、take Queue 或调用 provider; - callback principal、transport carrier、thread holder 或 current identity 被当作 action/wait lease authority,或 secondary resolver 在 terminal/recovery 重新选择 owner generation; - response bubble 已 completed/canceled,而该 invocation 的 structured source bindings 没有在同一个 owner-generation/predicate 校验的 terminal commit 中提交 disposition; -- delivered-message `dispatchRef` 从 `dispatched` 倒退到 `assigned`、跳过 actual target、缺少 `statusMessageId`,或 `settled` ref 指向仍为 processing 的 canonical record; +- delivered-message `dispatchRef` 从 `settled` 倒退到 `dispatched`、为 pending target 提前出现、跳过 actual target、缺少 `statusMessageId`,或 `settled` ref 指向仍为 processing 的 canonical record; - response bubble 已 terminal,但对应 delivered-message ref 仍为 `dispatched`,或 ref 已 `settled` 而 linked canonical result 尚未在同一事务终局; - 无关 `hold_ball`、event wait 或展示 holder 改写了另一个 action/TurnExecution reservation; - 旧 inbound A2A carrier 仅因目标猫后来出现在无关反向 handoff / hold 中就被判为 replaced,或 successor 与 source 没有 exact reply/causal/coordination subject lineage; @@ -1680,13 +1676,13 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi - failed/canceled 丢弃已经生成的 partial body,或把 error/cancel 追加成第二条 canonical chat / Agent 正文; - completed final 被复制成第二条 Agent History message;stream chunk、failed/canceled residual output 或重复 callback 创建 successor wake; - completed final 的有效 target 已经随 bubble 对用户可见,但引用同一 `responseMessageId` 的唯一 Queue wake 没有在同一 terminal transaction 中提交; -- completed final / `post_message` 的 Queue wake 已提交,却没有在同一事务写入每个 exact target 的 `assigned` ref; +- completed final / `post_message` 的 Queue wake 已提交成多条 source rows,或 actual dispatch 前提前写入 target ref; - 成员内部 compact、session rollover、handoff continuation 或 re-trigger 被主生命周期解释为新 Queue entry、新 Active Run、新 response bubble,或导致 callback principal / invocation identity 漂移; - UI 与 Agent context 对同一组消息使用不同排序键; - 用户 UI 与 Agent situation/context summary 对同一成员是否正在工作给出不同结论; -- UI 把 `assigned` 当成成员已看到消息,把“正在处理”解释为 provider 已接收/body seen/handled,或另行展示用户已读/未读、receipt processing/attempt aggregate 作为第二套责任真相; -- UI 仅因 Queue row 可见或 client capability 存在就把 Append/Steer 呈现为可提交,或 UI 与 command endpoint 不消费同一 Queue revision、完整 target set、source-owner verdict 与 exact Active Run preconditions; -- typed stale/conflict 后旧确认仍可提交、自动重复相同命令而不刷新 canonical Queue/action projection; +- UI 把 Queue pending target 当成成员已收到消息,把“正在处理”解释为 provider 已接收/普通未读已推进,或另行展示 receipt processing/attempt aggregate 作为第二套责任真相; +- UI 仅因 Queue row 可见或 client capability 存在就把 Append/Steer 呈现为可提交,或 UI 与 command endpoint 不消费同一 exact mutation fence、完整 target set、source-owner verdict 与 exact Active Run preconditions; +- structured owner fence 的 typed stale/conflict 后旧确认仍可提交;或 Steer 对普通并发一刀切冲突,而不是重读 live Queue+History、跳过已投递 target 并提交仍 pending 的选择; - Agent cursor 越过 processing bubble 后永远读不到其终局正文; - same-wave sibling 因 freshness suppression 被当作已读/已投递,或 incremental projection 在暂缓该 sibling 时仍把 delivery boundary 推到其后,导致后续定向 synthesis 无法恢复完整正文; - 已发布猫输出存在于 raw timeline/thread ZSET,却没有 `visibilitySeq` 或 thread visibility membership,导致增量投影在任何 filter/cursor 判定前永久看不见正文; @@ -1694,7 +1690,7 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi - 增量窗口诊断记录消息正文、无限 message refs、高基数字段,或只给 counts 而无法判断 sampled message ref 的 bounded filter reason; - covered Agent wake 已从 Queue 删除,却没有提交 structured dispatch owner binding(若有),或没有把 exact input IDs 附到 current bubble/run; - provider launch/execution 失败但 response bubble 永久 processing; -- failed bubble 已终局,但 exact pre 对应的必要 predecessor return 因半提交永久缺失,或系统从 History author/source 猜出了一个 pre; +- 普通 failed bubble 已终局后系统仍从 History author/source 猜 predecessor、生成 hidden return,或 caller observation 未按 exact source×target 保留到成功交付; - run 已释放却没有触发下一轮 drain; - terminal outcome 出现 completed/failed/canceled 之外的普通 client 分支,或 invocation 的 structured source bindings 尚未持久提交 owner disposition 就提前释放 exact Active Run; - same-generation replay 返回不同 delivery terminal、再次解析 completed final、创建第二个 follow-up/bubble 或追加第二个 typed verdict; @@ -1703,7 +1699,7 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi - Stop 绕过 Agent Client 直接伪造 canceled terminal / 释放 Active Run,或 Agent Client 接受 exact cancel 后仍继续产出可提交 stream、没有唯一 canceled callback; - Steer 先取消 client,随后才竞争 Queue take; - Append 自动取消旧 run,或 Steer 按 author 猜测并取消不是消息 exact `producerInvocationId` 的 source run; -- multi-target entry 被显式操作 partial-claim,或拆成持久/未持久化的 per-target Queue 残片,而不是对完整 stored target set 做 all-or-none cutover; +- multi-target source 的 target mapping 在同一 source cutover 只落半组、新 no-mention source 没有在初始 admission 绑定 canonical target(或历史 targetless recovery 后仍未绑定),或一个 target 的 post-cutover failure 错误回滚 sibling; - Retry/重发复活、部分复用一个已经 terminal 的旧 Queue entry,而不是由其 owner 明确创建新的 entry/attempt; - private input 在被目标 Agent 处理前进入 Chat History 或普通 Queue Panel; - B 后续发起 B→D 时,重新打开已经终局的 A→B 当前一跳; @@ -1711,7 +1707,7 @@ API 启动时必须先完成 §10.2 的 owner-fence validation、canonical termi - startup 对 `mismatch` pending work 产生 provider side effect,或对 `insufficient_evidence` work 擅自 take、terminalize、fallback/replay; - callback principal 在 canonical terminal 前因静默失效; - exact execution/result 已 terminal但 visible lineage 缺失时,系统从 body/carrier/current identity 伪造 History bubble/ref,或因缺 link 撤销/重答 canonical terminal; -- failure-evidence private input 绕过统一 priority Queue 另建 hidden priority,或递归生成责任树; +- A2A failed 复制第二条公开 result、从 author/body/current holder 猜 caller、重复 admission 或递归 fail-back;completed/canceled/interrupted 无 owner 却另建 predecessor wake; - 为修复上述问题再增加一套平行 lifecycle ledger 或 timer 型正确性 fallback。 最终判断标准不是“覆盖了多少状态”,而是:普通读者沿一条输入从 Queue row 走到公开 History message,再走到一个 terminal bubble 时,每一步只有一个 owner、一个顺序和一个下一触发。 diff --git a/docs/architecture/ownership/README.md b/docs/architecture/ownership/README.md index 0a8bb4fc4e..feed2dafb9 100644 --- a/docs/architecture/ownership/README.md +++ b/docs/architecture/ownership/README.md @@ -20,17 +20,17 @@ Why: 一句话 |------|-------|---------|--------------------|----------------------| | [`action-plane`](cells/action-plane.md) | Action Plane | 外部动作的 typed service、CLI executor、callback route、权限、审计、dry-run、资源句柄与 durable operator receipt/lease。 | `ADR-029`, `F162`, `F308` | `packages/api/src/infrastructure/enterprise/WeComActionService.ts`
`packages/api/src/infrastructure/enterprise/WeComCliExecutor.ts`
`packages/api/src/infrastructure/enterprise/LarkActionService.ts`
`packages/api/src/infrastructure/enterprise/LarkCliExecutor.ts`
`packages/api/src/routes/callback-wecom-action-routes.ts`
`packages/api/src/routes/callback-lark-action-routes.ts`
`scripts/sync-to-opensource.sh`
`scripts/publish-release-tag.sh`
`scripts/lib/release-tag-provenance.sh`
`scripts/lib/github-annotated-tag-lock.sh` | | [`approval-index`](cells/approval-index.md) | Approval Index | operator approval aggregation layer — one shared producer catalog and exhaustive runtime registry project canonical stores with honest provenance and feature-owned decisions. | `F246` | `packages/api/src/domains/approval-hub/ApprovalIngress.ts`
`packages/api/src/domains/approval-hub/ApprovalLifecycleEpochAuthority.ts`
`packages/api/src/domains/approval-hub/ApprovalProducerRegistry.ts`
`packages/api/src/domains/approval-hub/projectApprovalNavigation.ts`
`packages/api/src/domains/approval-hub/requireAnchoredPublication.ts`
`packages/api/src/domains/approval-hub/ports/ApprovalPublicationStore.ts`
`packages/api/src/domains/approval-hub/adapters/F128ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F139ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F193ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F221ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F225ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F231ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F260ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F266ApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/adapters/F306ApprovalAdapter.ts`
`packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts`
`packages/api/src/domains/cats/services/agents/providers/CodexRuntimeInteractionRun.ts`
`packages/api/src/domains/approval-hub/ports/IApprovalAdapter.ts`
`packages/api/src/domains/approval-hub/stores/ports/IDispatchProposalStore.ts`
`packages/api/src/domains/approval-hub/stores/redis/RedisDispatchProposalStore.ts`
`packages/api/src/domains/approval-hub/stores/factories/DispatchProposalStoreFactory.ts`
`packages/api/src/domains/cats/services/stores/redis/RedisApprovalPublication.ts`
`packages/api/src/routes/approval-hub-routes.ts`
`packages/api/src/routes/eval-repair-approval-routes.ts`
`packages/api/src/routes/schedule-mutation-principal.ts`
`packages/api/src/routes/schedule-mutation-proposal.ts`
`packages/api/src/routes/schedule-proposal-decision-routes.ts`
`packages/api/src/infrastructure/scheduler/ScheduleMutationProposalStore.ts`
`packages/shared/src/approval-producer-catalog.ts`
`packages/shared/src/types/approval-hub.ts`
`packages/shared/src/types/approval-lifecycle.ts`
`packages/shared/src/types/entity-proposal.ts`
`packages/shared/src/types/schedule-mutation.ts`
`packages/mcp-server/src/tools/schedule-tools.ts`
`packages/mcp-server/src/server-toolsets.ts`
`packages/web/src/lib/approval-features.ts`
`packages/web/src/components/ApprovalPanel.tsx`
`packages/web/src/components/workspace/WorkspaceTabBar.tsx`
`packages/web/src/components/ApprovalItemCard.tsx`
`packages/web/src/components/ApprovalProvenanceLinks.tsx`
`packages/web/src/components/EntityConflictResolutionPanel.tsx`
`packages/web/src/stores/approvalHubStore.ts`
`packages/web/src/hooks/useApprovalHub.ts`
`packages/web/src/components/ApprovalHubDrawer.tsx # deprecated: replaced by ApprovalPanel` | -| [`ball-custody`](cells/ball-custody.md) | Ball Custody Engine | 球权与等待责任边界:BallCustodyProjection、Queue 普通 queued message、F254 closure/supplement、F167 action-successor 单账本与 hold rescue authorization,以及 F280 typed AwaitState lifecycle / owner fence / expiry / one-shot consume / canonical user-cancel termination event / immutable continuation carrier。责任对象各自持久化 origin/frontier/holder/generation/final;attention、carrier success、feedback why 与 source collection 都不裁决 action success 或 wake。 | `F167`, `F233`, `F254`, `F280` | `packages/shared/src/types/action-successor.ts`
`packages/api/src/domains/ball-custody/action-successor-state-machine.ts`
`packages/api/src/domains/ball-custody/action-successor-outcome-state-machine.ts`
`packages/api/src/domains/ball-custody/action-successor-return-state-machine.ts`
`packages/api/src/domains/ball-custody/action-successor-redis-scripts.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorLeaseStore.ts`
`packages/api/src/domains/ball-custody/RedisActionSuccessorLeaseStore.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorAdmissionService.ts`
`packages/api/src/domains/ball-custody/reconcile-action-successor-enqueue.ts`
`packages/api/src/domains/ball-custody/ActionSubjectTruthResolver.ts`
`packages/api/src/domains/ball-custody/ActionTerminalPredicateCatalog.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorCompletionService.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorRecoverySweep.ts`
`packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts`
`packages/api/src/domains/ball-custody/managed-command-wake-lifecycle.ts`
`packages/api/src/domains/ball-custody/hold-ball-access-policy.ts`
`packages/api/src/routes/callback-hold-ball-cancel-routes.ts`
`packages/api/src/domains/ball-custody/TurnCustodyProjectionService.ts`
`packages/api/src/domains/ball-custody/ManagedHoldSourceSelection.ts`
`packages/api/src/domains/ball-custody/ManagedHoldDispositionService.ts`
`packages/api/src/domains/ball-custody/TurnCustodyAdoptionRegistry.ts`
`packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts`
`packages/api/src/domains/ball-custody/wait-state-machine.ts`
`packages/api/src/domains/ball-custody/wait-continuation-carrier.ts`
`packages/api/src/domains/ball-custody/WaitContinuationRetryPreflight.ts`
`packages/api/src/domains/github-signals/GitHubWaitLifecycleService.ts`
`packages/shared/src/types/github-wait.ts`
`packages/shared/src/types/wait-termination.ts`
`packages/api/src/domains/ball-custody/WaitTerminationStore.ts`
`packages/api/src/domains/ball-custody/RedisWaitTerminationStore.ts`
`packages/api/src/domains/ball-custody/WaitTerminationService.ts`
`packages/api/src/domains/ball-custody/wait-termination-keys.ts`
`packages/api/src/routes/wait-termination-routes.ts`
`packages/shared/src/types/ball-custody.ts`
`packages/api/src/domains/ball-custody/BallCustodyEventLog.ts`
`packages/api/src/domains/ball-custody/BallCustodyProjector.ts`
`packages/api/src/domains/ball-custody/ball-custody-state-machine.ts`
`packages/api/src/domains/ball-custody/BallCustodyProjectionStore.ts`
`packages/api/src/domains/ball-custody/ball-custody-keys.ts`
`packages/api/src/domains/ball-custody/BallCustodyIngest.ts`
`packages/api/src/domains/ball-custody/CoordinationTerminalRetirement.ts`
`packages/api/src/domains/ball-custody/ball-custody-events.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessAttentionEventLog.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessInvocationStateStore.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessNoticeService.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessReinvokeDecider.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessClosureStateMachine.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessClosureStore.ts`
`packages/api/src/domains/cats/services/freshness/RedisFreshnessClosureStore.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessClosureLegacyMigrationState.ts`
`packages/api/src/domains/cats/services/freshness/freshness-closure-store-types.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessOutputCommitCoordinator.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStateMachine.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessDraftCustody.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts`
`packages/api/src/domains/cats/services/freshness/checkStreamOutputFreshness.ts`
`packages/api/src/domains/cats/services/stores/ports/DeliveryCursorStore.ts`
`packages/api/src/domains/cats/services/stores/message-delivery-boundary.ts`
`packages/api/src/domains/cats/services/agents/routing/delivery-boundary-recovery.ts` | -| [`bubble-pipeline`](cells/bubble-pipeline.md) | Bubble Pipeline | Provider-normalized semantic event 到用户可见 bubble 的单一投影边界;覆盖气泡 identity、reducer single-writer、structured payload fail-closed、live/hydration convergence、诊断、cache invalidation、typed execution/freshness projection,以及 F264 原消息上的 durable per-target receipt 与整条 lineage 导航。 | `F177`, `F183`, `F254`, `F264` | `packages/api/src/domains/cats/services/types.ts`
`packages/shared/src/types/bubble-pipeline.ts`
`packages/shared/src/types/turn-execution.ts`
`packages/web/src/stores/bubble-reducer.ts`
`packages/web/src/stores/chatStore.ts`
`packages/web/src/hooks/useAgentMessages.ts`
`packages/web/src/hooks/bubble-event-adapter.ts`
`packages/web/src/hooks/system-info-visible.ts`
`packages/web/src/hooks/useChatHistory.ts`
`packages/web/src/debug/bubbleIdentity.ts`
`packages/web/src/debug/bubbleInvariantDiagnostics.ts`
`packages/web/src/hooks/useSocket.ts`
`packages/web/src/components/ChatMessage.tsx`
`packages/web/src/components/MessageReceiptDock.tsx`
`packages/web/src/components/ConnectorBubble.tsx` | +| [`ball-custody`](cells/ball-custody.md) | Ball Custody Engine | 球权与显式等待责任边界:BallCustodyProjection、F254 closure/supplement、F167 action-successor 单账本与 hold rescue authorization,以及 F280 typed AwaitState lifecycle / owner fence / expiry / one-shot consume / canonical user-cancel termination event / immutable continuation carrier。普通 A2A 与 managed-hold 消息投递归 F117 source/Queue/response lifecycle,不再由 Ball disposition 或 Queue receipt 裁决。责任对象各自持久化 origin/frontier/holder/generation/final;attention、carrier success、feedback why 与 source collection 都不裁决 action success 或 wake。 | `F167`, `F233`, `F254`, `F280` | `packages/shared/src/types/action-successor.ts`
`packages/api/src/domains/ball-custody/action-successor-state-machine.ts`
`packages/api/src/domains/ball-custody/action-successor-outcome-state-machine.ts`
`packages/api/src/domains/ball-custody/action-successor-return-state-machine.ts`
`packages/api/src/domains/ball-custody/action-successor-redis-scripts.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorLeaseStore.ts`
`packages/api/src/domains/ball-custody/RedisActionSuccessorLeaseStore.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorAdmissionService.ts`
`packages/api/src/domains/ball-custody/reconcile-action-successor-enqueue.ts`
`packages/api/src/domains/ball-custody/ActionSubjectTruthResolver.ts`
`packages/api/src/domains/ball-custody/ActionTerminalPredicateCatalog.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorCompletionService.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorRecoverySweep.ts`
`packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts`
`packages/api/src/domains/ball-custody/managed-command-wake-lifecycle.ts`
`packages/api/src/domains/ball-custody/hold-ball-access-policy.ts`
`packages/api/src/routes/callback-hold-ball-cancel-routes.ts`
`packages/api/src/domains/ball-custody/TurnCustodyProjectionService.ts`
`packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts`
`packages/api/src/domains/ball-custody/wait-state-machine.ts`
`packages/api/src/domains/ball-custody/wait-continuation-carrier.ts`
`packages/api/src/domains/ball-custody/TypedWaitRegistration.ts`
`packages/api/src/domains/ball-custody/TypedWaitContinuation.ts`
`packages/api/src/routes/callback-typed-wait-source.ts`
`packages/api/src/domains/github-signals/GitHubWaitLifecycleService.ts`
`packages/shared/src/types/github-wait.ts`
`packages/shared/src/types/wait-termination.ts`
`packages/api/src/domains/ball-custody/WaitTerminationStore.ts`
`packages/api/src/domains/ball-custody/RedisWaitTerminationStore.ts`
`packages/api/src/domains/ball-custody/WaitTerminationService.ts`
`packages/api/src/domains/ball-custody/wait-termination-keys.ts`
`packages/api/src/routes/wait-termination-routes.ts`
`packages/shared/src/types/ball-custody.ts`
`packages/api/src/domains/ball-custody/BallCustodyEventLog.ts`
`packages/api/src/domains/ball-custody/BallCustodyProjector.ts`
`packages/api/src/domains/ball-custody/ball-custody-state-machine.ts`
`packages/api/src/domains/ball-custody/BallCustodyProjectionStore.ts`
`packages/api/src/domains/ball-custody/ball-custody-keys.ts`
`packages/api/src/domains/ball-custody/BallCustodyIngest.ts`
`packages/api/src/domains/ball-custody/ball-custody-events.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessAttentionEventLog.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessInvocationStateStore.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessNoticeService.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessReinvokeDecider.ts`
`packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStateMachine.ts`
`packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStore.ts`
`packages/api/src/domains/cats/services/freshness/closure/RedisFreshnessClosureStore.ts`
`packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureLegacyMigrationState.ts`
`packages/api/src/domains/cats/services/freshness/closure/freshness-closure-store-types.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessOutputCommitCoordinator.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStateMachine.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessDraftCustody.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts`
`packages/api/src/domains/cats/services/freshness/checkStreamOutputFreshness.ts`
`packages/api/src/domains/cats/services/stores/ports/DeliveryCursorStore.ts`
`packages/api/src/domains/cats/services/stores/message-delivery-boundary.ts`
`packages/api/src/domains/cats/services/agents/routing/delivery-boundary-recovery.ts` | +| [`bubble-pipeline`](cells/bubble-pipeline.md) | Bubble Pipeline | Provider-normalized semantic event 到用户可见 bubble 的单一投影边界;覆盖气泡 identity、reducer single-writer、live/hydration convergence、typed execution/freshness projection,以及 MessageStore dispatchRefs 驱动的实际投递头像与响应终态。 | `F177`, `F183`, `F254`, `F264` | `packages/api/src/domains/cats/services/types.ts`
`packages/shared/src/types/bubble-pipeline.ts`
`packages/shared/src/types/turn-execution.ts`
`packages/api/src/domains/cats/services/stores/ports/MessageStore.ts`
`packages/web/src/stores/bubble-reducer.ts`
`packages/web/src/stores/chatStore.ts`
`packages/web/src/hooks/useAgentMessages.ts`
`packages/web/src/hooks/bubble-event-adapter.ts`
`packages/web/src/hooks/system-info-visible.ts`
`packages/web/src/hooks/useChatHistory.ts`
`packages/web/src/debug/bubbleIdentity.ts`
`packages/web/src/debug/bubbleInvariantDiagnostics.ts`
`packages/web/src/hooks/useSocket.ts`
`packages/web/src/components/ChatMessage.tsx`
`packages/web/src/components/MessageDispatchAvatars.tsx`
`packages/web/src/components/ConnectorBubble.tsx` | | [`callback-auth`](cells/callback-auth.md) | Callback Auth | Invocation credential、persistent agent-key、callback principal 验证、refresh、telemetry 与现场 auth failure 可见性。 | `F174`, `F178` | `packages/api/src/domains/cats/services/agents/invocation/InvocationRegistry.ts`
`packages/api/src/domains/cats/services/agents/invocation/RedisAuthInvocationBackend.ts`
`packages/api/src/domains/cats/services/agents/invocation/MemoryAuthInvocationBackend.ts`
`packages/api/src/routes/callback-auth-prehandler.ts`
`packages/api/src/routes/callback-auth-schema.ts`
`packages/api/src/routes/callback-auth-telemetry.ts`
`packages/api/src/routes/callback-auth-system-message.ts`
`packages/api/src/domains/cats/services/agents/agent-key/AgentKeyRegistry.ts`
`packages/api/src/domains/cats/services/agents/agent-key/AgentKeySidecarProvisioner.ts`
`packages/api/src/domains/cats/services/agents/agent-key/AgentKeySidecarRenewalLoop.ts`
`packages/shared/src/types/callback-auth-reasons.ts` | | [`capability-evolution-control`](cells/capability-evolution-control.md) | Capability Evolution Control | F311-owned thin, durable, production-only control plane for Evolution Program identity/lifecycle, refs-only preparation submissions, certificate and owner references, stage/schedule projections, and keep/tune/rollback/sunset/no-change orchestration; named domain owners retain all domain truth. | `F311` | `packages/shared/src/types/capability-evolution.ts`
`packages/shared/src/types/capability-evolution-preparation.ts`
`packages/shared/src/types/capability-evolution-metabolism.ts`
`packages/api/src/infrastructure/capability-evolution/program-event-log.ts`
`packages/api/src/infrastructure/capability-evolution/program-event-appender.ts`
`packages/api/src/infrastructure/capability-evolution/program-service.ts`
`packages/api/src/infrastructure/capability-evolution/program-preparation-service.ts`
`packages/api/src/infrastructure/capability-evolution/program-preparation-projection.ts`
`packages/api/src/infrastructure/capability-evolution/program-owner-surface-resolvers.ts`
`packages/api/src/infrastructure/capability-evolution/change/program-change-bridge.ts`
`packages/api/src/infrastructure/capability-evolution/change/program-change-owner-contract.ts`
`packages/api/src/infrastructure/capability-evolution/change/program-change-owner-event.ts`
`packages/api/src/infrastructure/capability-evolution/change/program-change-snapshot.ts`
`packages/api/src/infrastructure/capability-evolution/change/program-lineage.ts`
`packages/api/src/infrastructure/capability-evolution/adapters/request-review/request-review-owner-adapter.ts`
`packages/api/src/infrastructure/capability-evolution/adapters/request-review/request-review-owner-ledger.ts`
`packages/api/src/infrastructure/capability-evolution/adapters/request-review/request-review-owner-ledger-contract.ts`
`packages/api/src/infrastructure/capability-evolution/adapters/request-review/request-review-owner-port.ts`
`packages/api/src/infrastructure/capability-evolution/adapters/request-review/request-review-owner-projection.ts`
`packages/api/src/infrastructure/capability-evolution/adapters/request-review/request-review-use-receipt.ts`
`packages/api/src/infrastructure/capability-evolution/adapters/request-review/request-review-version-attestor.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-canonical-dispatcher.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-decision-owner.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-eval-repair-owner-provider.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-eval-repair-owner-binding.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-lineage-binding-resolver.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-owner-actions.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-owner-fact-authority.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-owner-receipt-support.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-owner-receipts.ts`
`packages/api/src/infrastructure/capability-evolution/change/request-review-owner-version-verifier.ts`
`packages/api/src/routes/callback-request-review-owner-routes.ts`
`packages/api/src/routes/callback-skill-consumption-routes.ts`
`packages/api/src/routes/capability-evolution-program-routes.ts`
`packages/api/src/routes/capability-evolution-program-preparation-routes.ts`
`packages/api/src/routes/capability-evolution-program-change-handler.ts`
`packages/mcp-server/src/tools/capability-evolution-tools.ts`
`packages/mcp-server/src/tools/capability-evolution-preparation-tools.ts`
`packages/mcp-server/src/tools/capability-evolution-round-tools.ts`
`packages/mcp-server/src/tools/capability-evolution-change-tools.ts`
`packages/mcp-server/src/tools/request-review-owner-fact-schema.ts`
`packages/mcp-server/src/tools/skill-consumption-tools.ts`
`packages/web/src/components/capability-evolution/EvolutionProgramSurface.tsx`
`packages/web/src/components/capability-evolution/preparation/EvolutionPreparationWorkspace.tsx`
`packages/web/src/components/capability-evolution/EvolutionChangePanel.tsx` | | [`cat-life-settings`](cells/cat-life-settings.md) | Cat Life Settings and Diary Feedback | F255-owned per-owner/per-cat life configuration, stable Present Loop projection, diary reading/engagement APIs, and adjacent `/starry` home surface; F139 executes projections, F258 only hosts the doorway, and F229 only previews/asks for confirmation. | `F255` | `packages/shared/src/types/auto-dream.ts`
`packages/api/src/domains/auto-dream/CatLifeSettingsService.ts`
`packages/api/src/domains/auto-dream/cat-life-operations.ts`
`packages/api/src/domains/auto-dream/cat-life-schedule.ts`
`packages/api/src/domains/auto-dream/engagement-operations.ts`
`packages/api/src/routes/auto-dream.ts`
`packages/api/src/routes/callback-auto-dream-routes.ts`
`packages/mcp-server/src/tools/auto-dream-tools.ts`
`packages/web/src/components/visible-cafe/cat-home/` | | [`code-intelligence`](cells/code-intelligence.md) | Code Intelligence / Convention Graph | Worktree-local code convention graph engine, domain extractor plugins, provenance/freshness metadata, and repo-specific convention discovery skills. | `F242` | `packages/convention-graph/src/engine.ts`
`packages/convention-graph/src/plugin.ts`
`packages/convention-graph/src/queries.ts`
`packages/convention-graph/src/extractors/mcp-tool.ts`
`packages/convention-graph/src/extractors/skill-manifest.ts`
`packages/convention-graph/src/extractors/fastapi-route.ts`
`cat-cafe-skills/convention-graph-discovery/SKILL.md` | -| [`collaborative-content-plane`](cells/collaborative-content-plane.md) | Collaborative Content Plane | Versioned cross-media anchors, owner-authorized human-edit awareness, replayable receipts, race-safe Agent patch settlement, honest remap/orphan semantics, and adapter receipts without owning canonical content or policy. | `F309` | `packages/api/src/domains/collaborative-content/runtime-composition.ts`
`packages/api/src/domains/collaborative-content/editor-session-service.ts`
`packages/api/src/domains/collaborative-content/patch-service.ts`
`packages/api/src/domains/collaborative-content/semantic-operation-store.ts`
`packages/api/src/domains/collaborative-content/named-cat-content-service.ts`
`packages/api/src/routes/callback-content-editor-routes.ts`
`packages/mcp-server/src/tools/content-editor-tools.ts`
`packages/web/src/components/workbench/content-editor/ContentEditorOwnerSurface.tsx` | +| [`collaborative-content-plane`](cells/collaborative-content-plane.md) | Collaborative Content Plane | Versioned cross-media anchors, owner-authorized human-edit awareness, replayable receipts, race-safe Agent patch settlement, honest remap/orphan semantics, and adapter receipts without owning canonical content or policy. | `F309` | `packages/api/src/domains/collaborative-content/runtime-composition.ts`
`packages/api/src/domains/collaborative-content/workspace-editor-service.ts`
`packages/api/src/domains/collaborative-content/editor-session-service.ts`
`packages/api/src/domains/collaborative-content/patch-service.ts`
`packages/api/src/domains/collaborative-content/semantic-operation-store.ts`
`packages/api/src/domains/collaborative-content/named-cat-content-service.ts`
`packages/api/src/routes/callback-content-editor-routes.ts`
`packages/api/src/domains/video-studio/content-owner/service.ts`
`packages/mcp-server/src/tools/content-editor-tools.ts`
`packages/web/src/components/workbench/content-editor/ContentEditorOwnerSurface.tsx` | | [`collective-runtime`](cells/collective-runtime.md) | Collective Runtime | Independent Collective Service identity, Human auth binding and membership, endpoint pairing, canonical client, durable ordered Channel events, Host ingress, replay/ACK and verifiable Clowder AI Agent provenance boundary. | `F290` | `packages/shared/src/types/collective.ts`
`packages/collective-service/src/store.ts`
`packages/collective-service/src/identity-store.ts`
`packages/collective-service/src/github-human-auth-provider.ts`
`packages/collective-service/src/http-server.ts`
`packages/collective-service/src/http-router.ts`
`packages/collective-client/src/CollectiveClient.tsx`
`packages/collective-connector/src/connector.ts`
`packages/collective-connector/src/persistence.ts`
`packages/api/src/domains/plugin/builtin-runtime/collective-connector-runtime.ts`
`packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts`
`packages/api/src/domains/plugin/builtin-runtime/collective-agent-verifier.ts`
`packages/api/src/routes/collective-connector-routes.ts`
`packages/web/src/components/collective/CollectiveLaunchSurface.tsx` | | [`community-ops`](cells/community-ops.md) | Community Ops Engine | 社区事件 Log(append-only canonical)、CommunityObject 投影/状态机、closure invariant、activity-signal 双游标交付,以及 external case current-HEAD review/delivery/custody-return workflow;GitHub wait predicate 由 F280 github-signals 提供。 | `F168` | `packages/shared/src/types/community-event.ts`
`packages/api/src/domains/community/CommunityEventLog.ts`
`packages/api/src/domains/community/community-projector.ts`
`packages/api/src/domains/community/external-review-aggregate.ts`
`packages/api/src/domains/community/external-review-projector.ts`
`packages/api/src/domains/community/community-state-machine.ts`
`packages/api/src/domains/community/CommunityObjectStore.ts`
`packages/api/src/domains/community/community-bootstrap.ts`
`packages/api/src/domains/community/community-keys.ts`
`packages/api/src/domains/community/community-delivery-policy.ts`
`packages/api/src/domains/community/community-auto-tracking.ts`
`packages/api/src/domains/community/CommunityRepoConfigStore.ts`
`packages/api/src/routes/community-repo-config.ts`
`packages/shared/src/types/community-role.ts`
`packages/api/src/domains/community/RoleResolver.ts`
`packages/api/src/domains/community/community-closure-checklist.ts`
`packages/api/src/domains/community/CommunityReconciler.ts`
`packages/api/src/domains/community/CommunityReconciliationFindingStore.ts`
`packages/api/src/infrastructure/connectors/github-repo-event/CommunityReconcilerTaskSpec.ts`
`packages/api/src/domains/community/community-decision-queue.ts`
`packages/api/src/routes/community-decision-queue.ts`
`packages/web/src/components/community/ClosureChecklistCard.tsx`
`packages/web/src/components/community/ReconciliationFindingCard.tsx`
`packages/web/src/components/community/DecisionQueuePanel.tsx`
`packages/web/src/components/community/DecisionQueueItem.tsx` | | [`concierge-surface`](cells/concierge-surface.md) | Concierge Surface | Always-on receptionist shell and domain extension (猫猫球 / Cat Ball) — per-user concierge lifecycle, configurable duty-cat, anchor-first岗位 protocol, triage, pet behavior and escalation handoff; conversation plumbing belongs to thread-chat-surface. | `F229` | `packages/shared/src/types/concierge.ts`
`packages/api/src/domains/concierge/ConciergeConfigStore.ts`
`packages/api/src/domains/concierge/ConciergeThreadService.ts`
`packages/api/src/domains/concierge/ConciergePromptSection.ts`
`packages/api/src/domains/concierge/concierge-keys.ts`
`packages/api/src/routes/concierge.ts`
`packages/web/src/components/concierge/ConciergeHost.tsx`
`packages/web/src/components/concierge/ConciergePanel.tsx`
`packages/web/src/stores/conciergeStore.ts` | -| [`dispatch`](cells/dispatch.md) | Dispatch / Queue | Invocation queue、busy gate、fairness、priority、外部 wake 执行、durable per-child execution ledger、普通 queued user message 的 MessageStore-backed restart custody、append-only per-target attempt 与 F264 durable receipt、F254 legacy closure preflight / non-Queue supplement carrier、F167 action successor generation fence、F247 cloud-only terminal bridge、F280 wait continuation carrier,以及 F295 live/managed-command 的统一可取消 execution 投影。 | `F167`, `F175`, `F177`, `F185`, `F247`, `F254`, `F264`, `F280`, `F295` | `packages/shared/src/types/active-execution.ts`
`packages/shared/src/types/turn-execution.ts`
`packages/api/src/domains/cats/services/stores/ports/TurnExecutionStore.ts`
`packages/api/src/domains/cats/services/stores/redis/RedisTurnExecutionStore.ts`
`packages/api/src/domains/cats/services/agents/invocation/TurnExecutionStartupReconciler.ts`
`packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts`
`packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts`
`packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts`
`packages/api/src/domains/cats/services/agents/invocation/QueueCarrierSourceProjection.ts`
`packages/api/src/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.ts`
`packages/api/src/domains/cats/services/agents/invocation/queue-terminal-consumption.ts`
`packages/api/src/domains/cats/services/stores/ports/InvocationRecordStore.ts`
`packages/api/src/domains/cats/services/stores/redis/RedisInvocationRecordStore.ts`
`packages/api/src/domains/cats/services/agents/invocation/queue-entry-settlement.ts`
`packages/api/src/domains/cats/services/agents/invocation/CollaborationContinuityCapsule.ts`
`packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorRecoverySweep.ts`
`packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts`
`packages/api/src/domains/ball-custody/wait-continuation-carrier.ts`
`packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.ts`
`packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.ts`
`packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts`
`packages/api/src/domains/cats/services/stores/ports/queued-message-custody.ts`
`packages/api/src/domains/cats/services/stores/ports/queued-message-receipt.ts`
`packages/shared/src/types/queue-receipt.ts`
`packages/api/src/utils/queue-enrichment.ts`
`packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts`
`packages/api/src/routes/messages.ts`
`packages/api/src/routes/invocations.ts`
`packages/api/src/routes/queue.ts`
`packages/api/src/routes/active-execution-routes.ts`
`packages/api/src/domains/cats/services/session/thread-access-policy.ts`
`packages/web/src/stores/activeExecutionStore.ts`
`packages/web/src/hooks/useActiveExecutionProjection.ts`
`packages/web/src/components/ThreadExecutionBar.tsx`
`packages/web/src/components/ThinkingIndicator.tsx`
`packages/web/src/components/workspace/WorkspaceNowSurface.tsx`
`packages/api/src/domains/cats/services/freshness/FreshnessClosureStore.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessClosurePreflight.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts`
`packages/api/src/routes/callback-a2a-trigger.ts`
`packages/api/src/routes/callback-multi-mention-routes.ts`
`packages/api/src/routes/callbacks.ts` | +| [`dispatch`](cells/dispatch.md) | Dispatch / Queue | 按 thread 持久化的 source Queue Entry、未投递 targets 集合、有序 active index、短时 claim、原子 admission/target 更新、逐成员 Steer、外部 wake 执行,以及 durable child/execution 投影。 | `F047`, `F117`, `F167`, `F175`, `F177`, `F185`, `F247`, `F254`, `F264`, `F280`, `F295` | `packages/shared/src/types/active-execution.ts`
`packages/shared/src/types/turn-execution.ts`
`packages/api/src/domains/cats/services/stores/ports/TurnExecutionStore.ts`
`packages/api/src/domains/cats/services/stores/redis/RedisTurnExecutionStore.ts`
`packages/api/src/domains/cats/services/agents/invocation/TurnExecutionStartupReconciler.ts`
`packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts`
`packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts`
`packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts`
`packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts`
`packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts`
`packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts`
`packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.ts`
`packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.ts`
`packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-scripts.ts`
`packages/api/src/domains/cats/services/stores/ports/InvocationRecordStore.ts`
`packages/api/src/domains/cats/services/stores/redis/RedisInvocationRecordStore.ts`
`packages/api/src/domains/cats/services/agents/invocation/CollaborationContinuityCapsule.ts`
`packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts`
`packages/api/src/domains/ball-custody/ActionSuccessorRecoverySweep.ts`
`packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts`
`packages/api/src/domains/ball-custody/wait-continuation-carrier.ts`
`packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts`
`packages/api/src/domains/cats/services/stores/ports/MessageStore.ts`
`packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts`
`packages/shared/src/types/queue-receipt.ts`
`packages/api/src/utils/queue-enrichment.ts`
`packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts`
`packages/api/src/routes/messages.ts`
`packages/api/src/routes/invocations.ts`
`packages/api/src/routes/queue.ts`
`packages/api/src/routes/active-execution-routes.ts`
`packages/api/src/domains/cats/services/session/thread-access-policy.ts`
`packages/web/src/stores/activeExecutionStore.ts`
`packages/web/src/hooks/useActiveExecutionProjection.ts`
`packages/web/src/hooks/useLiveExecutionCancelControl.ts`
`packages/web/src/components/ThreadExecutionBar.tsx`
`packages/web/src/components/MessageDispatchAvatars.tsx`
`packages/web/src/components/workspace/WorkspaceNowSurface.tsx`
`packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStore.ts`
`packages/api/src/domains/cats/services/freshness/closure/FreshnessClosurePreflight.ts`
`packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts`
`packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts`
`packages/api/src/routes/callback-a2a-trigger.ts`
`packages/api/src/routes/callback-multi-mention-routes.ts`
`packages/api/src/routes/callbacks.ts` | | [`github-signals`](cells/github-signals.md) | GitHub Work Item Signals | GitHub PR/issue 外部事实采集、source frontier、snapshot、typed wait predicate resolver 与 matched-delta renderer;采集事实不直接裁决责任或投递。 | `F133`, `F140`, `F280` | `packages/shared/src/types/task.ts`
`packages/api/src/infrastructure/github/comment-cursors.ts`
`packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts`
`packages/api/src/infrastructure/email/ReviewFeedbackRouter.ts`
`packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts`
`packages/api/src/infrastructure/email/CiCdRouter.ts`
`packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts`
`packages/api/src/infrastructure/email/ConflictRouter.ts`
`packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts`
`packages/api/src/infrastructure/email/IssueCommentRouter.ts`
`packages/api/src/domains/plugin/github-schedule-factories.ts` | | [`harness-eval`](cells/harness-eval.md) | Harness Eval Control Plane | Harness contract、runtime eval、measurement validity/owner-backed issuance、verdict handoff、domain registry、durable verdict lifecycle、F313 immutable finding/repair-target artifacts,以及 F278 每条爪感差的 duty/issue 双轴、source-exact owner-backed direct-repair、可恢复 blocker poller 与 refs-only legacy census。 | `F192`, `F266`, `F267`, `F278`, `F313` | `packages/api/src/infrastructure/harness-eval/f167-eval.ts`
`packages/api/src/infrastructure/harness-eval/cross-thread-coordination-eval.ts`
`packages/api/src/infrastructure/harness-eval/attribution.ts`
`packages/api/src/infrastructure/harness-eval/domain/eval-domain-registry.ts`
`packages/api/src/infrastructure/harness-eval/verdict-handoff.ts`
`packages/api/src/infrastructure/harness-eval/publish-verdict/lifecycle-root-artifact.ts`
`packages/api/src/infrastructure/harness-eval/eval-cat-invocation.ts`
`packages/api/src/infrastructure/harness-eval/legacy-task-cleanup.ts`
`packages/api/src/infrastructure/harness-eval/reeval-closure.ts`
`packages/api/src/infrastructure/harness-eval/reeval-closure-schema.ts`
`packages/api/src/infrastructure/harness-eval/reeval-closure-event-log.ts`
`packages/api/src/infrastructure/harness-eval/reeval-closure-service.ts`
`packages/api/src/infrastructure/harness-eval/reeval-closure-reconciler.ts`
`packages/api/src/infrastructure/harness-eval/reeval-closure-task-spec.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-approval-contracts.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-approval-projection.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-approval.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-case-action-resolver.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-cutover.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-reconciliation.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-outcome.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-fresh-outcome.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-evolution-owner-port.ts`
`packages/api/src/infrastructure/harness-eval/eval-repair-owner-runtime.ts`
`packages/api/src/infrastructure/capability-evolution/change/f311-e0-eval-repair-owner-binding.ts`
`packages/api/src/infrastructure/capability-evolution/change/f311-e0-eval-repair-owner-provider.ts`
`packages/api/src/infrastructure/capability-evolution/change/f311-e0-eval-repair-owner-runtime-registration.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case-cycle-order.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case-types.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case-guards.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case-root.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case-service.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case-responsibility.ts`
`packages/api/src/infrastructure/harness-eval/reeval-case-reevaluation.ts`
`packages/api/src/infrastructure/harness-eval/legacy-reeval-case-migration.ts`
`packages/api/src/infrastructure/harness-eval/eval-release-truth-resolver.ts`
`packages/api/src/infrastructure/harness-eval/freshness/freshness-replay-types.ts`
`packages/api/src/infrastructure/harness-eval/freshness/freshness-replay-fixtures.ts`
`packages/api/src/infrastructure/harness-eval/freshness/freshness-replay-provider.ts`
`packages/api/src/infrastructure/harness-eval/freshness/eval-freshness-live-verdict.ts`
`packages/api/src/infrastructure/harness-eval/freshness/freshness-eval-cat-instructions.ts`
`packages/api/src/infrastructure/harness-eval/publish-verdict/freshness-generator-adapter.ts`
`packages/api/src/infrastructure/harness-eval/publish-verdict/source-ref-handler-validation.ts`
`packages/api/src/infrastructure/harness-eval/a2a/eval-a2a-adapter.ts`
`packages/api/src/infrastructure/harness-eval/hub/eval-hub-read-model.ts`
`packages/api/src/infrastructure/harness-eval/hub/eval-hub-lifecycle-projection.ts`
`packages/api/src/infrastructure/harness-eval/hub/eval-hub-lifecycle-debt.ts`
`packages/api/src/infrastructure/harness-eval/hub/eval-hub-operator-narrative.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-signal-source.ts`
`packages/api/src/infrastructure/harness-eval/friction/paw-feel-marker.ts`
`packages/api/src/infrastructure/harness-eval/friction/paw-feel-adapter.ts`
`packages/api/src/infrastructure/harness-eval/friction/cancel-adapter.ts`
`packages/api/src/infrastructure/harness-eval/friction/user-feedback-adapter.ts`
`packages/api/src/infrastructure/harness-eval/friction/eval-domain-adapter.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-aggregator.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-clusterer.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-rollup-input.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-rollup-report.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-measurement-pilot.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-measurement-report.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-finding-artifact.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-finding-child-artifact.ts`
`packages/api/src/infrastructure/harness-eval/friction/friction-repair-target-resolver.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/read-model.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/service.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/blocker-recovery/service-blocker-reopen.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/continuation/follow-up-resolver.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/direct-repair/direct-repair-federation.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/continuation/source-case-action-resolver.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/blocker-recovery/blocker-reconciler.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/signal-scan.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/blocker-recovery/legacy-blocker-census.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/blocker-recovery/legacy-blocker-recovery.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/providers/memory-cue-owner-provider.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/providers/memory-cue-git-truth.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/providers/task-workflow-owner-provider.ts`
`packages/api/src/infrastructure/harness-eval/paw-feel-disposition/providers/task-workflow-git-truth.ts`
`packages/api/src/infrastructure/harness-eval/measurement/measurement-bundle-schema.ts`
`packages/api/src/infrastructure/harness-eval/measurement/measurement-bundle-validation.ts`
`packages/api/src/infrastructure/harness-eval/measurement/measurement-bundle-census.ts`
`packages/api/src/infrastructure/harness-eval/measurement/friction-measurement-bundle.ts`
`packages/api/src/infrastructure/harness-eval/measurement/measurement-replay.ts`
`packages/api/src/infrastructure/harness-eval/measurement/capability-evolution/capability-evolution-measurement-source.ts`
`packages/api/src/infrastructure/harness-eval/measurement/capability-evolution/capability-evolution-measurement-source-validation.ts`
`packages/api/src/infrastructure/harness-eval/measurement/capability-evolution/capability-evolution-measurement-source-store.ts`
`packages/api/src/infrastructure/harness-eval/measurement/capability-evolution/capability-evolution-measurement-issuer.ts`
`packages/api/src/infrastructure/harness-eval/measurement/measurement-artifact-files.ts`
`packages/api/src/infrastructure/harness-eval/measurement/measurement-decision-proof-owner-object-spec.ts`
`packages/api/src/infrastructure/harness-eval/measurement/measurement-decision-proof-resolver.ts`
`packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts`
`scripts/check-verdict-publish-contract.mjs`
`scripts/guarded-bin/gh`
`packages/shared/src/types/friction-signal.ts`
`packages/shared/src/types/paw-feel-continuation.ts`
`packages/api/src/routes/eval-hub.ts`
`packages/api/src/routes/eval-verdict-lifecycle.ts`
`packages/api/src/routes/eval-repair-outcome-routes.ts`
`packages/api/src/routes/paw-feel-legacy-census.ts`
`packages/api/src/routes/capability-evolution-measurement-issuance-route.ts`
`packages/api/src/routes/feature-thread-resolver.ts`
`packages/mcp-server/src/tools/eval-lifecycle-tools.ts`
`packages/mcp-server/src/tools/capability-evolution-tools.ts`
`packages/mcp-server/src/tools/paw-feel-disposition-tools.ts`
`packages/web/src/components/HubEvalTab.tsx`
`packages/web/src/components/HubEvalLifecycleSummary.tsx`
`packages/web/src/components/eval-workspace/EvalWorkspaceEventCard.tsx`
`packages/web/src/components/eval-workspace/PawFeelInboxRow.tsx`
`packages/web/src/components/paw-feel/PawFeelDispositionDock.tsx`
`sop-definitions/development.yaml`
`sop-definitions/stubs/video-cocreation.yaml`
`sop-definitions/stubs/tech-article.yaml`
`sop-definitions/stubs/family-office.yaml`
`scripts/sop-definitions.mjs`
`scripts/lib/sop-definition-codegen.mjs`
`packages/shared/src/types/sop-definition.generated.ts` | | [`hub-action-surface`](cells/hub-action-surface.md) | Hub Action Surface | First-party Hub user-visible actions that let cats surface files, previews, rich blocks, workflow state, and other in-context artifacts to the user. | `F022`, `F063`, `F096`, `F120`, `F131`, `F223`, `F232`, `F284`, `F290`, `F299`, `F307`, `F310` | `packages/shared/src/types/context-attachment.ts`
`packages/shared/src/schemas/context-attachment.schema.ts`
`packages/api/src/routes/messages.schema.ts`
`packages/api/src/domains/cats/services/agents/routing/context-attachment-prompt.ts`
`packages/web/src/components/ContextAttachmentView.tsx`
`packages/web/src/hooks/useTextSelectionAction.ts`
`packages/api/src/routes/workspace.ts`
`packages/api/src/routes/callback-skill-consumption-routes.ts`
`packages/api/src/domains/cats/services/tool-usage/SkillConsumptionReceiptService.ts`
`packages/web/src/hooks/useWorkspaceNavigate.ts`
`packages/api/src/routes/preview.ts`
`packages/web/src/components/__tests__/preview-auto-open-store.test.ts`
`packages/web/src/components/__tests__/workspace-navigate-store.test.ts`
`packages/mcp-server/src/tools/hub-action-tools.ts`
`packages/mcp-server/src/tools/skill-consumption-tools.ts`
`packages/mcp-server/src/tools/callback-tools.ts`
`packages/mcp-server/src/tools/rich-block-rules-tool.ts`
`packages/api/src/domains/cats/services/agents/routing/thread-artifacts-aggregator.ts`
`packages/web/src/components/ArtifactsPanel.tsx`
`packages/web/src/components/rich/RuntimeInteractionCard.tsx`
`packages/api/src/routes/runtime-interaction-routes.ts` | diff --git a/docs/architecture/ownership/cells/ball-custody.md b/docs/architecture/ownership/cells/ball-custody.md index d8c103c870..72890d3482 100644 --- a/docs/architecture/ownership/cells/ball-custody.md +++ b/docs/architecture/ownership/cells/ball-custody.md @@ -1,7 +1,7 @@ --- cell_id: ball-custody title: Ball Custody Engine -summary: 球权与等待责任边界:BallCustodyProjection、Queue 普通 queued message、F254 closure/supplement、F167 action-successor 单账本与 hold rescue authorization,以及 F280 typed AwaitState lifecycle / owner fence / expiry / one-shot consume / canonical user-cancel termination event / immutable continuation carrier。责任对象各自持久化 origin/frontier/holder/generation/final;attention、carrier success、feedback why 与 source collection 都不裁决 action success 或 wake。 +summary: 球权与显式等待责任边界:BallCustodyProjection、F254 closure/supplement、F167 action-successor 单账本与 hold rescue authorization,以及 F280 typed AwaitState lifecycle / owner fence / expiry / one-shot consume / canonical user-cancel termination event / immutable continuation carrier。普通 A2A 与 managed-hold 消息投递归 F117 source/Queue/response lifecycle,不再由 Ball disposition 或 Queue receipt 裁决。责任对象各自持久化 origin/frontier/holder/generation/final;attention、carrier success、feedback why 与 source collection 都不裁决 action success 或 wake。 canonical_features: [F167, F233, F254, F280] code_anchors: - packages/shared/src/types/action-successor.ts @@ -22,17 +22,11 @@ code_anchors: - packages/api/src/domains/ball-custody/hold-ball-access-policy.ts - packages/api/src/routes/callback-hold-ball-cancel-routes.ts - packages/api/src/domains/ball-custody/TurnCustodyProjectionService.ts - - packages/api/src/domains/ball-custody/ManagedHoldSourceSelection.ts - - packages/api/src/domains/ball-custody/ManagedHoldDispositionService.ts - - packages/api/src/domains/ball-custody/TurnCustodyAdoptionRegistry.ts - packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts - packages/api/src/domains/ball-custody/wait-state-machine.ts - packages/api/src/domains/ball-custody/wait-continuation-carrier.ts - - packages/api/src/domains/ball-custody/WaitContinuationRetryPreflight.ts - packages/api/src/domains/ball-custody/TypedWaitRegistration.ts - packages/api/src/domains/ball-custody/TypedWaitContinuation.ts - - packages/api/src/domains/ball-custody/TypedWaitCustodyGuard.ts - - packages/api/src/domains/cats/services/stores/redis/RedisTypedWaitCustodyGuard.ts - packages/api/src/routes/callback-typed-wait-source.ts - packages/api/src/domains/github-signals/GitHubWaitLifecycleService.ts - packages/shared/src/types/github-wait.ts @@ -49,17 +43,16 @@ code_anchors: - packages/api/src/domains/ball-custody/BallCustodyProjectionStore.ts - packages/api/src/domains/ball-custody/ball-custody-keys.ts - packages/api/src/domains/ball-custody/BallCustodyIngest.ts - - packages/api/src/domains/ball-custody/CoordinationTerminalRetirement.ts - packages/api/src/domains/ball-custody/ball-custody-events.ts - packages/api/src/domains/cats/services/freshness/FreshnessAttentionEventLog.ts - packages/api/src/domains/cats/services/freshness/FreshnessInvocationStateStore.ts - packages/api/src/domains/cats/services/freshness/FreshnessNoticeService.ts - packages/api/src/domains/cats/services/freshness/FreshnessReinvokeDecider.ts - - packages/api/src/domains/cats/services/freshness/FreshnessClosureStateMachine.ts - - packages/api/src/domains/cats/services/freshness/FreshnessClosureStore.ts - - packages/api/src/domains/cats/services/freshness/RedisFreshnessClosureStore.ts - - packages/api/src/domains/cats/services/freshness/FreshnessClosureLegacyMigrationState.ts - - packages/api/src/domains/cats/services/freshness/freshness-closure-store-types.ts + - packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStateMachine.ts + - packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStore.ts + - packages/api/src/domains/cats/services/freshness/closure/RedisFreshnessClosureStore.ts + - packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureLegacyMigrationState.ts + - packages/api/src/domains/cats/services/freshness/closure/freshness-closure-store-types.ts - packages/api/src/domains/cats/services/freshness/glass-box/FreshnessOutputCommitCoordinator.ts - packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStateMachine.ts - packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts @@ -127,13 +120,14 @@ Architecture cell: ball-custody F280 的注册 producer 在 canonical Task hash 内,与 await generation 原子安装私有 `TypedWaitRegistration`。注册入口快照既有 source/adoption,receipt 绑定当前 invocation、 精确通知及等待身份;公开 AwaitState 不含这些消费权限。带锚审阅仍经过原 coverage 验证。 -F167 primary/adopted 对各自 source 独立查证,F233/F254 的 Queue 回执在原有原子提交中 -复验 Task/receipt/generation/expiry。已经匹配、过期或换代的等待不再授予 active continuation; -已成功的历史回执可重放。私有字段随 Task 生命周期保存,不增加线程级权限布尔值或独立账本。 +F167 primary/adopted 对各自 source 独立查证;F117 Queue admission 只运输已由 Task / action owner +签发的 immutable continuation carrier,并在 provider start 前复验 task/outcome/generation/expiry。 +已经匹配、过期、换代或已投递的等待不再授予 active continuation;历史 outcome 可按同一 owner fence +幂等重放。私有字段随 Task 生命周期保存,不增加线程级权限布尔值、Queue receipt 或独立 custody guard。 ## Canonical Owner -F233 owns the ball-custody event-sourcing infrastructure: append-only Event Log as the single internal-canonical truth for ball custody(谁该对一个责任单元行动), BallCustodyProjection as a rebuildable projection, and the 7-state ball lifecycle (active/blocked/parked/dead/void/zombie/resolved,加 `new` 初始态) enforced by a pure-function state machine. F254 extends the same responsibility boundary in two layers: independent `seenCursor` / `FreshnessAttentionEventLog` provide attention evidence; ADR-041 `FreshnessClosureAggregate` remains canonical only for unfinished/legacy work, while ADR-042 `FreshnessSupplementAggregate` owns bounded additive follow-up responsibility after a completed answer is already MessageStore truth. `FreshnessRelevancePolicy` decides whether a late message creates responsibility from typed provenance only: a cat reply whose `causal.triggerMessageId` was already covered by this invocation's prompt is a same-user-wave sibling and does not create a supplement unless it explicitly targets the current cat. Independent triggers remain relevant. Time windows, prose classification and NLU do not participate. A blocked legacy closure remains active responsibility until every attached withheld invocation has an exact evidence-backed outcome; full accounting terminalizes through `legacy_migrated`, which is distinct from user `dismissed`. F167 Phase S adds a separate TTL=0 `ActionSuccessorLease` responsibility object keyed by tenant + canonical subject + action family + server-authorized slot. S.1-a keeps structured transfer and grounded existing standing on that same CAS: every structured generation persists its authenticated predecessor route; a single-holder ownership rejection increments generation and moves custody back atomically, while a parallel rejection terminates only the rejecting holder and leaves the shared generation in place. S.1-b freezes a server-owned typed terminal predicate in each generation: machine-checkable evidence first creates a non-terminal candidate, and only `ActionSubjectTruthResolver` verified truth can CAS a holder to succeeded. Production admission requires both a server verifier and an end-to-end completion producer; the registry opens `review/review_delivered` and task-backed `implement/task_done`. The task capability binds tenant, named owner and task thread from TaskStore before claim, then uses the same task's persisted `done` revision as completion evidence; boot/periodic recovery enumerates active task leases and replays the idempotent completion CAS without another ledger. Canonical `pr_merged` / `ci_passed` resolvers and reserved `test_passed` / `durable_verdict` shapes remain fail-closed until their production completion producers are wired, rather than opening a lease that can never complete. HEAD-fenced predicates accept only canonical full Git OIDs (40/64 lowercase hex). A delivered review coupled to an action lease commits the exact generation/holder/predicate completion before appending the community verdict event; already-verified same-fence retries remain idempotent if event append or projection previously failed. Provider exit 0, response text and Queue delivery remain carrier facts. A server-observed new review HEAD continues the same completed lease at generation+1, rebuilds claim origin/predecessor provenance from the incoming claim, clears prior candidate/outcomes, and leaves the canonical action key unchanged. `returnDeliveryState` only tracks carrier delivery and never adjudicates custody. S.1-c keeps managed-command terminal results and pending return delivery durable until a positive execution carrier exists, then uses boot/periodic recovery sweeps to retry idempotently. Phase T reads only the protocol ball selected by the invocation wake carrier, snapshots its transition truth, and enforces the three-state structured stop decision; it never creates custody or scans unrelated open work. Explicit A2A wake provenance selects the existing `ball:thread:*` dispatch only after its `ball.handed` transition is durably recorded for the target holder;exact managed-command wake provenance selects the existing hold subject。Generic or missing provenance remains `unknown_legacy` and fails closed. For `covered_empty + non_obligation:coordination_terminal`, Phase T may expose a bounded typed `terminal_silent` witness to the existing Queue receipt consumer. For an exact `ball.dispatch_dispositioned`, the stop decision also preserves the canonical `handled | completed` discriminator and event identity. `completed` is terminal-only; `handled` may expose one bounded `dispatch_handled_continuation` witness only after the disposition tool settled and the provider ended without substantive post-tool progress. The witness does not establish or complete owner work and does not reopen the settled carrier. Phase T does not own the durable receipt, parse empty model text, or turn a non-Queue invocation into message custody. The retired legacy comparison is telemetry only. Each responsibility object owns its lifecycle without entering the F233 `BallCustodyEvent` union. +F233 owns the ball-custody event-sourcing infrastructure: append-only Event Log as the single internal-canonical truth for ball custody(谁该对一个责任单元行动), BallCustodyProjection as a rebuildable projection, and the 7-state ball lifecycle (active/blocked/parked/dead/void/zombie/resolved,加 `new` 初始态) enforced by a pure-function state machine. F254 extends the same responsibility boundary in two layers: independent `seenCursor` / `FreshnessAttentionEventLog` provide attention evidence; ADR-041 `FreshnessClosureAggregate` remains canonical only for unfinished/legacy work, while ADR-042 `FreshnessSupplementAggregate` owns bounded additive follow-up responsibility after a completed answer is already MessageStore truth. `FreshnessRelevancePolicy` decides whether a late message creates responsibility from typed provenance only: a cat reply whose `causal.triggerMessageId` was already covered by this invocation's prompt is a same-user-wave sibling and does not create a supplement unless it explicitly targets the current cat. Independent triggers remain relevant. Time windows, prose classification and NLU do not participate. A blocked legacy closure remains active responsibility until every attached withheld invocation has an exact evidence-backed outcome; full accounting terminalizes through `legacy_migrated`, which is distinct from user `dismissed`. F167 Phase S adds a separate TTL=0 `ActionSuccessorLease` responsibility object keyed by tenant + canonical subject + action family + server-authorized slot. S.1-a keeps structured transfer and grounded existing standing on that same CAS: every structured generation persists its authenticated predecessor route; a single-holder ownership rejection increments generation and moves custody back atomically, while a parallel rejection terminates only the rejecting holder and leaves the shared generation in place. S.1-b freezes a server-owned typed terminal predicate in each generation: machine-checkable evidence first creates a non-terminal candidate, and only `ActionSubjectTruthResolver` verified truth can CAS a holder to succeeded. Production admission requires both a server verifier and an end-to-end completion producer; the registry opens `review/review_delivered` and task-backed `implement/task_done`. The task capability binds tenant, named owner and task thread from TaskStore before claim, then uses the same task's persisted `done` revision as completion evidence; boot/periodic recovery enumerates active task leases and replays the idempotent completion CAS without another ledger. Canonical `pr_merged` / `ci_passed` resolvers and reserved `test_passed` / `durable_verdict` shapes remain fail-closed until their production completion producers are wired, rather than opening a lease that can never complete. HEAD-fenced predicates accept only canonical full Git OIDs (40/64 lowercase hex). A delivered review coupled to an action lease commits the exact generation/holder/predicate completion before appending the community verdict event; already-verified same-fence retries remain idempotent if event append or projection previously failed. Provider exit 0, response text and Queue delivery remain carrier facts. A server-observed new review HEAD continues the same completed lease at generation+1, rebuilds claim origin/predecessor provenance from the incoming claim, clears prior candidate/outcomes, and leaves the canonical action key unchanged. `returnDeliveryState` only tracks carrier delivery and never adjudicates custody. S.1-c keeps managed-command terminal results and pending return delivery durable until a positive execution carrier exists, then uses boot/periodic recovery sweeps to retry idempotently. Phase T reads only the protocol ball selected by the invocation wake carrier, snapshots its transition truth, and enforces the three-state structured stop decision; it never creates custody or scans unrelated open work. Explicit A2A wake provenance selects the existing `ball:thread:*` dispatch only after its `ball.handed` transition is durably recorded for the target holder;exact managed-command wake provenance selects the existing hold subject。Generic or missing provenance remains `unknown_legacy` and fails closed. For `covered_empty + non_obligation:coordination_terminal`, Phase T may expose a bounded typed `terminal_silent` witness to Queue terminal-consumption policy without creating Queue receipt truth. For an exact `ball.dispatch_dispositioned`, the stop decision also preserves the canonical `handled | completed` discriminator and event identity. `completed` is terminal-only; `handled` may expose one bounded `dispatch_handled_continuation` witness only after the disposition tool settled and the provider ended without substantive post-tool progress. The witness does not establish or complete owner work and does not reopen the settled carrier. Phase T does not own Queue or History delivery truth, parse empty model text, or turn a non-Queue invocation into message custody. The retired legacy comparison is telemetry only. Each responsibility object owns its lifecycle without entering the F233 `BallCustodyEvent` union. F280 adds `AwaitState` as another responsibility object: the caller supplies a bounded typed any-of continuation, the containing task or action lease supplies a generation fence, source adapters supply @@ -209,9 +203,9 @@ attributable canonical provenance remain non-executable. - Freshness 事件新增时保持独立 closed union(`FreshnessAttentionEvent`),不要把它塞进 `BallCustodyEvent`;需要聚合时由 projector/read-model 读取 freshness log。 - Known-stale output 必须通过 `FreshnessClosureStore` + `FreshnessClosureStateMachine` 转移责任;queue coverage 本身不是 closure,只有携带 exact closure ID 且成功 claim 的 typed successor 才接手。独立 invocation 不得按 scope 猜 closure。 - Legacy migration 必须以全部 active closure 为根集合,再逐 attached invocation 分类。只有每项都有 exact message/evidence 或审计化 no-text 归宿时,才能通过 revision-fenced `MigrateLegacyFreshnessClosureInput` 写入 `legacy_migrated` disposition;恢复 append 必须幂等且零路由副作用。 -- Completed output 必须先成为 MessageStore truth。普通 queued user message 继续由 Queue exact ACK 生命周期单一拥有,禁止复制到 supplement;只有 non-Queue relevant unseen 才能通过 distinct `FreshnessSupplementAggregate` 追加责任,且不能复用 closure final key、删除原文或把 supplement 当 replacement。 +- Completed output 必须先成为 MessageStore truth。普通 queued user message 继续由 F117 Queue 的 pending `targets[]` 单一拥有,禁止复制到 supplement;actual dispatch / terminal 分别归 MessageStore dispatchRefs 与 exact response / structured owner。只有 non-Queue relevant unseen 才能通过 distinct `FreshnessSupplementAggregate` 追加责任,且不能复用 closure final key、删除原文或把 supplement 当 replacement。 - Late cat replies must carry typed `invocation_reply -> triggerMessageId` provenance. Compare that trigger only with exact message IDs already present in this invocation's prompt; explicit current-cat targets override sibling suppression, while independent causal roots remain relevant. -- Queue restart 只能用 immutable per-target invocation success witness 核销 exact `(messageId, catId, invocationId)` custody;aggregate parent `succeeded`、`targetCats` membership 或缺失 witness 都不是 handled 证据。缺失证据必须 fail-closed 回 `failed/queued`。 +- Queue restart 只从 schema v2 ledger 恢复 source order、pending `targets[]` 与 bounded claim;不得从 invocation success、aggregate parent、target membership 或旧 receipt 推导 Queue membership。实际 dispatch 与 terminal 只能从各自 canonical owner 恢复。 - 删除/隐藏任何 draft 前必须拿到 typed custody proof(message / exact closure / retained);blocked old lineage 不得吸收独立新 draft。 - 新 freshness cursor 复用 `DeliveryCursorStore` 的 CAS 基础设施时必须使用独立 key prefix,不能推进 `deliveryCursor`。 - Action successor 必须通过 `ActionSuccessorLeaseStore` claim/recordOutcome/returnToPredecessor/replace/preflight;structured generation 的 predecessor 只能从认证 actor/source thread 派生,existing-standing 只允许 grounded self-claim。single return 必须由该 generation 持久化的 holder cat + holder thread 发起,先以 expected generation 原子移交 custody,再由 carrier 确认 delivery;parallel rejection 只 CAS 写入 rejecting holder 的 terminal outcome,不增 generation、不唤醒整租约 predecessor。同一 immutable single dispatch 重试只 replay 已完成的 transition,delivery 失败保持 pending,不得反向改成 unavailable。 diff --git a/docs/architecture/ownership/cells/bubble-pipeline.md b/docs/architecture/ownership/cells/bubble-pipeline.md index 6a4251b360..32bea23c88 100644 --- a/docs/architecture/ownership/cells/bubble-pipeline.md +++ b/docs/architecture/ownership/cells/bubble-pipeline.md @@ -1,12 +1,13 @@ --- cell_id: bubble-pipeline title: Bubble Pipeline -summary: Provider-normalized semantic event 到用户可见 bubble 的单一投影边界;覆盖气泡 identity、reducer single-writer、structured payload fail-closed、live/hydration convergence、诊断、cache invalidation、typed execution/freshness projection,以及 F264 原消息上的 durable per-target receipt 与整条 lineage 导航。 +summary: Provider-normalized semantic event 到用户可见 bubble 的单一投影边界;覆盖气泡 identity、reducer single-writer、live/hydration convergence、typed execution/freshness projection,以及 MessageStore dispatchRefs 驱动的实际投递头像与响应终态。 canonical_features: [F177, F183, F254, F264] code_anchors: - packages/api/src/domains/cats/services/types.ts - packages/shared/src/types/bubble-pipeline.ts - packages/shared/src/types/turn-execution.ts + - packages/api/src/domains/cats/services/stores/ports/MessageStore.ts - packages/web/src/stores/bubble-reducer.ts - packages/web/src/stores/chatStore.ts - packages/web/src/hooks/useAgentMessages.ts @@ -17,9 +18,11 @@ code_anchors: - packages/web/src/debug/bubbleInvariantDiagnostics.ts - packages/web/src/hooks/useSocket.ts - packages/web/src/components/ChatMessage.tsx - - packages/web/src/components/MessageReceiptDock.tsx + - packages/web/src/components/MessageDispatchAvatars.tsx - packages/web/src/components/ConnectorBubble.tsx doc_anchors: + - docs/decisions/043-queue-durable-single-ledger.md + - docs/features/F117-message-delivery-lifecycle.md - docs/features/F306-codex-app-capability-parity.md - feature-discussions/2026-08-26-f306-provider-neutral-semantic-events/README.md - docs/features/F295-cancelable-execution-projection.md @@ -36,8 +39,12 @@ doc_anchors: - feature-specs/2026-07-31-f264-terminal-consumption-receipt.md - feature-specs/2026-08-04-f264-author-declared-message-disposition.md - feature-specs/2026-08-13-1291-gate6-live-terminal-receipt-consumption.md -static_scan_hints: [AgentMessageType, system_info, provider_signal, BubbleEvent, bubbleKind, bubbleIdentity, BubbleReducer, bubble-event-adapter, formatVisibleSystemInfo, isSystemInfoProtocolPayload, useAgentMessages, useChatHistory, useSocket, queue_updated, QueueMessageReceipt, QueueMessageReceiptProjection, messageReceipts, TurnExecutionMessageProjection, executionKind, routing_guard, freshness_supplement, auxiliaryTurnExecutions, system-routing-guard, freshness_closure, MessageReceiptDock, seenAt, handledAt, evidenceRef, lineage, closureId, supplementId, originalMessageId, sourceInvocationId, chatStore, hydration, IndexedDB] +static_scan_hints: [AgentMessageType, system_info, provider_signal, BubbleEvent, bubbleKind, bubbleIdentity, BubbleReducer, bubble-event-adapter, formatVisibleSystemInfo, useAgentMessages, useChatHistory, useSocket, queue_updated, dispatchRefs, dispatchedAt, statusMessageId, TurnExecutionMessageProjection, executionKind, routing_guard, freshness_supplement, auxiliaryTurnExecutions, MessageDispatchAvatars, lifecycleStatus, evidenceRef, lineage, originalMessageId, sourceInvocationId, chatStore, hydration, IndexedDB] cited_by: + - {feature: F117-canonical-source-entry, date: 2026-09-08, delta: the source bubble is materialized once at first actual delivery; MessageStore dispatchRefs own per-target delivery identity and time, while each exact response bubble owns processing and terminal state without Queue receipt projection} + - {feature: F264-author-intent-steer-ui, date: 2026-09-03, delta: the Steer modal offers non-interrupting send as author intent for every selectable target; admission appends to an exact accepting run or preserves the same row for ordinary drain when the carrier is unavailable or already stopped} + - {feature: F254-ADR-043-read-adoption, date: 2026-09-03, delta: full queued-body adoption links the original source dispatchRef to the already-processing response so its member avatar and processing bubble appear on the original timeline message without copying or moving the body} + - {feature: F117-ADR-043, date: 2026-09-20, delta: Queue owns pending targets without a receipt projection; first actual dispatch materializes the one source bubble, MessageStore dispatchRefs own per-target delivery identity and time, and each exact response owns processing and terminal state} - {feature: F306, date: 2026-08-26, delta: provider raw streams remain adapter-specific but converge into a provider-neutral semantic event contract; one projector registry serves live, background, hydration, callback, and replay, while unknown structured payloads fail closed instead of rendering raw JSON} - {feature: F295, date: 2026-08-13, delta: a managed-command hold bubble consumes the same execution projection and exact taskId cancel target as thread/workspace running chrome; message identity and hold lifecycle ownership remain unchanged} - {feature: F177-F254-F264-child-execution-truth, date: 2026-07-16, delta: live and F5 consume one typed child identity projection; routing guards render as system-assisted execution without copied prose, supplements remain distinct replies, and receipt timing separates body-read from terminal handling} @@ -56,47 +63,83 @@ cited_by: ## Canonical Owner -F143 provider adapters own raw wire parsing and normalize into the shared AgentService event port; this cell owns the next boundary from provider-neutral semantic event to `BubbleEvent` / human-facing projection. Provider provenance is diagnostic metadata, not the renderer's primary switch. Active foreground, background, hydration/F5, callback, and replay must consume one registry/projector. A structured protocol envelope is visible only through an explicit human-readable projector; unknown/invalid payloads and projector failures retain diagnostics but fail closed instead of becoming raw JSON bubbles. Plain human-readable system notices remain visible. +F143 provider adapters own raw wire parsing and normalize into the shared AgentService event port; this +cell owns the next boundary from provider-neutral semantic event to `BubbleEvent` and human-facing +projection. Provider provenance is diagnostic metadata, not the renderer's primary switch. Foreground, +background, hydration/F5, callback, and replay consume the same semantic projector registry. Unknown or +invalid structured payloads retain diagnostics and fail closed rather than rendering raw protocol JSON. -F183 / ADR-033 own bubble identity and the single-writer reducer contract for frontend message rendering. This cell owns `(catId, canonicalInvocationId, bubbleKind)` identity, BubbleEvent ingress, hydration merge, IDB fallback, and diagnostics. Each visible cat body may carry one immutable `TurnExecutionMessageProjection`: ordinary replies remain visually ordinary, `routing_guard` is labeled “系统补路由”, and `freshness_supplement` is labeled as a later-message supplement. When a guard only assists preserved first-pass prose, it is attached as `auxiliaryTurnExecutions` to that original bubble and never creates or copies a second body. Terminal lifecycle remains in the ledger glass-box API. Under ADR-042, the published original remains ordinary MessageStore truth. A supplement lifecycle projection is attached by exact `originalMessageId`; pending/running/declined/failed/budget states decorate that original, and a produced supplement is a separate timestamped reply carrying `extra.supplement`. F264 attaches `QueueMessageReceipt` to the original user bubble. It shows carrier admission as delivered, exact child creation as awakened, exact `seenAt` as body read, and `handledAt` as terminal handling, never collapsing those events or calling the latter “received”. The existing `queue_updated` store transition consumes both receipts embedded in surviving Queue rows and additive dispatch-owned `messageReceipts`, keyed only by exact message ID; therefore a terminal withdrawal remains live when the actionable Queue is empty, without a second receipt store or message writer. A typed `terminal_silent` outcome renders a system-owned explanation under the original cross-thread message; empty provider text never creates a cat body or second identity. Its evidence action selects every loaded message in the exact invocation lineage, including supplements, while retaining each message's own timeline identity and position. Live socket and F5 hydration must converge without content/timestamp or log-text guessing. +F183 / ADR-033 own bubble identity and the reducer single-writer contract. Visible cat bodies key by +`(catId, canonicalInvocationId, bubbleKind)`; provider IDs remain lifecycle metadata. Typed child +projections distinguish ordinary replies, routing guards, and freshness supplements without copying prose +or creating duplicate bodies. Supplement state attaches only by exact `originalMessageId`, while a +produced supplement remains its own timestamped reply. + +F264 delivery presentation now reads actual delivery truth from MessageStore `dispatchRefs[]`. A ref appears +only after one exact source-to-target cutover and carries `dispatchedAt`, target identity, and the exact +`statusMessageId`; later settlement updates that same ref from the canonical response lifecycle. Queue owns +only pending targets and contributes no receipt, processing, exposure, or terminal projection. + +The first actual delivery of a user or external inline source materializes its one History bubble. Additional +targets append refs to the same source message, so a multi-target send never renders duplicate user bubbles. +Agent-authored sources already in History reuse their existing bubble. A running member that adopts a complete +queued body through thread context performs the same transition and links the source to its existing response; +other targets remain pending in the single Queue entry. + +`MessageDispatchAvatars` is the two-stage projection of those refs: a dispatched target shows the member avatar, +hover reveals the actual delivery timestamp, and activation navigates to the exact response bubble. The response +bubble itself owns `processing`, streamed content, `completed`, `failed`, and `canceled`; a failure retains any +partial content and appends its structured diagnostic. The source ref never manufactures a second response. + +Live socket updates and F5 hydration read the same MessageStore lifecycle. They must converge without content, +timestamp, Queue receipt, or log-text guesses. Ordinary no-mention user input already has a server-bound Queue +target before admission; a historical/recovered or invalid-mention warning row may remain targetless. Either way, +a user/external source remains outside History until some real dispatch occurs, while a source already in History +does not need to be moved or copied. + +A bodyless processing response renders a lifecycle tip; once content streams, that same response identity +becomes the bubble. Empty terminal responses use their typed lifecycle notice and remain available to peer +context. Dispatch navigation follows exact persisted identities without moving the original message or +copying handled replies beneath it. ## Use This When -- Adding a provider/origin that creates, streams, finalizes, hydrates, or restores frontend message bubbles. -- Adding or changing a provider-neutral semantic event, `system_info` protocol projector, visibility rule, or provider provenance handling. -- Changing `BubbleEvent`, `bubbleKind`, canonical invocation ID handling, placeholder upgrade, or hydration merge behavior. -- Touching `useAgentMessages`, `bubble-reducer`, `chatStore` message mutation paths, IDB message cache, or bubble diagnostics. -- Adding or changing legacy closure projections or ADR-042 freshness annotation, supplement status, budget, decline/failure, or reply-chain rendering. -- Adding or changing F264 receipt rendering, handled-disposition copy, reminder state copy, or lineage focus navigation. -- Adding or changing message-disposition selector/onboarding, preference-source labels, one-shot override, or author-intent/fallback receipt copy. -- Adding or changing ordinary/routing-guard/freshness-supplement execution identity, auxiliary execution badges, or ledger hydration links. +- Adding or changing provider-neutral semantic events, bubble identity, placeholder upgrade, hydration, or + replay behavior. +- Touching `useAgentMessages`, `bubble-reducer`, `chatStore`, `useSocket`, IndexedDB hydration, or + bubble diagnostics. +- Changing F254 supplement projection, typed child execution badges, or lifecycle tips. +- Changing dispatch-avatar rendering, delivery timestamps, response terminal outcomes, reminders, or exact + response navigation. +- Changing first-delivery source materialization or delivery-order hydration. ## Extend By -- Declare which `BubbleEvent` types a new provider emits, where canonical ID comes from, and which `bubbleKind` each event lands in. -- Map provider wire events into provider-neutral semantics at the adapter boundary; add both a positive provider fixture and a negative fixture proving the wire type does not become the global render contract. -- Register a semantic kind once for active/background/hydration/callback/replay. Add exhaustiveness and historical raw-payload fixtures so a missing consumer path fails before runtime. -- Route message mutations through the reducer/single-writer path before adding new direct store writes. -- Add replay fixtures or invariant tests when extending event kinds, placeholder recovery, or hydration behavior. -- Keep runtime diagnostics structured enough to identify duplicate stable identities and phase regression. -- Attach supplement projections only to exact `originalMessageId`; keep the original in the normal timeline and render committed supplement content as a separate reply. UI grouping must never merge original and supplement truth. -- Attach queue receipts only to the exact original message. Navigate evidence by canonical invocation identities and supplement provenance, highlighting the loaded lineage as a set rather than relocating or duplicating bubbles. -- Render disposition as an author request, not execution fact. Hide the selector when no relevant invocation is live; show exact exposure/fallback/outcome from custody after send, and clear one-shot state without mutating inherited preferences. -- Persist and hydrate execution identity through the shared typed projection. A child that owns a visible body uses `turnExecution`; a bodyless assisting child uses `auxiliaryTurnExecutions`. Read terminal status from the ledger API rather than copying mutable status into message text. +- Normalize provider wire events before they reach the shared semantic projector; add positive and + fail-closed fixtures for every new structured kind. +- Route all message mutations through the reducer/single-writer boundary. +- Attach supplements and dispatch refs only by exact persisted identity, never by text or timestamp proximity. +- Project delivery avatars from MessageStore `dispatchRefs` for both live socket and F5 history; keep the + frontend normalizer structural and idempotent. +- Add paired live/hydration tests whenever dispatch state, lifecycle notice, or timeline ordering changes. +- Preserve one bubble identity as processing tip upgrades to streamed or terminal content. ## Do NOT Unify With -- Do not put connector transport policy or platform-specific formatting in bubble identity. Transport owns delivery; bubble pipeline owns rendering identity. -- Do not make Codex app-server notification names—or any provider wire type—the global UI event vocabulary. -- Do not use an LLM/skill to guess how protocol JSON should render. Do not fall back to `JSON.stringify` for unknown structured payloads or projector errors. -- Do not let provider lifecycle IDs become frontend bubble identity. OUTER/canonical invocation ID wins; provider IDs are lifecycle metadata. -- Do not create new `messages` write entrances without a reducer event and invariant coverage. -- Do not use IndexedDB as online merge authority. It is a provisional/offline cache. -- Do not remove or replace a completed original because freshness advanced. Do not discover supplement parents by text/timestamp proximity when exact identity exists. -- Do not copy handled replies under the receipt, move the original message, or label `seen` as `handled`. -- Do not keep a meaningless disposition chip visible while all targets are idle, or let composer preference overwrite hydrated message truth. -- Do not parse prompt text, logs, labels or rendered prose to infer execution kind. Do not render a bodyless routing guard as a second ordinary cat answer. +- Do not put connector transport policy or provider wire names into bubble identity. +- Do not render unknown protocol envelopes with `JSON.stringify` or infer semantics with an LLM. +- Do not let provider lifecycle IDs replace the canonical bubble identity. +- Do not use IndexedDB as online merge authority. +- Do not copy Queue lifecycle into MessageStore or derive delivery from pending Queue targets. +- Do not remove or move an original message when freshness or target lifecycle changes. +- Do not label Queue admission as delivery, or provider success as handled without exact lifecycle evidence. +- Do not use content/timestamp proximity to merge supplements, dispatch refs, or terminal notices. ## Static Scan Hints -Watch for new or renamed `AgentMessageType`, `system_info`, `provider_signal`, `BubbleEvent`, `BubbleKind`, `bubbleKind`, `bubbleIdentity`, `BubbleReducer`, `formatVisibleSystemInfo`, `isSystemInfoProtocolPayload`, `useAgentMessages`, `useChatHistory`, `TurnExecutionMessageProjection`, `executionKind`, `routing_guard`, `freshness_supplement`, `auxiliaryTurnExecutions`, `freshness_closure`, `QueueMessageReceipt`, `MessageReceiptDock`, `seenAt`, `handledAt`, `evidenceRef`, `lineage`, `closureId`, `supplementId`, `originalMessageId`, `turnInvocationId`, `originTriggerMessageId`, `turnOutcome`, `sourceInvocationId`, `chatStore`, `mergeReplaceHydrationMessages`, `IndexedDB`, `placeholder`, provider-specific render switches, raw `JSON.stringify` fallbacks, and direct `messages` mutations. +Watch for new or renamed `BubbleEvent`, `bubbleKind`, `bubbleIdentity`, `BubbleReducer`, +`bubble-event-adapter`, `useAgentMessages`, `useChatHistory`, `useSocket`, `queue_updated`, +`dispatchRefs`, `dispatchedAt`, `statusMessageId`, `TurnExecutionMessageProjection`, `executionKind`, +`freshness_supplement`, `MessageDispatchAvatars`, `lifecycleStatus`, +`originalMessageId`, `sourceInvocationId`, `chatStore`, hydration, IndexedDB, placeholder recovery, +provider-specific render switches, raw JSON fallbacks, and direct message mutations. diff --git a/docs/architecture/ownership/cells/dispatch.md b/docs/architecture/ownership/cells/dispatch.md index 6b9e6df529..e338dff28c 100644 --- a/docs/architecture/ownership/cells/dispatch.md +++ b/docs/architecture/ownership/cells/dispatch.md @@ -1,8 +1,8 @@ --- cell_id: dispatch title: Dispatch / Queue -summary: Invocation queue、busy gate、fairness、priority、外部 wake 执行、durable per-child execution ledger、普通 queued user message 的 MessageStore-backed restart custody、append-only per-target attempt 与 F264 durable receipt、F254 legacy closure preflight / non-Queue supplement carrier、F167 action successor generation fence、F247 cloud-only terminal bridge、F280 wait continuation carrier,以及 F295 live/managed-command 的统一可取消 execution 投影。 -canonical_features: [F167, F175, F177, F185, F247, F254, F264, F280, F295] +summary: 按 thread 持久化的 source Queue Entry、未投递 targets 集合、有序 active index、短时 claim、原子 admission/target 更新、逐成员 Steer、外部 wake 执行,以及 durable child/execution 投影。 +canonical_features: [F047, F117, F167, F175, F177, F185, F247, F254, F264, F280, F295] code_anchors: - packages/shared/src/types/active-execution.ts - packages/shared/src/types/turn-execution.ts @@ -12,22 +12,22 @@ code_anchors: - packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts - packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts - packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts - - packages/api/src/domains/cats/services/agents/invocation/QueueCarrierSourceProjection.ts - - packages/api/src/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.ts - - packages/api/src/domains/cats/services/agents/invocation/queue-terminal-consumption.ts + - packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts + - packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts + - packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts + - packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.ts + - packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.ts + - packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-scripts.ts - packages/api/src/domains/cats/services/stores/ports/InvocationRecordStore.ts - packages/api/src/domains/cats/services/stores/redis/RedisInvocationRecordStore.ts - - packages/api/src/domains/cats/services/agents/invocation/queue-entry-settlement.ts - packages/api/src/domains/cats/services/agents/invocation/CollaborationContinuityCapsule.ts - packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts - packages/api/src/domains/ball-custody/ActionSuccessorRecoverySweep.ts - packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts - packages/api/src/domains/ball-custody/wait-continuation-carrier.ts - - packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.ts - - packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.ts - packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts - - packages/api/src/domains/cats/services/stores/ports/queued-message-custody.ts - - packages/api/src/domains/cats/services/stores/ports/queued-message-receipt.ts + - packages/api/src/domains/cats/services/stores/ports/MessageStore.ts + - packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts - packages/shared/src/types/queue-receipt.ts - packages/api/src/utils/queue-enrichment.ts - packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -38,18 +38,21 @@ code_anchors: - packages/api/src/domains/cats/services/session/thread-access-policy.ts - packages/web/src/stores/activeExecutionStore.ts - packages/web/src/hooks/useActiveExecutionProjection.ts + - packages/web/src/hooks/useLiveExecutionCancelControl.ts - packages/web/src/components/ThreadExecutionBar.tsx - - packages/web/src/components/ThinkingIndicator.tsx + - packages/web/src/components/MessageDispatchAvatars.tsx - packages/web/src/components/workspace/WorkspaceNowSurface.tsx - - packages/api/src/domains/cats/services/freshness/FreshnessClosureStore.ts - - packages/api/src/domains/cats/services/freshness/FreshnessClosurePreflight.ts + - packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStore.ts + - packages/api/src/domains/cats/services/freshness/closure/FreshnessClosurePreflight.ts - packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts - packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts - packages/api/src/routes/callback-a2a-trigger.ts - packages/api/src/routes/callback-multi-mention-routes.ts - packages/api/src/routes/callbacks.ts doc_anchors: - - feature-specs/2026-09-06-issue1371-direct-witness.md + - docs/decisions/043-queue-durable-single-ledger.md + - docs/features/F117-message-delivery-lifecycle.md + - docs/features/F047-queue-steer.md - docs/features/F295-cancelable-execution-projection.md - docs/features/F177-harness-update.md - docs/features/F167-a2a-chain-quality.md @@ -70,9 +73,13 @@ doc_anchors: - feature-specs/2026-08-12-1291-gate3-terminal-receipt-publication.md - feature-specs/2026-08-12-1291-gate4-wait-carrier-integration.md - feature-specs/2026-08-12-1291-gate5-retry-revalidation.md -static_scan_hints: [TurnExecutionRecord, TurnExecutionStore, RedisTurnExecutionStore, TurnExecutionStartupReconciler, executionKind, auxiliaryTurnExecutions, InvocationQueue, QueueProcessor, InvocationRecordStore, RedisInvocationRecordStore, WaitContinuationCarrierV1, waitContinuationCarrier, queuedAttemptIdByCatId, QueueTargetAttempt, targetAttempts, resolveQueueEntrySettlement, QueueCustodyReplacementProof, CollaborationContinuityCapsule, dispatch_handled_continuation, QueuedMessageCustody, QueueBodyExposure, QueueMessageReceipt, QueueMessageReceiptProjection, messageReceipts, QueueReceiptTarget, QueueReminderAttempt, QueuedMessageCustodyCoordinator, QueuedMessageCustodyStartupReconciler, projectQueueReceipt, transitionQueueCustody, restoreDurableEntry, InvocationTracker, ConnectorInvokeTrigger, resolveThreadAccess, actionSuccessorFence, actionLeaseId, actionGeneration, freshnessClosureId, freshnessRequiredFrontierMessageId, freshnessSupplementId, readOnlyToolPolicy, busy, priority, autoExecute] +static_scan_hints: [QueueLedgerEntry, QueueLedgerStore, QueueLedgerAdmission, RedisQueueLedgerStore, InMemoryQueueLedgerStore, queueEntryId, sourceRecordId, targets, authorIntentByTarget, expandTargets, InvocationQueue, QueueProcessor, StartupReconciler, TurnExecutionRecord, TurnExecutionStore, executionKind, InvocationRecordStore, WaitContinuationCarrierV1, waitContinuationCarrier, claimPrefix, claimExactSteerEntryDurable, restoreClaimedEntries, ConnectorInvokeTrigger, actionSuccessorFence, actionLeaseId, actionGeneration, freshnessClosureId, freshnessSupplementId, readOnlyToolPolicy, priority, sourceCategory, autoExecute, reconcileInactiveLiveInvocation, EXECUTION_CONTROL_UNAVAILABLE] cited_by: - - {feature: issue-1371-direct-witness, date: 2026-09-06, delta: direct route consumers forward exact child and adopted wake witnesses through the shared terminal collector to existing Queue custody CAS; source projection reuses canonical managed-hold owner visibility} + - {feature: F117-canonical-source-entry, date: 2026-09-08, delta: Queue owns one source entry with only pending targets, order, author intent and a short reversible claim; actual delivery moves exact targets into MessageStore dispatchRefs while response and TurnExecution records own processing and terminal truth} + - {feature: F117-steer-per-target, date: 2026-09-20, delta: default delivery copy separates queueing from immediate guidance; static configured-client guide capability is projected per member; composer and Queue Steer support multi-select with per-target guide or interrupt; historical or warning-bearing targetless recovery plus target-set reconciliation is one atomic ledger mutation} + - {feature: F220-KD9-stop-ladder, date: 2026-09-03, delta: Stop is the only user-facing termination; an exact live cancel escalates server-side to per-target reconciliation of durable running truth instead of 409, an incomplete process-owner snapshot terminalizes the execution as failed instead of prompting, and force-reset is demoted to an internal thread-scoped reconciler} + - {feature: F254-ADR-043-read-adoption, date: 2026-09-03, delta: an exact full same-thread read adopts only that target from the one source Queue Entry into the current lifecycle response, records its History dispatchRef, and leaves sibling targets pending on the same entry} + - {feature: F117-ADR-043, date: 2026-09-20, delta: QueueLedger is the only durable pending-delivery truth; one source row owns pending targets and a bounded claim, actual dispatch writes MessageStore dispatchRefs, and Queue retains neither per-target rows nor processing/terminal receipt tombstones} - {feature: F295-post-close-thread-admission, date: 2026-08-22, delta: active-execution read and exact-cancel reuse canonical owner/default/user-index/external-anchor thread admission before liveness lookup while retaining masked shared occupancy and execution-principal control fences} - {feature: F295, date: 2026-08-13, delta: one project-scoped read projection joins canonical live invocation truth with existing managed-command receipts; every displayed execution carries thread, kind, exact identity and an identity-fenced cancel target or an explicit non-cancelable reason} - {feature: issue-1291-gate6-batch-steer, date: 2026-08-13, delta: Batch Steer accepts only an exact allowlist of compatible ordinary-user entries for one cat; Queue reserves the complete set before one preempt and QueueProcessor creates one replacement invocation without F175 absorbing unselected neighbors} @@ -96,102 +103,148 @@ cited_by: - {feature: F264, date: 2026-07-15, delta: durable custody projects six honest per-target UI states, distinct responded vs completed-with-turn outcomes, exact invocation-lineage evidence, idempotent reminder requested/delivered/seen/missed attempts, and persisted Steer-in-progress truth} - {feature: F264-terminal-consumption, date: 2026-07-31, delta: cross-thread messages bind immutable per-target Queue carriers; exact child creation records awakened separately from body exposure, and only exact-child plus aggregate success may commit a typed Phase T terminal-silent witness into the existing receipt} - {feature: F264-author-disposition, date: 2026-08-04, delta: ordinary queued sources persist per-target author disposition with an exact parent exposure fence; next-work remains the default, and an unconsumed current-work request falls back to the same Queue custody instead of leaking into a successor turn} + - {feature: F117-reminder-retirement, date: 2026-09-17, delta: retire the manual Queue reminder button, endpoint and attempt projection; Queue owns only pending targets/order while Steer owns the explicit delivery-mode choice} --- # Dispatch / Queue ## Canonical Owner -F175 owns the unified message queue and priority ordering. F185 / ADR-034 own entry-level busy-gate stratification. `TurnExecutionStore` is the TTL=0 lifecycle owner for every real child invoke: provider dispatch starts only after an idempotent `running` create, one immutable terminal wins, and startup interrupts pre-process orphaned running records. `InvocationRecordStore` remains parent/Queue aggregate truth; `InvocationRegistry` remains callback authentication and cannot serve as history. An ordinary queued user message has exactly one carrier here: `InvocationQueue` is its live ordering view, while the revisioned TTL=0 custody embedded in the same MessageStore record is restart truth. F264 projects that same truth into six per-target UI states without creating a second lifecycle. Successful Queue API delete and clear withdrawals publish exact message-bound receipt deltas beside the same ordered `queue_updated` snapshot, including when no actionable row survives; the delta and history hydration both use `projectQueueReceipt` over MessageStore custody. A best-effort notice may set `notified`; a manual reminder records independent `requested / delivered / seen / missed` attempts; exact body exposure binds `seen` to `(messageId, targetCat, childInvocationId, seenAt)`; only that exact child's successful terminal may record either `responded` or `completed_with_turn`, and `handledAt` must follow `seenAt`. The single settlement seam consumes success and explicit user cancellation, rolls ordinary system failure back to the same carrier, consumes action-fenced failure, and retains superseded truth unless an exact source-message CAS has verified replacement custody. A replacement rebind preserves the old message's owner, intent, targets, history, and queued delivery state; no speculative replacement may start before that CAS owns the new entry ID. Startup reconstructs the exact `messageId / entryId / position / target` owner and closes transient reminder/Steer attempts honestly; it does not convert pending execution into delivered-only visibility. Steer means cancel current + restart that exact entry once; reorder is a separate control. F254 keeps ADR-041 typed closure carriers only for unfinished/legacy work. ADR-042 supplement queue rows are allowed only for non-Queue unseen sources and project a distinct durable sequence: `QueueProcessor` resolves current aggregate truth, claims the exact ID, reloads original + required messages, seeds seenCursor, and passes a harness-enforced read-only policy before model start. F167 Phase S keeps action uniqueness in ball-custody and projects `leaseId + generation + dispatchId` onto QueueEntry. S.1-c leaves terminal command/return truth durable until dispatch is positively recoverable. Phase T adds a read-only wake provenance carrier into direct user and queued execution. An explicit A2A `sourceCategory` selects the current thread-ball dispatch, but only after `ball.handed` is durably recorded for the target holder; an exact `hold-ball:*` source selects its hold subject. Generic connectors, missing source records, and failed lookups remain `unknown_legacy` and fail closed through the structured stop gate rather than being guessed from prose. F280 wait delivery persists one immutable carrier in the server-authored `github-wait` message; direct admission copies it to `InvocationRecord`, while busy-thread admission projects it through `InvocationQueue` and QueueProcessor into the same record. Startup re-derives that projection from the exact enclosing MessageStore row, and coalescing includes wait/outcome identity. Queue custody never stores a second copy, and an action-successor wait owner fence stays inside `structured:event_wait` instead of becoming `actionSuccessorFence`. A pre-Gate-4 queued `github-wait` message with no carrier is terminalized failed for its own pending custody targets and never reconstructed as generic connector work; Queue-entry recovery isolates that group and continues unrelated groups in the same pass. For cross-thread messages, one stored body may bind multiple immutable per-target Queue carriers. Carrier admission records delivered; child ledger creation records awakened before body exposure; startup restores each nonterminal carrier independently. A typed Phase T terminal-silent witness may be committed only with the same target's exact body-exposing successful child and successful aggregate. An exact `dispatch_handled_continuation` witness follows the same receipt boundary: only its source message stores the witness, while coalesced siblings settle without inheriting it. QueueProcessor derives a source-free `CollaborationContinuityCapsule` and schedules exactly one same-cat continuation; the capsule carries neither the settled A2A trigger nor its caller/body, so it cannot redispose or replay that carrier. Existing continuation-key dedupe handles identical pending capsules, and `completed` or same-invocation post-tool progress produces no continuation. - -F247 cloud-only execution still creates a normal durable child and exposes the exact source body before -transport. It awaits only a bounded Host receipt/failure, publishes one user-visible transport status, and -writes `completed` for the exact A2A source. It never waits for the cloud cat's later MCP response and never -turns missing Host capability into a retryable governance failure. - -Gate 5 explicit retry appends one target-local attempt to the existing MessageStore custody only after -the injected ball-custody authority committer atomically compares the raw Task/action-lease witness and -every Message custody revision in the same Redis Lua that appends the attempt. `queuedAttemptIdByCatId` -is then a pure live Queue projection of that accepted durable attempt, and its ID is the Invocation -idempotency key across start, duplicate clicks, and restart. Receipt history projects every attempt -without becoming writable state. -A coalesced cross-thread A2A carrier can aggregate multiple source messages with different attempt IDs, -so it intentionally projects none; legacy agent retry remains authority-fail-closed instead of guessing. - -Gate 6 Batch Steer is a bounded command over the existing Queue, not a new carrier or ledger. The API -atomically reserves the caller's ordered `entryIds` before preemption, requires one target cat, one intent, -and one non-unknown owner-auth provenance, then performs one `preempted` cancel and one replacement -invocation. Eligibility is allowlisted to ordinary user entries: action-fenced, connector/system, -freshness, continuation/A2A, pinned, `continue_current`, auto-execute, and legacy-unattributed carriers -fail closed before cancellation. Every selected Queue row enters processing together; QueueProcessor -passes those exact rows to the existing prompt/body-exposure and settlement seams. F175 adjacent batching -is bypassed for this invocation, so an unselected neighboring row may run later but cannot enter the -selected replacement. Provider/infrastructure failure returns every selected row to the same reserved -group; explicit withdrawal cancels the unstarted reservation without deleting sibling work. The -reservation marker is process-local coordination and is redacted from Queue API projections; durable -per-message MessageStore custody remains the only restart truth. If restart loses the group marker before -replacement start, the persisted per-entry Steer intent disables F175 adjacency: selected rows degrade to -separate invocations, while an unselected neighbor still cannot be absorbed. +ADR-043 / F117 own one durable Queue ledger per thread. One source message has one `QueueLedgerEntry`, whose +`targets[]` is exactly the set of members still awaiting delivery. The entry owns source identity +(`sourceId` plus `sourceRecordId`), ordering, immutable launch fences, per-target author intent, +revision, and a short reversible claim. It does not own response processing, terminal outcomes, +body-exposure receipts, or handled state. `InvocationQueue` is only the in-process scheduling adapter over +that ledger, never restart truth. + +Redis stores source entries in one thread-scoped hash and active entry IDs in the order list. Durable Queue +state is `queued` plus a bounded `claimed` cutover. A claim is fenced by `claimId`: a failed pre-dispatch +cutover restores the same entry and position; an actual delivery removes only that target; and an empty +`targets[]` deletes the entry. Queue retains no processing/terminal tombstone. Retry is a new attempt in the +owning lifecycle store, not resurrection of finished Queue state. Startup validates persisted rows and +restores abandoned short claims before scheduling. + +MessageStore owns the source body and its actual delivery projection. `dispatchRefs[]` records only real +source-to-target deliveries, including `dispatchedAt`, response identity, and later settled outcome. A user +or external inline source remains absent from History while it has no actual dispatch; its first delivered +target materializes the one source bubble, and later targets extend the same message. Agent-authored sources +already in History reuse that message. Queue and History therefore meet by exact source identity without +copying the body or rendering one user message per target. + +A complete same-thread unread adoption is the same delivery transition as ordinary dispatch: prove the +exact running child, remove that exact target from the Queue entry, and append one `dispatchRef` tied to the +existing response. Other pending targets remain in the same entry. Sparse, cross-thread, oversized-anchor, +or unproven-child reads do not consume a target. There is no separate Queue `seen`, `handled`, or receipt +writer. + +Routing preflight is not a second chat-message producer. A fail-open `warned` decision remains available in +structured routing evidence and telemetry while leaving the requested target unchanged; only a `rejected` +decision may emit a user-visible receipt. When a cat-authored A2A response fails, that response is the only +terminal History fact. The dispatching member observes it on a later natural invocation through an exact +source×target History lookup; failure does not synthesize a predecessor Queue row or hidden wake. + +Stop is the only user-facing termination. Running truth is the durable `TurnExecution` record plus a +liveness witness (tracker slot or process owner); in-memory slots, tracker tombstones, and session +locks are caches and cannot pin a thread busy by themselves. An exact Stop first cancels a live candidate; when +the durable execution still says running but no controllable candidate exists, the same request reconciles that +target (retire the pre-start reservation, cancel the running record, release the orphaned lock and slot, +publish terminal) and returns `reconciled` rather than 409. An incomplete process-owner snapshot (`ps` failure, +unreadable owner manifests, or a platform without `ps`; no platform compatibility branch) is +retried server-side and then terminalizes the exact execution as failed through the ordinary dispatch terminal +path, so ordinary failure propagation runs (source dispatchRef settlement and `delivery_failure` for pre-start; +the dispatching member later observes either fact through History); no confirmation dialog exists, and +`force-reset` remains only an internal thread-scoped reconciler. The +same reconciliation is reused at startup, on projection read, and on Stop (ADR-043 D9). + +Steer is the only preemption path. The modal projects current participants plus routed/fallback members and +joins pending Queue targets with already-dispatched History refs. Confirmation rereads live truth: targets +delivered while the modal was open are skipped, additions/removals update the source entry, and each remaining +selected target receives its chosen guide-or-interrupt strategy. Stale presentation state is not itself a +business conflict. The short claim fences the exact server-side cutover; pre-dispatch failure restores Queue +state, while provider outcomes after actual delivery settle independently in History. + +The default disposition is `next_work` (排队等待) or `continue_current` (立即发送,引导回复). Guide support is +a static per-member client capability projected by the server; the UI must not hard-code it or infer it from +local activity. Queue and composer Steer show both guide and interrupt actions, allow a per-target strategy for +multi-select, and enable guide from static member capability rather than a local liveness guess. Confirmation +rereads the exact active parent: a present parent receives the non-interrupting append, while an absent parent +falls back to ordinary Queue admission. Both `exact_active_turn` and `queued_internal_turn` support +current-invocation guidance; only the former proves exact consumption by the visible provider turn. +Unsupported default guide preserves the target as `next_work` and never cancels the current execution. Ordinary +no-mention user input is bound before Queue admission; only historical/recovered or invalid-mention warning rows use +head-time targetless binding. That recovery and every multi-target update mutate the one source entry atomically; +after actual delivery each target proceeds independently. A successful +`runtime_replacement` already completed its recovered attempt and must not enqueue a second source-less +continuation. + +F175 owns comparator/order policy and F185 / ADR-034 own busy-gate stratification. `TurnExecutionStore` +owns each real child lifecycle; `InvocationRecordStore` owns parent/aggregate execution truth; +`InvocationRegistry` remains callback authentication only. F254 closure and supplement stores retain +their own authority while Queue rows carry typed launch metadata. F167 action leases and F280 wait +continuations remain owned by ball-custody stores; Queue may transport their exact immutable fences but +cannot mint, retry, or reinterpret them. The retired Gate-5 bridge may not recreate completed Queue work. +F247 cloud-only execution still creates a normal child and settles the exact source without turning a +transport failure into Queue replay. ## Use This When -- Changing invocation enqueue/dequeue behavior, priority, queue visibility, queue full handling, or auto-execution. -- Changing busy-gate semantics for users, A2A, connectors, CI/PR/review tasks, scheduled tasks, or generic external wakes. -- Adding fairness rules such as "non-agent entries must not be starved by agent chains". -- Changing F254 legacy successor or supplement enqueue, exact body reconstruction, claim/lease, read-only policy, failure terminalization, sequence budget, or startup carrier recovery. -- Changing ordinary queued-message persistence, custody CAS, same-invocation handled evidence, restart reconciliation, legacy custody backfill, or exact Queue owner reconstruction. -- Changing child invocation create/terminal/restart lifecycle, execution kind, parent index, glass-box API, or callback-auth cleanup boundary. -- Changing F264 receipt projection, handled disposition, lineage evidence, reminder attempts, or Steer-in-progress persistence. -- Changing author-declared current-work/next-work intent, exact parent exposure eligibility, preference resolution, or terminal fallback. -- Changing action-scoped multi-mention/cross-post admission order, queue idempotency projection, generation preflight, or stale structured response suppression. -- Changing managed-command/return recovery dispatch, or the wake provenance passed into the turn-scoped custody stop gate. -- Changing F280 wait carrier direct/Queue admission, coalescing, restart reconstruction, Invocation persistence, or event-wait projection. -- Changing exact-target retry custody, attempt history, Queue attempt-id projection, or Invocation retry idempotency. -- Changing Batch Steer eligibility, exact-entry reservation, preemption, or F175 bypass behavior. +- Changing Queue admission, target-set mutation, priority, ordering, capacity, claim/commit/restore, withdrawal, or + restart behavior. +- Adding a producer that persists Message and Queue work, or changing one of the three cross-record atomic + admission transactions. +- Changing Steer, append-without-stop, prestart retirement, or the `ENTRY_PROCESSING` conflict boundary. +- Changing the Queue-to-History dispatch cutover, unread adoption, or source materialization. +- Changing F254 freshness carriers, F167 action-successor fences, F280 wait carriers, or other typed launch + metadata transported by Queue. +- Changing child execution lifecycle, busy-gate policy, wake provenance, or external connector admission. ## Extend By -- Reuse `InvocationQueue` for queued work and `QueueProcessor` for execution order. -- Add source/category/priority metadata to queue entries instead of bypassing queue ordering. -- Route external automated wakes through `ConnectorInvokeTrigger.trigger()` unless a new architecture decision says otherwise. -- Keep thread-level vs slot-level busy gates explicit in docs and tests. -- Treat `freshnessClosureId` as a typed custody carrier. Before model execution, resolve immutable origin, scan through the latest raw frontier, CAS-merge relevant bodies, and then claim the running lease. Missing/terminal closure cancels; incomplete evidence blocks before model execution. -- Treat `freshnessSupplementId` as a projection-only carrier. Resolve the durable aggregate, require one target + exact lineage/seq, claim before launch, rebuild input from MessageStore, and fail before provider start if `readOnlyToolPolicy` is absent or unsupported. -- Persist ordinary queued-message responsibility on the same MessageStore record with immutable identity, revisioned CAS, stable ordering, and independent `notified / seen / failed / handled` target sets. Rebuild `InvocationQueue` from that truth on startup and reconcile only exact InvocationRecord evidence. -- Route every attempt terminal through `resolveQueueEntrySettlement`; change an entry ID only with `QueueCustodyReplacementProof` for the exact queued source, and gate provider start on the committed owner. -- Derive `QueueMessageReceipt` from custody for both live Queue and F5 history. Keep reminder attempts additive and idempotent per exact message/target/invocation; keep `responded` and `completed_with_turn` as distinct terminal dispositions. -- Append an explicit retry as a new per-target custody attempt only through the ball-custody atomic - authority/custody committer; only its durable winner may reopen Queue and project its exact ID into - Invocation. Preserve sibling/old attempts and reconstruct that same ID after restart. -- Publish Queue API delete and clear receipts as message-bound deltas on the existing ordered `queue_updated` event. Name only the exact affected message IDs and derive every delta from persisted custody after settlement. -- Persist author disposition on the existing per-target custody. Bind `continue_current` to the exact active parent at admission; only a matching full-body read may expose it, and parent terminal must append a fallback to `next_work` for every still-pending target. -- Create every child in `TurnExecutionStore` before provider start, pass `executionKind` explicitly, and transition `running` through the store's one-way terminal CAS. Reconcile abandoned running children from the durable ledger, never from logs or callback auth. -- Treat `actionSuccessorFence` as a typed projection from `ActionSuccessorLeaseStore`. Admission happens before request/message/timer/queue persistence; start and commit both preflight the exact generation. -- Derive stop-gate wake provenance from typed Queue/action/message-source fields only. User/cron/freshness can be obligation-free; action fences, explicit A2A dispatches, and exact hold sources select one custody truth. Persist the A2A `ball.handed` transition before opening its projection; missing/failed lookups stay legacy unknown. -- For `github-wait`, load the closed carrier from the exact stored source message, copy it unchanged through Queue and Invocation, and include wait/outcome identity in coalescing. Reject malformed or divergent projections before provider start; leave generic non-wait connectors actionless. -- For Batch Steer, reserve the complete ordered entry allowlist before preemption, keep one target/intent/provenance, and pass the exact selected rows through the existing QueueProcessor settlement path. +- Create entries through `QueueLedgerAdmission` and use the deterministic source identity; never allocate + one independent Queue identity per target. +- Use `appendAndEnqueueDurable`, connector adoption, or terminal-response fan-out transactions whenever + Message and Queue facts must appear together. +- Add state transitions to `QueueLedgerStore` with Redis Lua that validates every precondition before its + first write, plus matching in-memory and isolated-Redis tests. +- Keep active-order scans bounded to the order list and join actual dispatch state from MessageStore only + when a caller needs it. +- Project public Queue DTOs from pending source entries; project dispatch avatars and terminal truth from + MessageStore/response lifecycle, not Queue fields. +- Keep source authority in its owner store. Queue fields may transport an immutable fence, but start and + terminal commit must revalidate it at the owning boundary. +- Create every child in `TurnExecutionStore` before provider start and settle child plus response lifecycle + truth monotonically. ## Do NOT Unify With -- Do not collapse user side-dispatch and external automated events into one busy-gate rule. ADR-034 explicitly keeps them stratified. -- Do not add a second queue for a feature-specific wake path without proving `InvocationQueue` cannot express the priority/fairness requirement. -- Do not create a supplement carrier for an ordinary queued user message, or treat notice delivery as seen/handled evidence. -- Do not infer `responded` from invocation success alone, collapse reminder delivery into body-read evidence, or turn `seen` into `handled`. -- Do not retain a terminal Queue row merely to transport its receipt, persist a second receipt ledger, or reconstruct terminal truth from the removed row. -- Do not add child arrays or kind enums to the parent InvocationRecord, and do not retain callback-auth registry entries as historical execution truth. -- Do not treat the in-memory `InvocationQueue` Map as restart truth, and do not call `markDelivered` merely because a queued owner was absent after process restart. -- Do not turn explicit user cancellation into a blind requeue, or mint/start a recovery replacement before exact custody rebind succeeds. -- Do not use Steer as a synonym for promote/reorder; Steer cancels current work and restarts the exact durable entry once. -- Do not let Batch Steer accept non-user control carriers, mint authority, create a second lifecycle store, cancel once per selected row, or reuse F175 adjacency to discover extra rows. -- Do not treat preference or `continue_current` as seen/handled evidence, leave it unbound to a parent, or let a replacement/same-cat successor inherit the old exposure window. -- Do not use `urgent` priority for agent-to-agent continuation except the explicit continuation exception. Urgent is for user/system blocking work, not agent chatter. -- Do not model transport delivery retries here; connector delivery belongs to `transport`. -- Do not infer catch closure from an untyped existing invocation. Scheduling coverage is not semantic closure and cannot suppress a required successor. -- Do not let queue removal silently delete supplement responsibility, or let a stale carrier launch after its aggregate became terminal. -- Do not let ordinary A2A coalesce absorb action-fenced work, and do not let queue state decide action uniqueness or replace eligibility. -- Do not classify a generic connector or provenance-free historical text mention as `covered_empty`; absence of a typed carrier must remain legacy fail-closed during migration. An explicit A2A source category is typed dispatch provenance, not a text guess. -- Do not duplicate wait carrier fields into `QueuedMessageCustody`, accept them from a trigger caller, promote their owner fence into action lease authority, or reimplement Gate 5 authority inside Queue transport; the route may run a read-only UX preflight, but custody mutation must use the injected ball-custody atomic authority/custody committer before Queue projection. +- Do not reintroduce `message.queueCustody`, per-target Queue rows, a second receipt ledger, or startup reconstruction + by scanning MessageStore. +- Do not persist Queue processing/terminal tombstones, body-exposure facts, or handled outcomes. Those facts + belong to TurnExecution, MessageStore dispatch refs, and response messages. +- Do not restore failed, cancelled, interrupted, handled, or withdrawn attempts to Queue. A retry is a new + lifecycle attempt; pending targets remain pending only until actual dispatch or withdrawal. +- Do not concatenate adjacent Queue bodies or let one row absorb another row's message identity. +- Do not preempt before winning the durable claim, and do not turn Steer into promote/reorder. +- Do not let Queue decide action uniqueness, wait ownership, freshness closure, connector transport policy, + or callback authentication. +- Do not infer delivery/handled state from notices, provider success, log text, or rendered prose. +- Do not render fail-open routing warnings as chat messages, synthesize a predecessor failure carrier, or enqueue a + new `runtime_replacement` continuation after the recovered attempt succeeded. +- Do not return a full queued body before its exact active-child adoption is durable, and do not retire + sibling targets when another target adopts the same source. +- Do not gate Stop on an in-memory live candidate or answer a user's Stop with 409 while the durable row still + says running; reconcile or terminalize the exact target instead, and reserve 503 for persistence failure only. +- Do not let an in-memory slot, tracker tombstone, or session lock pin a thread busy without a durable + `TurnExecution` and a liveness witness, and do not surface force-reset as a standing or stall-triggered user entry. +- Do not use the in-memory Queue cache as persistence or accept an unvalidated Redis row into scheduling. +- Do not collapse user side-dispatch and external automated wakes into one busy-gate policy. ## Static Scan Hints -Watch for new or renamed `TurnExecutionRecord`, `TurnExecutionStore`, `executionKind`, `routing_guard`, `freshness_supplement`, `TurnExecutionStartupReconciler`, `Queue`, `QueueProcessor`, `InvocationQueue`, `InvocationRecordStore`, `RedisInvocationRecordStore`, `WaitContinuationCarrierV1`, `waitContinuationCarrier`, `resolveQueueEntrySettlement`, `QueueCustodyReplacementProof`, `QueuedMessageCustody`, `QueueBodyExposure`, `QueueMessageReceipt`, `QueueMessageReceiptProjection`, `messageReceipts`, `QueueReceiptTarget`, `QueueReminderAttempt`, `QueuedMessageCustodyCoordinator`, `QueuedMessageCustodyStartupReconciler`, `projectQueueReceipt`, `transitionQueueCustody`, `restoreDurableEntry`, `ConnectorInvokeTrigger`, `ManagedCommandWakeRecoverySweep`, `ActionSuccessorRecoverySweep`, `TurnCustodyWakeProvenance`, `actionSuccessorFence`, `actionLeaseId`, `actionGeneration`, `FreshnessClosurePreflight`, `FreshnessRelevancePolicy`, `freshnessClosureId`, `freshnessSupplementId`, `freshnessSupplementSeq`, `readOnlyToolPolicy`, `originTriggerMessageId`, `freshnessRequiredFrontierMessageId`, `tryAutoExecute`, `autoExecute`, `busy`, `priority`, `sourceCategory`, `urgent`, and `enqueue` code. +Watch for new or renamed `QueueLedgerEntry`, `QueueLedgerStore`, `QueueLedgerAdmission`, +`RedisQueueLedgerStore`, `InMemoryQueueLedgerStore`, `queueEntryId`, `sourceRecordId`, `targets`, +`authorIntentByTarget`, `InvocationQueue`, `QueueProcessor`, +`StartupReconciler`, `claimPrefix`, `claimExactSteerEntryDurable`, `restoreClaimedEntries`, +`dispatchRefs`, `dispatchedAt`, `statusMessageId`, `TurnExecutionStore`, +`InvocationRecordStore`, `actionSuccessorFence`, `waitContinuationCarrier`, +`freshnessClosureId`, `freshnessSupplementId`, `sourceCategory`, `priority`, and `autoExecute`. diff --git a/docs/decisions/043-queue-durable-single-ledger.md b/docs/decisions/043-queue-durable-single-ledger.md new file mode 100644 index 0000000000..db64d0bb57 --- /dev/null +++ b/docs/decisions/043-queue-durable-single-ledger.md @@ -0,0 +1,423 @@ +--- +decision_id: ADR-043 +related_features: [F117, F039, F047, F122, F175, F254, F264] +topics: [queue, delivery, lifecycle, persistence, architecture] +doc_kind: decision +created: 2026-09-02 +--- + +# ADR-043: 消息队列 = 独立持久化的有序工单账本 + +> **Status**: exact candidate implemented and locally reviewed; basic journeys 1–5 accepted; full UAT, fork soak, and upstream gates pending | **Decider**: co-creator | **Analysis**: 布偶猫(opus) | **Implementation**: 缅因猫/砚砚 | **Priority**: P1 +> **Supersedes (设计层)**: F039 / F122 / F175 / F047 中关于队列状态与 Steer 预留的描述 + +## 背景 + +F117 对 A2A 消息投递做了完整重构。收尾阶段的实读审计发现:这个在 RFC 中被定义为「持久化的、有序的消息队列」的组件,实际实现是 **≈15,690 行**,其中 `QueueProcessor.ts` 单文件 **7,004 行**(项目硬上限 350 行的 20 倍)。 + +## 根因(实读证据) + +### 1. 队列不持久,真相寄生在 message 上 + +```ts +private queues = new Map(); // InvocationQueue.ts:263 — 纯内存 +``` + +持久的那一份是 `message.queueCustody`,被序列化成 JSON 塞进 message 的 Redis hash: + +```lua +redis.call('HSET', KEYS[1], 'queueCustody', ARGV[1], 'queueCustodyRevision', ARGV[2]) +-- RedisMessageStore.ts:189 +``` + +而 `QueuedMessageCustody` 的形状本身就是一个队列条目: + +```ts +interface QueuedMessageCustody { + entryId: string; // 队列条目 ID + revision: number; // 乐观并发版本 + status: 'queued' | 'processing' | 'terminal'; // 队列条目状态机 +} +``` + +**结论:持久队列条目已经存在,只是存错了地方。** + +### 2. 双写:13 个字段逐一镜像 + +| QueueEntry(内存) | message.queueCustody(持久) | +|---|---| +| `queuedNotifiedByCatIds` | `notifiedByCatIds` | +| `queuedAwakenedInvocationIdByCatId` | `awakenedInvocationIdByCatId` | +| `queuedSeenByCatIds` | `seenByCatIds` | +| `queuedSeenInvocationIdByCatId` | `seenInvocationIdByCatId` | +| `queuedBodyExposures` | `bodyExposures` | +| `queuedFailedByCatIds` | `failedByCatIds` | +| `queuedHandledByCatIds` | `handledByCatIds` | +| `allTargetCats` | `allTargetCats` | +| `steerRequestedByCatIds` | `steerRequestedByCatIds` | +| `steeredInvocationIdByCatId` | `steeredInvocationIdByCatId` | +| `authorIntentByCatId` | `authorIntentByCatId` | +| `queuedFailure*ByCatId` | `targetAttempts` | +| `queuedAttemptIdByCatId` | `targetAttempts` | + +那 15,690 行里的绝大部分——CAS、`-3` lifecycle 冲突码、reconcile、rollback、`restoreDurableEntry`、startup reconciler——**都是维持这两份一致的机器**。 + +### 3. 旧实现把多目标拆行,导致 UI 与账本身份不一致 + +```ts +// retired shape +return admission.targetCats.map((targetCatId) => createQueueRow(sourceId, targetCatId)); +``` + +这会让同一条用户消息在 Queue UI 中看起来像发了 N 次,同时又迫使 agent/user/connector 走不同数据模型。正确边界不是“把所有来源都拆行”,而是:**一条 source message 始终只有一条 Queue Entry;目标差异只存在于这条 entry 的 pending target set 与 History 的实际 dispatch records 中。** + +## 决策 + +### D1 — 队列条目独立持久化 + +按 thread 建持久队列:入队即持久化,重启直接反序列化。**不再寄生在 message hash 上。** + +### D2 — Queue 只拥有尚待投递的工作;History 拥有实际投递与响应终局 + +职责边界: + +- **source message**:内容、结构化 `from`、稳定 source identity,以及在 actual dispatch 后取得的公开时间线身份; +- **Queue Entry**:尚未投递的 `targets[]`、顺序、默认/单目标作者意图和短暂可回滚 claim; +- **History lifecycle**:每个实际 source→target 投递的 `dispatchRefs[]`,以及对应 response bubble 的 processing/completed/failed/canceled 终局。 + +Queue 不再保存或投影 `notified/awakened/seen/handled/failed/terminal` 回执,也不保留 terminal tombstone。`deliveryStatus` 只描述 source message 的粗粒度可见性;逐目标事实只能从 History lifecycle 读取。 + +用户与外部 connector 的 inline source 在尚未发生任何实际投递时不进入公开 History。第一次成功投递会把同一 source materialize 到 History,并追加第一个 `dispatchRef`;后续目标复用同一条 source bubble。Agent source 本来就在 History,不需要重复发布。 + +公开 History message 的自身执行终态与它能否成为下一跳 source 正交: +`completed / failed / canceled / interrupted` response 均可记录新的下游 `dispatchRef`,且不得改变自身终态。 +失败传播因此直接引用原 failed response;不复制正文,也不创建第二条失败通知。 + +### D3 — 一条 source message 只有一条 Queue Entry + +`QueueLedgerEntry.id = queueEntryId(sourceRecordId)`,不含 target。`targets: string[]` 是**仍待投递**的集合:投递一个成员只删除该成员;最后一个 target 离开后才删除整条 Queue Entry。多目标不会复制 source message,也不会在 UI 产生多个输入气泡。 + +`payload.sourceRecordId` 回答“这条 Queue Entry 代表哪一条持久 source record”;`from` 回答“谁写了它”。两者正交,任何一方都不能替代另一方。 + +### D4 — 单行原子 mutation + 跨 store 单调收敛 + +Queue 自身由 Lua/CAS 转换保证:`enqueue`、按 target 的 `claim/commit/restore`、整 source withdrawal、以及 `reconcileTargets`。初次多目标 admission 在**同一行的一次写入**中全有或全无;不会留下半组 target 或 ghost row。 + +实际 dispatch 跨越 Queue 与 History 两个 owner。drain 先按唯一 comparator 领取 source entry;同一 source 本轮 +冻结一个 exact target set,并按以下顺序提交: + +1. 为 target set 中每个成员并发创建/确认独立 response lifecycle,并把真实 + `dispatchRef(targetId, responseMessageId, dispatchedAt)` 写入 History; +2. 每个 target 的 receiver/ref 持久化后,在同一 source claim 内原子删除自己的 target 并启动自己的 provider; + 未完成 handoff 的 siblings 仍由原 claim 保护,最后一个 target 删除后才关闭该 row; +3. 某 target 在第 2 步前失败,只恢复尚未退役的 targets;健康 sibling 已有的 receiver/ref、provider run 与 Queue + 退役不回滚。若进程故障,重放先 join History,发现 target 已 dispatched 后只做幂等清理,绝不再次唤起。 + +这个 join 以 exact `sourceRecordId × targetCatId` 为键,并在 Memory/Redis 的 lifecycle terminal + Queue +transaction 中原子判定;不能先做一次易失 read,再凭旧快照 enqueue。A2A、connector 与恢复路径重放一个 +已消费 source 时,只允许清理残留 Queue target。multi-mention 也必须先创建正文一致的真实 Agent History +source,再将该 source 与同一 `targets[]` entry 原子 admission;callback response 只是 parent lineage,不能 +让它的 id 指向另一段只存在于 Queue 的 synthetic 正文。 + +一次 try-drain 只领取 comparator head 这一条 source;不满足 admission 条件就无副作用返回。该 source 的共享 claim +在所有 targets 已退役,或失败 target 已恢复 pending 时关闭并发出下一次普通 drain 信号,因此后一 source 不必等待健康 +target 的 provider terminal; +若下一条当时仍被 Active Run 等条件阻塞,这次尝试自然跳过,既有执行终局事件会再次触发 drain。 +同一 source 的 target set 是一次 fan-out,不以“第一个 target 完成 handoff 后再次 drain sibling”拼成并发。一个 target +进入 provider 后失败,不回滚已经被 sibling 接受的投递;每个 response bubble 独立终局。 + +Redis `order` 只索引当前待处理 entry;`messageId → entryId` 只为定位尚待投递的 source,不承担历史 receipt 查询。History 分页与头像投影只读 Message lifecycle。 + +### D5 — Steer 以实时 pending/dispatched join 为准 + +Steer 的短 claim 保护“一条 source entry + 本次选择的 pending targets”在跨 I/O cutover 期间不被普通 +drain 并发改写。它仍只是 Queue mutation fence,不把 source entry 升格为 processing,也不保存替补 run +的终局证据。中断当前回复需要调用 Agent Client,因此不能塞进 Redis Lua;中断或 admission 前失败时, +claim 按原位置恢复。某 target 一旦完成实际 dispatch,就写 History `dispatchRef` 并从 pending +`targets[]` 删除,之后由 response lifecycle 独立收敛。 + +消息默认分发与显式 Steer 是两层:默认设置只提供「排队等待」(`next_work`) 与「引导当前回复」 +(`continue_current`);产品默认是「排队等待」,用户可以在输入框左侧 `+` 菜单按全局、thread 或单次作用域显式 +覆盖,不依赖当前是否有成员回复。active 时输入区右侧只保留 Stop;有正文时再显示一个普通 Send。Send 只做一次 +Queue admission,不在客户端 admission 后追加第二次 Steer。Steer modal +固定提供「立即发送,引导回复」与「立即发送,中断回复」。前者只由该成员的静态 client capability 判定是否可选,不从 Web 当前活跃快照猜测;确认后由服务端重读 exact active parent,有父 invocation 时做非中断追加,无父 invocation 时诚实降级为普通 Queue 新运行。`exact_active_turn` 和 +`queued_internal_turn` 都可以表示这种引导能力,但只有前者证明当前可见 provider turn 已精确读取该消息。能力由服务端随 +成员信息逐成员投影,前端不得硬编码或从本地活跃态猜测。不支持时 action 禁用,原因只在 hover/focus tooltip +呈现,不增加常驻解释行;默认 `continue_current` 与显式 Steer 在无 active parent 时都诚实保留为 `next_work`,不自动中断,并由普通 FIFO drain 处理。 + +普通用户输入没有 authored `@` 时,在 source + Queue 原子 admission **之前**用 canonical resolver 绑定最近一个当前可路由的 completed responder,再退 configured default;Queue 持久化该 target,source 的 `mentions` 仍为空。只有历史/恢复 row,或 authored mention 无效且携带 routing warning 的 row,才会以 targetless 形式留在 Queue head:thread 活跃时原位等待,空闲后再用同一 resolver 绑定 target。无论哪种来源,真正投递后才追加 History dispatchRef 并 materialize source。 + +Steer modal 可多选,成员候选为 thread participants、路由目标与 fallback 的并集去重;fallback 直接读取 Queue admission 使用的同一 resolver 投影,Web 不另算。每位成员可以分别选择 guide 或 interrupt。 + +打开 modal 只读取快照,不写状态。确认时客户端提交“打开时 pending targets + 当前选择”;服务端重新读取: + +- 当前 Queue `targets[]`; +- History 中已存在的 `dispatchRefs[]`; +- 当前 membership/availability。 + +已经被未读接管或其他 dispatch 投递的成员直接跳过,不能复活;用户从旧快照取消一个已投递成员也没有副作用。仍 pending 的 remove/add 在同一 source row 上合并,新加入成员只在确认时加入 thread。普通状态变化不是整批 revision conflict;每个仍可执行 target 随后独立 guide/interrupt,失败不回滚 sibling。 + +### D6 — 副作用出口不再承担 freshness 门卫;专用 continuation 仍是显式载荷 + +F254 Phase A HELD、B1 MCP-result piggyback 与 B2 hold-ball reminder 已由 #1398 退役。callback/MCP 写工具 +是纯发送原语:不在 `post_message` 时检查 inbox、不阻止发送、不接受 `acknowledgeHeld`,也不在工具结果中 +注入读取教学。运行中正文由所选 carrier 的真实能力承接:live carrier 可向 exact active session append 或 +interrupt;单轮 CLI 不谎报 append,消息继续 pending 并由正常 FIFO drain 开下一轮。 + +这不等于删除独立的 scheduled/freshness/continuation 工作。它们若由自己的 owner 显式创建,仍以 typed Queue +payload 写一次、起跑读一次,并由对应 store 重建;这些字段是任务输入,不是 `post_message` 的隐藏状态机, +也不得重新生长成 Queue receipt。已证明纯写不读的 `freshnessRequiredFrontierMessageId` 继续删除。 + +成员接入方式只有顶层 canonical `carrier`。兼容读取只在配置边界执行 +`carrier → legacy top-level transport → cli`;下游 provider/capability/UI 不得再读旧字段或全局环境开关, +且一个 carrier 的失败不得静默 fallback 到另一个。 + +### D7 — Queue 没有 terminal row;重做是新的用户意图 + +目标一旦实际 dispatch,就从 pending `targets[]` 离开。`handled / failed / interrupted / canceled` 属于 response lifecycle,不回写 Queue;source withdrawal 则在 source lifecycle 写成 canceled 后删除尚待投递的整条 Queue Entry。 + +用户若要重做失败事项,应发送新消息或执行明确的新动作,由生产者产生新的 source/attempt。旧 response、dispatchRef 与 wait carrier 只保留原 attempt 的终态证据,不能充当 retry token。 + +### D8 — 完整正文读取就是一次真实投递 + +无 filter 的完整 thread-context 读取若要返回一个带持久 Message 的 same-target `conversation_input` queued +正文,服务端必须先验证 exact running `TurnExecution` 与同一个 `LifecycleActiveRun`,再把该 source 的 exact target 接管到现有 response: + +1. claim exact row; +2. 在 live Active Run 预留 source/entry,并将 source Message 单调推进到 `delivered`; +3. 把 source message/entry 持久写入该 response lifecycle; +4. 从 Queue Entry 的 `targets[]` 删除该 target;不写 Queue `seen/handled` 镜像。 + +History publication 失败时必须撤销 live 预留、restore claim。`active_run_missing` / `state_changed` / `lifecycle_conflict` +都是良性竞争:只从本次 payload 剔除尚未接管的 queued 正文并照常返回其余 History,不把整次读取变成 +409。只有持久层不可用才返回 503。接管一旦写入 History,后续失败不得把同一 target 放回 Queue;重放通过 dispatchRef 幂等收敛。`queued_seen` / `queued_handled` 仅可保留为兼容 telemetry 名称,不能成为 Queue 字段或恢复 authority。 + +A 读取只从同一 entry 删除 A,B 继续留在 `targets[]`;B 后续读取再删除 B。第一只猫接管后 +Message 已进入成员可见 History,其他 target 的 Queue 责任仍由同一 entry 追踪。无 Message 的行以及 +带 `actionSuccessorFence` / `waitContinuationCarrier` / scheduled/freshness/continuation custody 的行保留 read→seen +语义,不由 History 读取接管;稀疏读取、跨 thread 读取、oversized anchor 或无法证明 exact active child +的请求都不得接管。 + +本决策取代 F254 D1.2 的旧“两阶段 seen,成功后 handled”实现约束;旧段落只保留为历史设计记录。 + +### D9 — 停止是对持久执行真相的权威终态化,投影不能反过来卡住它 + +> 来源:co-creator 2026-09-03 worktree 验收决策(thread `thread_msr51149hym0i79f`)。取代 F220 Phase 3「force-reset 逃生口」作为用户概念的地位。 + +用户只有两种状态(在运行 / 未运行)和一种动作(停止:单只猫 / 全部)。**停止的成功判据是投影进入「未运行」**;进程是否真的退出由服务端在后台保证。用户不需要区分「正常停」与「强制重置」,也不需要理解逃生舱。 + +1. **「在跑」的真相 = 持久 execution/response lifecycle + 活性见证**:TurnExecution `running` 与 response lifecycle 是持久真相;Queue 的 `claimed` 只覆盖 admission 的短可回滚窗口,不能冒充执行态。tracker 槽位、`processingSlots`、session 锁只是内存缓存与活性见证,**单独不能构成 busy**。 +2. **停止阶梯(服务端自动升级,用户不选机制)**:exact 活候选 → 现有取消链(abort / `terminateExact` / 释放本猫锁与槽位 / 写终态);无活候选但目标仍投影为在跑 → 同一请求就地对账(退 pre-start 预留、running 记录置 canceled、释放孤儿锁与槽位、广播终态)并返回 `reconciled`;**对自己拥有的执行永不 409**。进程快照不完整(`ps` 失败 / owner manifest 目录损坏)时,服务端有界重试快照后仍不可用 → 走普通 dispatch terminal:该 exact execution 及其 response 行按 `failed`(reason `control_plane_unavailable`)终局,投影进入未运行,并沿 F117 Phase C/H 收敛:源消息 dispatchRef settle 到该失败 response(`settleLifecycleResponseInputs`);exact A2A caller 由引用该 response 的幂等 Queue control edge 及时唤起,其他观察仍在 dispatching member 下一次 invocation 反查该 exact 终态;pre-start 尚无 response 行时走 `delivery_failure`。重做只来自新的用户意图(INV-C7),系统不替用户重新拉起。只有持久层不可用(终态写不进去)才 503。**不做平台兼容分支**:Windows 没有 `ps`、子进程不可观测,同样落入这条 fail 分支收敛(快照不可用 = 无法确认 = failed 终局 + 后续 History observation);本 ADR 唯一保留的兼容是旧 chat message 的 `from` 读取(A.5 `messageFrom`)。 +3. **没有任何确认弹窗**:用户只关心运行态与终态,不需要理解「无法确认进程状态」。`ForceResetDialog` 退役;`/force-reset` 只作为服务端内部 thread 级对账实现,没有用户入口。终态行不复活(D7)保证迟到输出无法回写已终局的气泡;detached host 由 supervisor 收敛(F220 KD-8),有界。 +4. **同一段对账三处复用**:启动(`StartupReconciler`)、投影读取(read-repair:running 记录 + 无 tracker + 进程快照 complete 且无 owner + 超出 pre-start 窗口 → 以 `execution_owner_lost` 终态化;快照不 complete 时不动)、停止(第 2 条)。已离开 Queue 但从未安装 provider tracker 的 pre-start attempt 超时,以 `prestart_timeout` 正常失败终局;父 InvocationRecord 只有在没有 sibling tracker / process-owner 活性见证时才随 child 终局。 +5. **前端**:权威投影稳定即信;旧 socket busy 标记自愈,不再有「运行状态待确认」死角与基于卡死的强制重置入口。 + +历史根因:F220 Phase 2 报告(#972)已定性为「多 liveness SoT 无收敛点」;F194 读模型、TurnExecution 持久子真相与本 ADR 的账本已关闭进程内的绝大部分分叉,剩余只有基础设施故障(Redis / API 进程)一类,归 reconciler,不归用户。 + +## 不会简化的部分(诚实边界) + +`prestartRetirement` **不消失**。窗口是 `invocationRecordStore.create` → `invocationTracker.startAll`,中间包含 routing/visibility 预检、前缀吸收、session 准入。这个「已经 admission、但 tracker 里还没有」的空档由 **I/O 本身**造成,不是 Queue 持久状态,进不了 Lua。 + +它会从 Steer 专用标记收敛为同一次原子 Queue claim 的通用 `retiringGroupId`,用于关联一起进入 pre-start 窗口的 FIFO prefix entries;它不表示多目标 fan-out。 + +### D10 — 公开结果、失败控制边与普通 continuation 分工 + +canonical response / `delivery_failure` 是唯一公开结果,source `dispatchRef` 是其逐目标关联。不得复制 failed +正文或另造 system result。A2A target 进入 **failed** terminal 时,同一持久事务额外提交一条引用该 response、 +指向 exact caller 的幂等 Queue-only `a2a_failure` 控制载体;它只负责及时触发 caller 消费权威结果,消费 route +必须禁止递归 fail-back。completed/canceled/interrupted 不自动创建 caller work;target 显式发给 caller 的新消息 +仍是普通 dispatch,其他自动 continuation 必须由 hold/eventWait/action-successor 等显式 owner 创建。控制载体 +不登记为失败方新发起的 outbound business dispatch,也不进入失败方 caller view。 + +本约束的根因教训:Queue-only 控制载体不是第二条结果。曾把“失败只公开一次”错误推广为“失败不产生任何 +predecessor Queue work”,导致 canonical failed response 已落 History、caller 却没有触发器。单一真相源约束复制 +状态,不禁止引用权威状态的幂等调度边。 + +同理,系统诊断不能因为使用 `system_info` envelope 就自动成为 History message。未分类 warning 与 routing +fail-open `warned` 留在 telemetry;只有要求用户采取具体动作的 warning 显式声明 `user_action_required` 后才能 +持久呈现,自动重试等短暂状态只进入 live provider status。route-syntax、verdict-without-pass、void-hold 等 +Agent protocol correction 走私有 feedback / 下一 turn prompt / telemetry,不向用户对话追加提示气泡。这样 +“对话消息”由用户行动价值决定,而不是由入口类型或字符串 if/else 决定。 + +客户端单 runtime 在 caller 串行 slot 中维护一份**该成员自己的 outbound dispatch view**。它以 +`(owner, thread, caller, sourceMessageId, targetId)` 为 item key。每个 item 保存严格单调的 `revision`、最近一次 +成功进入 caller prompt 的 `presentedRevision`,以及当前 Queue/History 已无法反查的最小解释事实: +`firstAddedBy(initial|steer|unknown)`、`selectionChange(added|removed)` 与 +`selectionChangedBy(initial|steer|queue_withdrawal|unknown)`。这些字段不参与执行裁决;当前进程首次 +看到重启前遗留 target 的 remove/actual 事件时,最初来源不可证明,必须写 unknown 而不是猜 initial: + +这里的 source identity 不能被执行优化抹平。`M1→B` 与 `M2→B` 永远是两个 view items、两条分别属于 +M1/M2 的 dispatchRefs;Queue admission 逐 source 处理,即使其它显式机制让一个 response 反向引用两条 source, +也不能把 source lifecycle 合并成一次 attempt。相同 source 的 B/C 则共享一个 Queue row,但各有独立 ref/result。 + +- 一条 Agent source 成功提交 Queue admission 时,把当时的 exact targets 加入 view;同一 caller 随后发送新的 + source,只追加新 source×target items,不覆盖前一条仍 open 的 dispatch; +- Steer 对尚未 actual dispatch 的 target 做 remove/add 时,按已经提交的 Queue mutation 更新同一 item revision 与 + selectionChange;C 离开 Queue 后仍可说明“由 Steer 移除”,E 可说明“由 Steer 新增”; + 已 actual dispatch 的 target 仍由 History ref 裁决,不能被旧 Steer snapshot 改写为未投递; +- actual admission、direct `delivery_failure` 与 response terminal 只触发对应 item 的 revision 更新;dispatchRef + 关联已经发生的投递或投递失败事实,不代表 target 预期,也不能仅凭 ref 存在断言 provider 已收到消息;实际状态和 + response identity仍在读取时从 Queue + source `dispatchRefs[]` + canonical response 反查,view 不复制它们。 + +因此 `A→B/C/D` 进入 durable Queue 后,Steer 移除 C、保留 D、加入 E,可以在 A 的 view 中表达为:C 未投递且已移除,D 已实际 +投递,E 是新增待投递/已投递;B 随后在 actual admission 前失效时通过 canonical `delivery_failure` 表达。无法解析出 canonical +targetId 的 mention 只进入 routing diagnostic;durable Queue admission 前被 routing preflight 拒绝的 canonical target +由本次发送结果立即返回 typed fail-back,二者都不伪造 view item。初始 admission、Steer 与 actual result 都调用同一 +view update primitive,不为各入口建立平行状态机。 + +caller 下一次自然 invocation 先对所有保留 items 逐项反查:Queue 仍含 target 表示 pending,History ref 为 open +表示 executing,settled ref 读取 canonical terminal response;最近一次已提交 view mutation 为 remove 且没有 actual +ref 时表示 not_delivered/withdrawn。校验候选快照仍有效后,以 canonical fingerprint 变化推进 revision,再只把 +`revision > presentedRevision` 的未呈现变化加入 prompt。投影开始时冻结 `includedRevision`,最终正文必须绑定该快照;异步查询期间 revision +变化时必须重读或保留待核对,不能读取最新 revision 给旧正文补标签。item 清理后同 key 重建也必须分配严格更大 +的 revision/epoch,不能复用已确认身份。 + +caller invocation 成功 completed 后,pending/open item 只把 `presentedRevision` 推进到 `includedRevision`,item +继续保留但未变化时不再重复注入;terminal/withdrawn 仅在 current revision 仍匹配时 compare-and-clear。若 prompt +已包含 C removed@rev2,而完成前 C 又被重新加入为 rev3,旧 invocation 只能确认 rev2,不能清掉 rev3。预算裁剪、 +prompt 建成后的新 revision、失败或取消都不确认。 + +这份 view 是可丢弃的客户端运行时派生状态,不持久化,不改变 source message 或 `dispatchRefs` 的既有语义,也 +不成为第四本账;Queue 仍是 pending truth,History 仍是 actual result truth。它的生命周期天然由“入口追加/更新 +revision → caller 成功观察 terminal/withdrawn revision 后清理 → 新 source 或新 mutation 再追加”闭合。 + +runtime restart 后不自动重建这份进程内观察集合,也不扫描 History。actual result 仍可从 canonical History +查询;旧 runtime 独有的 firstAddedBy、Steer 增删说明与 presentedRevision 不保证恢复。每个 caller scope 在下一次自然 invocation +获得一条固定大小的 `process_start` 说明:当前 observation 只覆盖本进程登记的 dispatch,更早结果仍可按需从 +canonical History 读取。说明不推断旧状态、不创建 Queue work、不清理当前指针;只有实际进入最终 prompt 且 +caller 成功 completed 后才确认,失败或取消后重试,同一 process generation 成功交付后不重复。多 worker/跨节点 +部署若要自动观察跨进程结果,必须另行建立可重建查询或持久交付证据,不能把当前进程缓存升级为第四本账。 + +## 顺带删除的死码 + +1. **`PREEMPT_PENDING_PRESTART` 分支不可达**:`preemptSteerTarget` 三个成功出口(queue.ts:442/451/472)全部 `deferred: false` → `:902` 与 `:1062` 的 `if (preemption.deferred)` 恒假。 +2. **`freshnessRequiredFrontierMessageId`**:见 D6。 + +## 预期结果 + +- 持久 `QueueLedgerEntry` 一条 source 一行;`targets[]` 与 `authorIntentByTarget` 只覆盖尚待投递成员,`delivery` 不保存执行/终态回执 +- 删除模块:`QueuedMessageCustodyStartupReconciler` + `StartupQueueEntry` + `CarrierProjection`(436 行)、`queue-entry-settlement.ts`(49 行)、`convergeZombieQueue.ts`(87 行)、`exactSteerBatch` 预留 Map 与三段式 API,以及依赖 Message custody 复活 terminal attempt 的 Gate 5 retry bridge +- `QueueProcessor` 中的同步/CAS/rollback/restore 大部分消失 + +## 迁移 + +Redis Queue schema v2 在每个 thread 首次访问时原子升级: + +- v1 `queued` 与遗留短 `claimed` 行按 `sourceRecordId` 合并为一个 v2 entry,保留最早顺序、pending targets 与逐目标作者意图; +- v1 `processing/terminal` 不重新入队,避免把已经 admission/终局的工作复活; +- `order` 与 `messageId → entryId` 索引在同一 CAS 脚本中重建;并发写导致快照变化时重读重试,不能在半迁移状态继续运行。 + +## 一致性代价 + +`deliveryStatus` 与队列条目状态分处两地,但 `deliveryStatus` 只有 3 态且**单调**(queued → delivered/canceled),出队时一次写回即可,不需要持续同步。 + +--- + +## 附录 A:设计评审修订(2026-09-02,co-creator 逐条 push back) + +RFC 是「没看代码、不拘泥实现的理论目标」,因此不能照搬。以下为逐条评估结论。 + +### A.1 字段数不是目标函数:43 个扁平/镜像字段 → 约 20 个顶层字段 + 3 个职责命名空间 + +实现后复核确认:RFC 的 11 字段是概念模型,不应被当作 TypeScript interface 的行数 KPI。`QueueLedgerEntry` 把载荷与运行参数放入 `payload` / `execution`,把 pending-only 作者意图放入 `delivery`。真正必须消灭的是重复 lifecycle 真相,而不是为了数字把仍有运行语义的字段藏起来。 + +**RFC 漏掉的 3 条运行时约束(必须保留)** + +| 字段 | 为什么 RFC 没看到 | +|---|---| +| `owner` | RFC 引入 `from` 治好了 author 一侧,但 grep `ownerUserId` / `owner principal` / `owner scope` 在 RFC 中**零命中**——它想的是「谁发的」,没想「这是谁的队列」 | +| `status` 的 `claimed` 态 | RFC 认为「仍在 Queue 就表示尚未 dispatch」,隐含出队即离开。但 Steer 必须先 `cancel` 正在跑的 invocation,**cancel 是 I/O 进不了 Lua**,失败必须原位回滚 → 需要可回滚的中间态 | +| `claimedAt` | 出队到起跑之间存在异步窗口;进程内的重复工作/活跃性判定用它识别 stale claim。当前不做进程内 sweep:重启恢复会无条件把遗留 `claimed` 行恢复为 `queued`,因此这是观测与 restart-only 恢复证据,不是后台回收租约 | + +**RFC 建议但评估后不采纳的 2 条** + +- **freshness 4 字段搬进 payload metadata**:RFC 原意是「不要扩张 `from` 或 lifecycle `kind`」。这 4 个是独立顶层字段,未扩张二者,不违反该条。而 `QueueProcessor.ts:4951/5163` 起跑时直接读取,搬家只换访问路径、读法不变——纯形式主义,收益 0、需改 payload schema。 +- **删 `sourceCategory`**:RFC 反对的是「把 ci/review/a2a 当成与 user/Agent 并列的**身份类型**」。它当前不是 `from` 的一部分,只是独立来源标记,F175 用于视觉分组。RFC 反对的形态与现状不是一回事。 + +### A.2 `owner` 必须是判别 union,不是裸 id + +co-creator 在评审中发现的 RFC 空缺。裸 `userId` 已被塞入伪用户命名空间: + +```ts +export const SYSTEM_USER_IDS: ReadonlySet = new Set(['scheduler', 'system']); // visibility.ts:13 +return SYSTEM_USER_IDS.has(msg.userId) && (msg.catId === 'system' || msg.catId === null); // :23 +``` + +加上 25 处硬编码 `userId: 'system'`。**这正是 `from` 判别 union 要治的病,只治了 author 一侧。** + +```ts +type QueueOwner = + | { kind: 'user'; userId: string } + | { kind: 'system'; service: string } // 复用 RFC `from` 已有的 system 变体形状 +``` + +职责分离:`from` 回答「谁发的」,`owner` 回答「这条工单在谁的 scope」。两者正交,不得互相代替(否则违反 L1)。 + +收益:删除 `SYSTEM_USER_IDS` 集合与 `visibility.ts:23` 的字符串反推;消除「真实用户恰好叫 system」的命名空间碰撞。 + +### A.3 entry.id 只由来源持久 id 确定性派生,持久行不再保存 `idempotencyKey` / `continuationKey` + +现状:`id: randomUUID()`(`InvocationQueue.ts:449`)与来源持久 id 完全脱钩,因此另开 `idempotencyKey` 字段 + `:411-414` 线性扫描去重。 + +三种 kind 都有天然确定性 id: + +| kind | 确定性 id | 现状 | +|---|---|---| +| `conversation_input` | `sourceRecordId` | 统一采用 | +| `message_wake` | `messageId` 对应的 source identity | 统一采用 | +| `private_input` | 生产者持久 id(`action:{leaseId}:{gen}:{cat}`、closure/supplement id) | **已在用**,但塞进了 `idempotencyKey` | + +**决策**:推广确定性 id 到所有来源:`queueEntryId(sourceId)` 只由来源持久 id 派生;target 是行内 pending set,不参与主键。`idempotencyKey` / `continuationKey` 仍可作为生产者命令输入来确定 `sourceId`,但不再进入持久 Queue row。去重从应用层线性扫描降级为存储层主键冲突。 + +### A.4 §6.4 前缀批处理已退役 + +早期实现允许一次 dispatch 领取多条相邻 entry,并曾出现拼接正文的缺陷。最终模型进一步收敛为一次 try-drain +只领取一条 source entry:相同 target 也不触发 Queue 合批;每条消息的 identity、正文、顺序、ref 与 caller view +天然独立。Active Run 的未读接管仍可按 exact refs 接受多条已发布输入,但它不是 Queue 前缀批处理。 + +### A.5 author 身份统一由 resolver 读取;存储 owner userId 不冒充 author + +**初版结论「本轮只治内核内 188 个签名、内核外 734 个不动」已作废——分母用错了。** + +`packages/api/src` 共 922 个 `userId: string` 签名,但其中绝大多数是**真实用户 id,本来就该是 string**,改成 union 反而引入错误。真实缺陷面只有伪用户污染那条路径: + +| 缺陷面 | 数量 | +|---|---:| +| 硬编码 `userId: 'system' / 'scheduler'` 写入点 | 25 | +| `SYSTEM_USER_IDS` / `isSystemUserMessage` 消费点 | 56 | +| 裸 userId 与 `'system' / 'scheduler'` 比较 | 28 | +| **合计** | **≈ 109** | + +上述统计混合了两种不同语义:消息作者身份与存储 owner/tenant/thread ownership。后者本来就是 user id,不能机械替换成 `MessageFrom`。 + +**前置条件**:这些站点多数已是双分支形态—— + +```ts +msg.from ? msg.from.kind === 'system' : msg.userId === 'scheduler' && msg.catId === null +// ↑ 新路径(首选) ↑ legacy fallback +``` + +`from` 已是首选路径,裸比较只是 legacy 记录兜底。但 `MessageStore.ts:228` 为 `from?: MessageFrom`(可选),且全仓无通用回填(仅 `requireCanonicalMessageFrom` 会抛错,只用于 fanout)。因此直接删 legacy 分支会误判历史记录。 + +**方案(co-creator 提出,取代初版「回填 → 必填 → 删分支」三步):单一 resolver,不动数据。** + +```ts +from?: MessageFrom; // 兼容旧 Redis row,读模型暂时可选 +messageFrom(msg): MessageFrom; // 作者身份的统一访问路径,返回必填 union +queueOwner(entry): QueueOwner; // Queue scope 的统一访问路径 +``` + +`messageFrom` 内部:有 `from` 直接返回;缺失则按 legacy 字段(`source` / `userId` / `catId` / `origin`)推导并封装。推导规则不是新发明——是把历史读取逻辑收进一处: + +- `source?.connector` → `{ kind: 'external', connectorId }` +- `SYSTEM_USER_IDS.has(userId) && (catId === 'system' || catId === null)` → `{ kind: 'system', service }`(依据 `visibility.ts:23/50`、`:272` 的 `origin === 'briefing'`) +- `catId` 非空 → `{ kind: 'agent', catId }` +- 其余 → `{ kind: 'user', userId }` + +新 Message 写入必须携带 `from`;author 判定统一调用 `messageFrom`。`SYSTEM_USER_IDS` 仅保留在 thread/tenant owner 的兼容边界,不再用于反推消息作者。同样形状套用于 Queue scope:持久行必须带 `owner` 判别 union,`queueOwner` 只为旧的进程内调用形状提供集中 fallback。 + +**为何优于初版三步**:初版试图在**存储层**达成不变量(全量回写 message),本方案在**访问层**达成。类型强度相同(调用方拿到的永远是必填 union),但没有不可逆写操作的风险;存量数据自然老化,新写入均带 `fromRaw`,将来 fallback 分支自然成为死码,届时删除为零风险。 + +**关于门禁**:不新增字符串扫描 gate。新写边界要求 `from`,持久 Queue row 要求 `owner: QueueOwner`,Redis hydrate 再做运行时 shape 校验;兼容只留在 resolver。存储 owner 的 `userId` 仍是合法且必要的,不应被 lint 误报。 + +**Scheduled trigger 契约(R4 裁定,sol adjudication / Fable 三审)**:scheduler 模板(reminder / web-digest browser path)写出的 trigger message,`from = { kind: 'system', service: 'scheduler' }` 表达作者身份;存储 `userId` 必须是**已验证的 trigger owner(`triggerUserId`)**,Queue owner 与之一致。`userId: 'scheduler'` 会在 canonical ingress fence(`ConnectorInvokeTrigger` 的 source owner vs queue owner 强校验)处被拒,不会进入 ledger。行为回归由 `test/scheduler/scheduled-trigger-owner-fence.test.js`(真实 template + createDeliverFn + ConnectorInvokeTrigger 组合)钉住。 diff --git a/docs/features/F039-message-queue-delivery.md b/docs/features/F039-message-queue-delivery.md index 308ce159b6..b712256632 100644 --- a/docs/features/F039-message-queue-delivery.md +++ b/docs/features/F039-message-queue-delivery.md @@ -4,6 +4,7 @@ related_features: [F117] topics: [message, queue, delivery] doc_kind: note created: 2026-02-26 +tips_exempt: 2026-09-03 ADR-043 retirement updates the existing Queue delivery contract without adding a new user action or discoverable capability --- # F039: 消息排队投递 — 用户操作三模式 @@ -12,6 +13,8 @@ created: 2026-02-26 > **Created**: 2026-02-26 > **Completed**: 2026-02-28 +> ⚠️ **队列内核设计已于 2026-09-02 收敛。** 本文档中关于队列条目状态、per-target 投递簿记与 Steer 预留的描述反映的是收敛前的实现。当前设计真相源:[ADR-043](../decisions/043-queue-durable-single-ledger.md) 与 [F117 Phase D](F117-message-delivery-lifecycle.md)。本文档保留作为演进历史。 + ## Why - 2026-02-26 operator口述 @@ -53,11 +56,11 @@ created: 2026-02-26 - **描述**:Codex 原生队列有 "Steer" 按钮:当有消息在消息队列里时,把其中一条“拉出来”立即处理(弹窗 1/2) - **状态**:✅ 已实现为独立 Feature **F047**(PR #101) -## Out of Scope / 后续能力(不阻塞 F039) +## 后续演进(已由 ADR-043 完成) ### 队列持久化到 Redis(进程重启不丢队列) -- **现状**:队列内存态,进程重启会丢失“排队中”的条目(消息正文仍在 MessageStore,可见但不再自动执行)。 -- **备注**:若要做,需要同时定义“重启恢复语义”(包括:in-flight invocation 的 orphan 处理、processing 条目的回滚/重试、pause 状态恢复等)。 +- **当前**:Queue 已是按 thread 独立持久化的有序 ledger;启动直接 hydrate active rows,不再从 Message 重建。 +- **终态语义**:失败、取消、中断都会提交 terminal receipt 并离开 active order,不会把已出队消息塞回队列。详见 ADR-043。 ## Risk | 风险 | 缓解 | diff --git a/docs/features/F047-queue-steer.md b/docs/features/F047-queue-steer.md index 15f74d985e..fcc7f8ce4d 100644 --- a/docs/features/F047-queue-steer.md +++ b/docs/features/F047-queue-steer.md @@ -1,13 +1,12 @@ --- feature_ids: [F047] -related_features: [F039] +related_features: [F039, F117, F175] topics: [queue, steer, ux, chat] doc_kind: note created: 2026-02-28 -tips_exempt: existing Queue UI contract correction; no new cat-facing capability or workflow to teach --- -# F047: Queue Steer(取消当前轮并以同一消息立即重启) +# F047: Queue Steer(逐成员立即引导或中断回复) > **Status**: done(2026-07-12 语义修订) | **Owner**: Maine Coon/Maine Coon(Codex) > **Created**: 2026-02-28 @@ -18,60 +17,102 @@ tips_exempt: existing Queue UI contract correction; no new cat-facing capability ## Why -operator在 Codex 原生体验中使用 **Steer**:当消息在队列里等待时,点击 Steer 会让“那条排队消息”立刻进入猫的处理流程(而不是只能撤回/重排/再发一条)。 +operator 在 Codex 原生体验中使用 **Steer**:当消息在队列里等待时,点击 Steer 可以把同一条持久消息立即送给一个或多个成员,并逐成员选择“引导当前回复”或“中断当前回复”,而不是只能撤回、重排或再发一条。 ## What -- 在 QueuePanel 的 **queued** 条目上新增 **Steer** 按钮 -- 点击后只有一个 Steer 动作:取消目标猫当前 invocation(如有),并以**同一条持久 Queue 消息**立即启动一次 +- QueuePanel 的 **queued** 条目显示 **Steer** 按钮 +- 弹窗候选取 thread participants、消息路由目标与 Queue fallback 的去重并集;已处理目标可见但禁选 +- 支持多选,并为每个选中成员分别选择“立即发送,引导回复”或“立即发送,中断回复” +- 历史/恢复或无效 mention warning row 的 targetless 绑定与 `targets[]` 增删在同一持久 mutation 完成;普通无 `@` 用户输入已在入队前绑定 target;随后各 target 独立投递和终局 - 普通重排继续由 drag/move API 独立提供,不再借用 Steer 名称 ## Acceptance Criteria - [x] AC-A1: 本文档需在本轮迁移后维持模板核心结构(Status/Why/What/Dependencies/Risk/Timeline)。 - [x] `queued` 条目显示 Steer(`processing` 不显示) -- [x] Steer 弹窗明确告知“取消当前轮并以同一消息立即重启”,且可取消操作 -- [x] 有猫在跑时先 cancel,再以被 Steer 的 exact Queue entry 启动一次;空闲时直接启动同一 entry +- [x] Steer 弹窗始终显示“立即发送,引导回复”与“立即发送,中断回复”;静态 client capability 不满足时禁用引导并说明原因,当前活跃快照不参与弹窗能力判定 +- [x] 候选集合包含 participants、路由目标与 exact Queue fallback;默认选中尚待处理的路由/fallback 目标,已处理目标禁选 +- [x] 多目标选择在确认时原子更新同一 source entry 的 pending `targets[]`;每个 target 的 guide/interrupt 动作和终局彼此独立 +- [x] “引导回复”在服务端确认时重读 exact active parent invocation;父 invocation 存在时,`exact_active_turn` 可证明当前可见轮次读取,`queued_internal_turn` 则追加到同一运行的下一内部轮次;父 invocation 不存在或不能引导时保留普通 Queue 语义且绝不自动 cancel +- [x] “中断回复”有猫在跑时先 cancel,再以被 Steer 的 exact Queue row 启动;空闲时直接启动同一 row - [x] `{ mode: "promote" }` 被 API 拒绝;重排只走独立 move/reorder 交互 - [x] Steer 不创建 supplement 或第二个 later carrier -- [x] 具备 API/Web 测试覆盖(至少:权限、409 processing、promote reject、默认 immediate) +- [x] 具备 API/Web/Redis 测试覆盖(权限、stale/terminal/membership conflict、原子 fan-out、逐成员 mixed strategy、promote reject、默认 immediate) ## Implementation ### Backend -- Endpoint: `POST /api/threads/:threadId/queue/:entryId/steer` -- Body: 空 body 或 `{ "mode": "immediate" }`;其他 mode 返回 400 +- `GET /api/threads/:threadId/queue/:entryId/targets`:将 Queue pending targets 与 History 已投递 refs 联结为逐成员投影 +- `POST /api/threads/:threadId/queue/:entryId/targets`:重读 live truth,跳过已投递成员,并原子 reconcile 同一 source entry 的目标集合与逐成员策略 +- `POST /api/threads/:threadId/queue/:entryId/continue`:把 source entry 的 exact target 引导进当前回复;exact run 已变化时保留为排队等待 +- `POST /api/threads/:threadId/queue/:entryId/steer`:执行单个 target 的中断回复动作 +- `/steer` body 为空或 `{ "mode": "immediate", "targetCatId": "..." }`;其他 mode 返回 400 - Rules: - 404 if entry not found in current user scope - - 409 if entry is `processing` (processing steer out-of-scope) - - `immediate`: cancels active invocation (same user) and starts processing via QueueProcessor + - 409 if another action holds the short claim; a target delivered while the modal was open is skipped idempotently + - target mapping rereads History dispatch refs plus current availability/membership before any target action + - `continue`: append only to the exact supporting Active Run; otherwise keep normal Queue work + - `immediate`: cancel the exact target invocation (same user) and dispatch that target from the source entry via QueueProcessor - WS: immediate execution follows normal Queue processing updates; no `steer_promote` action exists ### Frontend - `QueuePanel` queued entry row adds **Steer** button -- Modal offers one explicit action: 取消当前猫,并以同一条消息立即重启 +- Modal lists the deduplicated member projection, permits multi-select, and stores one strategy per selected target +- Static guide capability comes from the configured Agent Client via the server cat projection; Web must not hard-code it or infer it from recent speech +- Queue rows do not duplicate capability explanations; the modal owns the selectable capability surface ### Reorder(F175 扩展) F175 在 Steer 基础上扩展了用户可控编排能力: - **Drag & Drop 排序**:QueuePanel 支持拖动排序(`@dnd-kit`),拖拽后通过 `PATCH /queue/reorder` 批量设置 position -- **Reorder API**:`PATCH /api/threads/:threadId/queue/reorder`,body: `{ positions: [{ entryId, position }] }` -- **排序语义**:显式 position(用户手动拖动)仅在同 user 内覆盖 `priority`,未手动排序的 entry 仍按 `priority > createdAt` +- **Reorder API**:`PATCH /api/threads/:threadId/queue/reorder`,body: `{ expectedQueueRevision, orderedVisibleEntryIds }`(RFC #1356 §4.1 `ReorderVisibleEntriesCommand`)。前端提交同一 snapshot 下**完整的 visible row 顺序**;服务端校验 revision 与集合一致后原子写入 `position=0..n-1`,revision/集合/eligibility 任一变化即整批 typed conflict,不做 partial write +- **排序语义**:唯一 comparator 为 `position presence → position → priority(urgent before normal) → enqueuedAt → id`。隐藏 rows 不被客户端寻址,仍按自身 priority/FIFO 排序 - **Optimistic UI**:前端立即按 comparator 重排,失败时 rollback ## Key Decisions -- Steer 不改动消息内容,也不表示 promote / supplement;它只做 cancel + exact-message restart +- Steer 不改动消息内容,也不表示 promote / supplement;它只更新同一 source Queue Entry 的 pending targets,并对 exact target 做 guide 或 cancel + restart +- 多目标 initial admission 是 all-or-none;cutover 后 sibling 独立,某 target 启动失败不取消已被其他 target 接受的动作 +- mixed strategy 是逐 target 的 UI 表达,不是 Queue 账本的新状态机;账本只保存一条 source row、pending `targets[]` 与逐目标 author intent +- 打开弹窗只读;新增 thread member 只在确认时写入。确认时重读 live state:弹窗打开后已投递的目标直接跳过,移出 thread 或不可用的新增目标不准入;旧快照不能复活已投递 target - 排序是独立的 Queue 控制面,不属于 Steer - `processing` 不提供 Steer:运行中纠偏属于更大能力(需要运行中注入/重路由),本 feature 不扩大范围 +## 设计现状(2026-09-02 校准,见 ADR-043) + +> **本节记录已落地的 ADR-043 语义。** + +旧实现的 interrupt Steer(cancel + 以同一 exact entry 重启)仍保留,但不再是弹窗唯一动作。旧实现的三段式预留 +(`reserveExactUserEntry` → `beginExactSteerPreemption` → `activateExactSteerReservation`)已经删除。 + +按 [ADR-043](../decisions/043-queue-durable-single-ledger.md) 已收敛为: + +- **两步,不是三段**:Lua 原子 claim exact target(`queued → claimed`)→ cancel 正在跑的 invocation(I/O,可能失败)→ actual delivery 时从 `targets[]` 删除该 target,失败则 Lua restore(`claimed → queued`,原位)。Queue 不持久化 processing。 + 不能压成一步的唯一原因是 `invocationTracker.cancel` 是 I/O,进不了 Lua。 +- **`exactSteerBatch` 删除**:它防的「F175 吸走相邻条目」在 `QueueProcessor.ts:5382-5383` 已与持久的 `steerRequestedByCatIds` 重复检查;原子 claim 后其余用途消失。 +- **条目只保留短暂 mutation 证据**:`steerRequestedAt` 用于请求 cutover;替补 run 的 identity 与 processing/terminal 归 TurnExecution / response lifecycle,不回写 Queue。 + +### Interrupt 动作的不变量 + +对已经完成 target mapping 的单个 interrupt action,唯一应当拦截的 Queue 生命周期场景是“同一 source entry 已被另一个短 claim 占用”→ 409 `ENTRY_PROCESSING`。已经实际投递的 target 由 History `dispatchRef` 识别并幂等跳过,不是整批冲突。 + +target mapping 自身还必须联结 History actual-dispatch、membership 与 availability;这些是确认时的资格 fence,不是 interrupt claim 的第二套生命周期。其余 interrupt 拒绝只允许是通用鉴权/归属/schema guard、系统固定位置约束,或真实基础设施失败(例如取消 I/O 失败)。旧三段式产生的 `STEER_STATE_CHANGED` / `STEER_RESERVATION_LOST` / `STEER_RESERVATION_PERSIST_FAILED` / `QUEUE_BUSY` / `PRESTART_STATE_CHANGED` 已消失。并发抢先 claim 统一投影为 `ENTRY_PROCESSING`。 + +新增任何 Steer 拒绝分支前,必须先证明它不是上述两类产物。 + +### 已确认的死码 + +`PREEMPT_PENDING_PRESTART`(202)分支不可达:`preemptSteerTarget` 三个成功出口(`routes/queue.ts:442/451/472`)全部返回 `deferred: false`,故 `:902` 与 `:1062` 的 `if (preemption.deferred)` 恒假。「先预留、稍后投递」模式早已被收敛为同步。 + ## Risk / Blast Radius -- **状态机复杂度**:立即执行会触发 cancel → 需要确保 queue 不被错误 pause -- **并发/互斥**:需要保持 QueueProcessor mutex 语义,不允许同 thread 并发执行两条 +- **原子映射**:历史/恢复 targetless binding 与 target-set reconcile 不能留下 ghost/半组 targets +- **状态机复杂度**:interrupt 会触发 cancel → 需要确保 queue 不被错误 pause;guide 不得暗中升级成 cancel +- **并发/互斥**:需要保持 QueueProcessor mutex 语义;逐 target 动作可以独立终局,但同一 target 不能 double-start ## Review Gate diff --git a/docs/features/F086-cat-orchestration-multi-mention.md b/docs/features/F086-cat-orchestration-multi-mention.md index b1334d7719..53b0a04b44 100644 --- a/docs/features/F086-cat-orchestration-multi-mention.md +++ b/docs/features/F086-cat-orchestration-multi-mention.md @@ -6,13 +6,15 @@ topics: [collaboration, routing, mcp, multi-mention, orchestration, meta-cogniti doc_kind: done created: 2026-03-08 completed: 2026-03-09 -tips_exempt: "Renewed 2026-09-04 for the current F308 public-link projection only; no user-facing capability or action changed." +tips_exempt: "Renewed 2026-09-09 for the F117/ADR-043 lifecycle calibration: exact-target replay prevention, Queue/History identity convergence, and MCP Freshness/HELD retirement harden existing multi-mention delivery without adding a new user-invokable action or discovery surface." --- # F086 Cat Orchestration — 猫猫自主协作 + 元认知系统 > **Status**: done | **Owner**: 三猫 +> ⚠️ **2026-09-20 delivery calibration**:本文关于 `WorklistRegistry`、独立 multi-mention 状态机、per-target `TargetStatus` 与 read/handled Queue receipt 的段落只保留为演进历史。当前所有文本 `@`、structured multi-mention、用户与 connector 输入都进入 F117 / ADR-043 的单一 durable Queue:一条 source 一条 entry,`targets[]` 只保存 pending recipients,actual dispatch / response terminal 归 History lifecycle。当前真相源见 [F117](F117-message-delivery-lifecycle.md)、[ADR-043](../decisions/043-queue-durable-single-ledger.md) 与 [A2A protocol](../architecture/a2a-protocol.md)。 + ## Why ### 核心痛点(operator采访 2026-03-08 20:25-20:36) @@ -57,6 +59,14 @@ operator experience: **目标**:最小闭环的多猫协作工具。 +> **2026-09-09 F117/ADR-043 current calibration**:一次 multi-mention 先创建一条真实、公开、可引用的 +> Agent source message;它的正文必须与 Queue payload 完全一致,`replyTo` / causal trigger 指向发起者当前 +> response。该 source message 与一条 `targets[]` 只含 pending recipients 的 Queue entry 原子提交。 +> `sourceRecordId` 永远指向这条真实 History source;不得把发起者 response 的 id 绑给另一段 synthetic +> `[Multi-Mention ...]` Queue-only 正文。每个 target 的 actual delivery 仍只写 History `dispatchRefs`,已投递 +> target 的重放幂等跳过。MCP `multi_mention` 本身是纯发送原语,不顺便检查发起猫 inbox、不 HELD,也不附加 +> freshness 补救教学。 + #### MCP 工具设计 ```typescript @@ -265,6 +275,8 @@ operator指出:Ragdoll在采访时没有先搜索了解知识体系现状就 - [x] 超时默认 8m(3~20m 可配),超时立即回流已有结果 - [x] 审计 envelope:initiator/callbackTo/idempotencyKey/triggerType/searchEvidenceRefs/overrideReason - [x] 可观测性:状态机变迁日志 + 审计字段(structured log in route handler) +- [x] F117 calibration:multi-mention 的 Queue source 是一条正文一致、可引用的真实 Agent History message,且 Message + single-source `targets[]` entry 原子 admission;callback response 只作 parent lineage +- [x] exact replay:已存在 `sourceRecordId × targetCatId` dispatch 时不重复唤起;MCP 工具不承担 inbox freshness/HELD 副作用 - [x] 上线验收指标: - 回流成功率 ≥ 90%(done/(done+failed) 在首 20 次调用内) - 超时率 ≤ 20%(timeout/total) @@ -406,9 +418,9 @@ F086 multi_mention(MCP 工具): --- -## Follow-up: Per-Target Queued Message State (ADR-040) +## Historical follow-up (superseded): Per-Target Queued Message State (ADR-040) -> Added 2026-07-01 | Source: F254 D1.2 dogfood + 斑斑/Maine Coon consensus +> Added 2026-07-01 | Superseded 2026-09-02 by ADR-043 / F117 Phase D. The checklist below is not pending work. F254 实测发现 queued 消息的 read/handled/target consumed 语义混在一起,导致重复唤醒或多目标吞并。ADR-040 统一定义了四层状态分离模型。 diff --git a/docs/features/F117-message-delivery-lifecycle.md b/docs/features/F117-message-delivery-lifecycle.md index d0729064c3..ffcb3d760e 100644 --- a/docs/features/F117-message-delivery-lifecycle.md +++ b/docs/features/F117-message-delivery-lifecycle.md @@ -1,15 +1,16 @@ --- feature_ids: [F117] -related_features: [F039, F264] +related_features: [F039, F086, F122, F167, F173, F183, F233, F254, F264, F280] +related_decisions: [043] topics: [message, queue, delivery, lifecycle, context] doc_kind: spec created: 2026-03-14 -tips_exempt: automatic owner-timeline and cat-delivery consistency hardening; no new user action or standalone capability to teach +tips_exempt: 2026-09-10 Phase G moves managed-hold recovery onto the existing History/Queue/response lifecycle and retires two internal completion tools; it adds no user-invokable capability to teach --- # F117: Message Delivery Lifecycle — 消息投递生命周期真相源 -> **Status**: done | **Owner**: Ragdoll + Maine Coon | **Priority**: P1 +> **Status**: implementing — PR #1398 已收敛单 source Queue Entry、History actual-dispatch lifecycle、carrier 能力真相、managed-hold 统一恢复与 caller runtime outbound dispatch view;exact-HEAD 本地跨族代码复审已通过,co-creator 已完成基础旅程 1–5 验收,剩余完整 worktree UAT、fork soak 与上游 owner gates | **Owner**: Ragdoll + Maine Coon | **Priority**: P1 > **community_issue**: [#20](https://github.com/zts212653/clowder-ai/issues/20) ## Why @@ -20,18 +21,73 @@ operator 2026-03-14 实测发现:queue 模式发送消息后立即取消,该 **2026-03-14 原始 invariant**:`undelivered user messages MUST NOT appear in timeline, history API, or prompt context.` -**2026-07-21 契约演进(F264)**:原句把“operator查看自己已持久化的消息”和“猫获得正文”混成一个 -visibility 开关,已被 F264 的 per-target receipt 契约细分: +**2026-09-07 最终契约**:F264 曾把 operator publication 与 cat delivery 分开,但仍让同一 queued user message 同时占有 Queue 与 owner-only History,造成两套顺序与 receipt 双写。ADR-043 最终退役这层兼容: -- durable queued user message 从 admission 起留在**operator的浏览器时间线**原位,并显示真实 Queue receipt; -- 同一消息在 exact delivery 前仍不得进入猫的 callback/thread-context/prompt/pending-mentions; -- canceled 消息同时从 live 时间线与 F5 history 消失; -- `deliveryStatus` 继续表示 cat delivery,不再兼任 owner timeline publication。 +- durable queued user/external message 只在 Queue Panel;第一次 actual dispatch 后才以同一 `sourceRecordId` 进入 History; +- 一条 source message 永远只有一个 Queue Entry,`targets[]` 只保存尚未投递的成员; +- target 已发生的投递或投递失败事实只由 History `dispatchRefs` 表达;ref 不是预期 target receipt,且仅凭 ref + 存在不能断言 provider 已收到消息;Queue 不保存 processing/terminal/seen/handled/receipt; +- Agent caller 在单 runtime 内维护自己的 outbound dispatch view,汇总该成员发出的 source×target 预期、Steer 变更与实际结果;它不改写 Queue/History,也不持久化为第四本账; +- canceled 且从未 actual dispatch 的输入不会进入 History 或 cat context。 operator experience: > "前端不应该显示你们真正没有收到的消息,对吧?" > "当我发了一个正在队列的消息的时候,我的用户气泡这里先不显示,等到你们真的收到这个消息的那一刻,再在正确的地方插入这个气泡" +## User Journey + +**Scope unit:一条 source message 从进入 Queue 到所有目标各自取得终局。** + +1. 用户、Connector 或成员发送一条消息;若它尚未实际交给任何成员,Queue Panel 只显示一条可恢复的 source entry,Chat History 不复制未投递的用户/外部消息。 +2. `targets[]` 只列仍待投递的成员。Agent caller 成功提交 source Queue admission 后,把 source×target 加入自己的 runtime dispatch view;Steer 已提交的增删更新同一 view item revision。 +3. 第一次实际投递让同一 `sourceRecordId` 进入 History;每个实际目标得到独立 `dispatchRef`、固定 response bubble 与执行终局,尚未投递的 sibling 仍留在原 Queue entry。 +4. caller 下一次自然 invocation 从 view 反查 Queue/History:仍 pending、已实际投递、已终局、投递前失败或由 Steer 移除分别诚实呈现;不靠 target 回复语义,也不为普通观察另造消息。failed 的 exact Queue control edge 只负责及时调度,见第 6 步。 +5. 已终局或未投递移除的 exact revision 成功进入 caller prompt 并由该 invocation completed 后,从 view 精确清理;open/pending 与期间新增 revision 继续保留。所有 pending targets 离开后 Queue entry 删除;用户若要重试,通过新消息或新动作产生新的 source,不复活旧工单。 + +## Unified State Model:一条 source、逐 target 生命周期、caller 变化视图 + +完整模型只有三个持久事实 owner,加一个可丢弃的 caller 视图: + +| 问题 | 唯一 owner | 更新时机 | +|---|---|---| +| 这条 source 还等哪些 target | Queue entry 的 `targets[]` | 初始入队;Steer/Cancel;actual admission 从 exact target 出队 | +| 这条 source 对哪些 target 已产生投递或投递失败事实 | source message 的 `dispatchRefs[]` | target 获得持久 processing response receiver,或投递前失败已持久形成 `delivery_failure` 后,写一条 exact source×target ref | +| target 最终怎样 | canonical response / `delivery_failure` | provider terminal 或投递前失败;随后把对应 source ref settle 到该 response | +| caller 下一 turn 需要知道哪些变化 | process-local outbound dispatch view | 在 Queue mutation 提交后 touch;actual 状态在投影时对比 canonical ref/response fingerprint 后 touch | + +**source message 是隔离单位,provider invocation 不是。**A 连续发送 `M1→B`、`M2→B`,A 的 view 中是 +`M1×B`、`M2×B` 两个 item,两条 source 也各自拥有一条 dispatchRef。Queue admission 不把 M1/M2 合并;即使其它 +显式机制让同一 response 引用了两条 input,也不能把两条 source 或两条 caller observation 合并。反过来, +一条 `M1→B/C` 也仍是一个 Queue entry、两个逐目标生命周期;B 的完成不覆盖 C 的 pending/failed。 + +一次完整流转固定为: + +1. **解析与入队**:先得到 canonical targets,再原子提交 source Queue entry。若 source 作者是 Agent,提交成功后 + 为每个 exact target 初始化 caller view item;解析不出 canonical target 的文本只产生 routing diagnostic。已解析但 + 在 durable Queue admission 前被 routing preflight 拒绝的 target,由本次发送结果立即返回 typed fail-back,不登记 view; +2. **Queue 修改**:Steer add/remove、整条撤回等先提交 durable Queue mutation,再 touch 对应 view item。未提交或 + 输掉并发的操作不能留下 view 变化。尚未 actual dispatch 的 remove 由 view 保留 `withdrawn` 说明; +3. **actual admission**:per-thread drain 按 comparator 顺序领取 source entry;普通 drain 的领取单位是完整 source + entry,只有本轮完整 pending target set 都可 admission 时才一次并发 fan-out。任一 sibling 忙碌时,本次尝试 + 无副作用返回,entry 的 `targets[]` 与顺序均不变;enqueue/cutover/执行终局会再次触发 try-drain。领取成功后, + 每个 target 先建立自己的持久 processing response receiver,并给实际输入 source 写 exact dispatchRef,再独立从 + `targets[]` 退役并启动 provider。Steer 或未读接管是显式 singleton cutover,可以只领取一个 exact target; + 它们不把普通 drain 改成空闲子集循环。不同 source 不在 Queue admission 中合并;view 只 touch 对应 + source×target; +4. **terminal**:response 进入 completed/failed/canceled/interrupted,或 pre-admission failure 建立 canonical + `delivery_failure`,再 settle 所有关联 source refs。view 不复制 outcome;caller 下次投影时对比 ref/response + fingerprint,发现变化便分配新 revision; +5. **caller 自然唤起**:先对所有仍保留的 view items 反查 canonical facts;事实 fingerprint 变化时推进 + revision,再仅注入相对 `presentedRevision` 尚未成功呈现的状态。 + 成功 completed 后,pending/open 只推进呈现基线并保留;terminal/withdrawn 按 includedRevision 精确删除; +6. **失败 fail-back 与普通观察分工**:completed/canceled/interrupted 不自动续跑 caller;failed terminal 则与 + canonical response 原子提交一条指向 exact caller 的幂等 Queue-only `a2a_failure` 控制载体。它不新增公开 + result message,只驱动 caller 读取已有 failed response,且自身失败时不得递归生成下一条 fail-back。 + +因此 view 不是第四套 lifecycle,也不需要 `attemptId`:它的 key 已包含 sourceMessageId;同一 target 的两条消息 +天然是两个 item。view 只保存 canonical owners 无法在事后重建的最小选择事实和“上次成功呈现到哪个 revision”, +执行裁决始终回到 Queue/History。 + ## 已确认的 Bug 现象(operator实测 2026-03-14) ### Bug 1: 队列消息提前显示气泡 @@ -87,6 +143,356 @@ operator experience: 3. 收到 `message_deleted` 时,从 store 中移除对应 message 4. F5 hydration 路径:history API 已过滤,无需额外处理 +### Phase C: Dispatch 可视化唯一规范(normative,2026-08-31) + +> **状态**:co-creator 已定稿;实现已在 PR #1398 的 feature worktree 完成,待跨家族 review 与体验验收。 +> +> **权威边界**:本节是聊天前端 dispatch 可视化的唯一规范。F117 早期阶段、F173/F183、 +> 架构审计或现有组件若与本节冲突,以本节为准;其他文档只能引用本节,不能再定义第二套 +> UI 状态机。本节只投影已有生命周期事实,不新增 receipt ledger。 + +#### 一句话 + +**只表达两件事:谁正在处理或处理过这条消息,以及每条成员回复在回复哪条源消息。** + +#### 统一领域模型 + +- 每条公开 History 消息都可以是 dispatch source,可被派给 `0..N` 个成员;发送者是 user、cat、 + IM connector、GitHub 通知或其他系统来源,都不改变渲染规则。 +- 是否显示处理成员,只由这条消息是否存在可验证的 actual-target lifecycle 决定;没有 dispatch + 就自然没有头像,不按消息分类设例外。 +- 前端只消费同一 domain snapshot:source 上的 `dispatchRefs`、其 `statusMessageId` 关联的 canonical + response status,以及 response 上指回 source 的 `messageRef`。Queue pending、caller runtime view、carrier、 + source owner、文本内容和旧 dock 都不能补猜 actual dispatch。 +- multi-target source 为每个成员独立投影状态;一个成员的终局不能覆盖、删除或代表 sibling。 + +#### 源消息头像:pending 无投影,actual dispatch 两阶段 + +| canonical fact | 视觉状态 | 含义 | +|---|---|---| +| 没有 actual dispatch;target 仅存在于 Queue `targets[]` 或 caller view | 无头像 | 没有成员正在处理 | +| `dispatched` + exact active run | 头像闪烁 | 该成员正在处理 | +| `settled` + linked terminal response | 头像静止保留 | 该成员本跳已经结束 | + +- `completed / failed / canceled / interrupted` 在头像层都只是“结束”,统一为静止保留。 +- 成功不显示 badge、勾号、额外文案或“已随本轮完成”。 +- 失败或取消不在头像上新增符号;复用 canonical response 已有的轨迹/状态提示表达结果。 +- 头像 hover 显示 `MM/dd HH:mm:ss 已投递`,时间取 `dispatchRef.dispatchedAt`(实际投递时刻,不是完成时刻);点击头像跳到 `statusMessageId` 指向的 exact response bubble,并使用与「查看消息」相同的临时 lineage focus 标记目标。 +- 任一 canonical read 不完整或映射多义时,不显示未经证明的动态头像,也不退回旧 receipt、消息 kind、 + 文本或 carrier 推断。 + +#### 回复与引用 + +- 任一作为 dispatch result 的公开成员 response 都必须携带 `messageRef`,精确引用触发它的 source; + 引用呈现为现有 `↩ @源作者: 源正文…` 语义。 +- source 是否需要引用由 lineage 决定,不由 author kind 决定。root source 没有上游就不造引用;completed + response 若继续 dispatch 给下一跳,它同时成为新的 source,并在自身气泡上承载下一跳头像。 +- processing 阶段只渲染 admission 已创建的**同一 response lifecycle 行**:成员脉冲头像加 + `CapabilityTipStrip`(无 tip 时为最小处理中动效)。它不是另造的 system/provider 状态消息;正文开始 + stream 后原位升级为 response 气泡,terminal 仍更新同一 identity。消息操作 dock 归真实气泡所有;只有 + lifecycle tip、尚无正文气泡时不得在作者行旁悬浮引用/复制按钮。 +- 成功、失败、取消和中断都必须有一个可关联的 terminal response 气泡;不得另追加 system row、 + provider notice 或第二条状态消息表达同一结果。 +- 结构化 `system_info warning` 默认是 provider/routing 诊断,留在日志/telemetry,不进入对话。只有显式标记 + `presentation=user_action_required` 的可操作提示可持久化;自动重试等短暂状态使用 + `provider_signal + presentation=transient_status`,只在 live 状态面显示。routing preflight `warned` 是 fail-open + telemetry,只有实际改变投递的 `rejected` 才显示一次 receipt。Agent 自身的 route-syntax、 + verdict-without-pass、void-hold 纠正只进入下一 turn prompt、私有 feedback 与 telemetry,不追加公开 History 气泡。 + +截至 2026-09-15,生产代码中的 `warning` producer 已逐项核对,不按文案做黑名单: + +| producer | 事实 | presentation / 用户呈现 | +|---|---|---| +| ACP capacity signal | 服务端容量不足,adapter 正在重试 | `transient_status`;仅 live 状态 | +| Antigravity retry signal | 已安排下一次自动重试 | `transient_status`;仅 live 状态 | +| Antigravity transient upstream error | 上游短暂错误,重试链仍在运行 | `transient_status`;仅 live 状态 | +| Codex item-level error | provider item 诊断;最终失败另由 response terminal 承载 | 未分类;仅内部诊断 | +| OpenCode missing token usage | adapter 缺少自动 handoff 所需遥测 | 未分类;仅日志/telemetry | + +当前没有生产者声明 `user_action_required`;它是以后新增“用户必须采取动作”提示的显式 opt-in 契约,而不是默认值。 +F296 的 oversized native rollover 生产者发出的是 `session_rollover_lifecycle`,不是 warning:透明 rollover 继续执行时 +不需要气泡,rollover 失败时由同一 response terminal 承载,不另造“内容被截断”系统消息。 + +#### 失败传播 + +- **成员唤起的 dispatch 失败**:failed response 原位终局,source 消息上该成员头像转静止(失败终态)。 + 同一终局事务为 exact source caller 提交一条幂等 Queue-only `a2a_failure` 控制载体;它引用该 failed response, + 不复制正文、不形成第二条 History/system failure,并在 route 层标记为 failure report 以禁止递归。caller 被唤起后 + 读取同一 canonical failed response,再决定重分发、放弃或上报用户;这些后续决定才是新的显式普通消息。 +- **未形成 provider response 的直接投递失败**(例如 target 在 actual admission 前失效):投影为 + `delivery failure result` 的用户可见 system message "唤起 xxx 失败";不伪造 response 气泡、不挂动态头像。 +- 判据:**已进入 provider 的 → 原位 response 终局;未形成 provider response 的直接投递失败 → 才写 + `delivery_failure` system result。** 两者都只更新 source 的 exact dispatch ref,不另造回报消息。 +- **失败正文必须完整且原样(2026-09-08 验收修正)**:failed terminal response 正文直接使用实际 + client/provider 调用返回的错误 message;同一 logical dispatch 有多次底层错误时,按发生顺序合并在同一 + response 正文。lifecycle 层不得翻译、概括、追加成员 id、来源消息 id、错误码说明或处置建议。成员由气泡 + header 表达,source 由既有 reply 引用表达,终态由既有 trajectory 表达。失败正文与其它终态回复一样进入 + 可见性索引;不得另放进 system/provider notice、独立 live error 或只写日志。 + +#### 重试与默认信息密度 + +- terminal response 气泡只表达本次执行的完成、失败、取消或中断,不把历史 attempt 伪装成仍可执行的 + retry token。用户要重做时发送新的消息或发起明确的新动作,由生产者生成新的 source 与 Queue row; + 任何终态都不原位复活旧 Queue 工单。 +- 默认界面不展示“普通执行”“查看本轮”“处理完成时间”、attempt aggregate 或独立 + “处理回执”区块。回复引用与头像三态已经完整覆盖用户需要理解的事实。 + +#### 必须删除的旧坐标 + +实现 Phase C 时必须删除,而不是继续修改: + +- 独立 `MessageReceiptDock` / “处理回执”展示模型; +- `primary_trigger` suppression 与任何 user-vs-cat、kind/scope/channel 的 dispatch 渲染分叉; +- 与 canonical response identity 无关的 processing 空气泡、假 `Thinking...` 和 system/provider rows; +- 从旧 receipt、carrier、source owner、消息正文或当前成员身份补猜头像/终态的 fallback; +- 成功 badge、头像结果符号、完成时间戳、“普通执行”和“查看本轮”;actual dispatch tooltip 与 exact-response 跳转保留。 + +历史兼容只能在 READ 边界把旧记录规范化为同一 domain snapshot;不能在 React 渲染层保留第二条 +legacy 路径。若规范化后仍没有唯一事实,就 fail closed,而不是添加分类分支或 fallback。 + +#### Phase C 不变量 + +1. **INV-C1 — 一条渲染路径**:user、cat、connector、GitHub/系统来源共用同一 projection;零分类例外。 +2. **INV-C2 — 头像投影守恒**:pending/无 actual dispatch = 无;active = 闪;terminal = 静止保留。 +3. **INV-C3 — exact lineage**:每个 dispatch-result response 必须引用 exact source;不得用当前 thread、 + 当前成员或相邻消息猜引用。 +4. **INV-C4 — 单一 processing 行**:processing 只显示 canonical response lifecycle 行(脉冲头像 + + capability tip/最小动效);stream 与 terminal 原位升级,不另造 placeholder 或 system row。 +5. **INV-C5 — 单一终局**:一个成员一次 execution 只有一个原位 terminal response,不附加第二条状态消息。 +6. **INV-C6 — 成功静默**:成功只有静止头像与正常回复;失败/取消只复用 terminal 轨迹提示,不加头像 badge。 +7. **INV-C7 — 终态不复活**:failed/cancelled/interrupted response 只保留终态证据;重做来自新的用户意图。 +8. **INV-C8 — 无展示 fallback**:legacy 只在 READ 边界规范化;渲染层不以 fallback 或 kind/scope 分支补事实。 + +### Phase D: 队列内核收敛(normative,2026-09-02) + +> **权威决策**:[ADR-043 — 消息队列 = 独立持久化的有序工单账本](../decisions/043-queue-durable-single-ledger.md) +> **RFC 依据**:[A2A 消息投递、处理与交接生命周期架构](../architecture/message-delivery-handling-handoff-audit.md)(PR #1356) +> +> **本节是队列内核设计的单一真相源。** F039 / F122 / F175 / F047 中与本节冲突的描述以本节为准。 + +#### D.1 职责边界 + +``` +queue entry = 一条尚待投递的 source message(完整 payload + pending targets[] + 顺序) +history message = 已发生至少一次 actual dispatch 的公开内容 + dispatchRefs +response bubble = 每个 actual target 的 processing / terminal 结果 +caller dispatch view = 单 runtime 内该成员自己发出的 source×target 变化索引 +``` + +Queue 只拥有 pending,不保存 actual dispatch、processing、seen/handled、terminal 或 retry receipt。每个 target 的实际投递与结果归 History lifecycle。caller dispatch view 只保存“需要在后续自然 turn 重新核对哪些 source×target revision”,不成为持久 truth。 + +#### D.2 根因(实读证据) + +1. **同一 source 被拆成多条 Queue rows**:用户只发送一次 `@B @C`,存储与 Queue UI 却出现 B/C 两个工单身份,无法自然表达“这是一条消息、还有哪些目标未投递”。 +2. **Queue 与 Message 双写 lifecycle**:processing/terminal/seen/handled 同时出现在 Queue delivery、message receipt 与 response bubble,违反 RFC L1。 +3. **History 提前 publication**:queued user message 在共同 History 中占位,又由 Queue 维护另一套顺序;Steer/reorder 后两者天然分叉。 +4. **正常并发被 stale CAS 放大**:Steer 弹窗打开后某目标已由未读接管,本应跳过该目标继续,却被整批 conflict 拦截。 + +#### D.3 收敛决策(详见 ADR-043) + +| # | 决策 | +|---|---| +| D1 | QueueEntry 按 thread 独立持久化;一条 source message 只有一条 entry | +| D2 | `targets[]` 只保存 pending targets;actual dispatch 后删除 exact target,空数组时删 entry | +| D3 | `sourceRecordId` 是消息 identity;`from` 是结构化作者/provenance,两者正交 | +| D4 | 用户/external inline source 第一次 actual dispatch 才进入 History;Agent/completed source 已在 History,只建立 message-ref wake | +| D5 | `dispatchRefs` 没有 `assigned`;actual dispatch 直接创建 `dispatched`,terminal 推进 `settled` | +| D6 | Queue mutation 由 Lua/CAS 原子维护 payload、targets、revision 与 order;schema v1 per-target rows 幂等聚合为 v2 source row | +| D7 | Steer 打开只读;确认时重读 Queue+History,跳过已 dispatch targets并原子刷新 pending set,不把正常并发当整批 stale;提交成功后更新 caller runtime view revision | +| D8 | Queue 不保存 processing/terminal/seen/handled/attempt receipt;Retry 只能创建新的 source/attempt | + +实现边界:Queue row 只回答“哪条 source 还在等谁”;History 只回答“实际投给了谁、结果是什么”;caller runtime view 回答“这个成员下一 turn 还需核对哪些自己发出的 dispatch 变化”。一条 `@B @C` 在 UI 和存储中始终是一条 source message,B 已投递时 entry 原位只剩 `targets=[C]`。 + +#### D.4 一并修正的实现偏离 + +- **§6.4 一次 try-drain 一条 source**:相同 target 也不合并相邻 entry;每条消息的 identity、正文、History 顺序与 dispatchRefs 保持独立。 +- **author 与 owner 解耦**:新 Message 写入携带判别式 `from`;读取旧 row 统一经 `messageFrom`。Queue scope 使用判别式 `owner` 并经 `queueOwner` 访问。存储 owner/tenant 的真实 `userId` 不属于 author 污染,继续保留。 + +#### D.5 不会简化的部分(诚实边界) + +Queue 的短暂 claimed 状态仍可存在于“原子取出 pending target → provider admission”窗口,但它只是可恢复的 mutation snapshot,不是 durable processing truth。公开 processing 只由 response bubble + exact Active Run 表达;启动失败也原位终局该 bubble。 + +### Phase E: 验收修正(normative,2026-09-03) + +> 来源:co-creator 在 #1398 worktree(验收实例 Redis 6388,thread `thread_mtkx52e4rmlvopk5`)的体验验收;根因由 Ragdoll(Fable) 数据 + 代码双证,实施由 Maine Coon(sol)。 +> **权威决策**:ADR-043 D8(完整读取即接管)、ADR-043 D9(停止阶梯)。 + +| # | 验收现象 | 定性 | 修正 | +|---|---|---|---| +| E1 | 猫的历史回复不进入其他猫的未读 / `get_thread_context`(codex resume 只见 co-creator 消息) | 实现回归:退役 custody 脚本时丢掉了 terminal commit 的 visibility 分配 | 两个 terminal commit Lua 恢复 validate-before-write 的 `visibilitySeq` 分配;隔离 Redis 测试断言「猫回复对其他猫 cursor 可见」;存量数据 repair | +| E2 | 被 @ 的成员头像不再脉冲,回复气泡固定「正在回复…」没有 tips | 设计取舍被验收否决:`833aa0587` 删除了 `PendingMemberBubble` / `CapabilityTipStrip` 消费者 | processing 态 lifecycle 回复行即新的 pending bubble:头像 `streaming` 脉冲 + `CapabilityTipStrip`;message 下小头像与回复气泡共用同一 `activeRun` 状态 | +| E3 | 猫通过未读读到 queued wake 后,该 target 仍留在队列 | 设计修正 | 无 filter 完整读取 = exact active child 实际接管该 target:从同一 source entry 的 `targets[]` 删除该成员并创建 History dispatch ref;siblings 留在原 entry;Queue 不另存 seen/handled | +| E4 | 狸花猫 Steer 无「立即发送,引导回复」;旧设置把 append 能力与排队/立即意图混为一项 | UI 与契约错误 | 默认发送策略收敛为「排队等待 / 引导当前回复」,放在输入框左侧 `+` 菜单并可在 idle/active 任意时刻调整;设置只保留持久的「本 Thread / 全局默认」,不再提供与 Steer 重复的「仅这一次」或「恢复继承」。active 时右侧只保留 Stop,输入正文后增加一个普通 Send,发送只 admission 一次,再由 Queue 按策略处理;无 `@` 时该 admission 由服务端绑定最近 completed responder/default,但不伪造 source mention。Queue Steer 固定为「立即发送,引导回复 / 立即发送,中断回复」,多选成员并逐成员选策略;静态 client capability 随成员信息投影,不支持 guide 时才禁用引导并在 disabled action 的 hover/focus tooltip 说明原因;是否已有 active reply 不参与弹窗能力判断,出队时无 active parent 则由 canonical admission 降级为新 invocation;历史 targetless 绑定与新增 targets 在同一 source entry 原子完成 | +| E5 | 失败正文与系统提示重复成两个公开气泡;dispatching member 没有及时处理失败的 exact control edge | 实现 | 失败细节合并进唯一 terminal failed response;exact A2A failed 同事务创建引用该 response 的幂等 Queue-only `a2a_failure` carrier;Phase H 也可在 dispatching member 下一次自然 invocation 反查 exact source×target History 终态 | +| E6 | 「执行中」与 QueuePanel「等待 xxx 当前回合」重复;气泡浮窗「查看轨迹」冗余;首个轨迹 chip 位置 | UI 冗余 | 去横幅、去按钮、轨迹 chip 置于引用 chip 之后 | +| E7 | 「卡住了?强制重置」常驻 / 「运行状态待确认」红横幅 | 设计(补丁化逃生舱) | ADR-043 D9:停止是唯一动作;无活候选时服务端就地对账;进程快照不可用时按 failed 终局并沿 Phase C 失败传播回溯上游;无任何确认弹窗 | +| E8 | Agent 把导航中的「最近活跃」误读成成员仍在执行,且无法从现有 `get_thread_context` 核验 | A79 实现缺口:UI 已有 exact lifecycle 投影,Agent 侧仍只有发言新近性 | `dispatched` source ref + processing response + 唯一 `LifecycleActiveRun` 组成共享 exact predicate;同一投影注入新 invocation 导航并由 `get_thread_context.situation` 只读返回;任一 join 不完整即 `complete=false`,不按最近发言猜运行态;D12 改名「最近发言」 | +| E9 | fail-open 路由 preflight 每次发送都产生「需注意」聊天提示,且同一次发送重复 | 可见性边界错误 | `warned` 只进入结构化 routing evidence / telemetry;只有确实改变投递结果的 `rejected` 才生成用户可见 receipt | +| E10 | History 仍提供旧「撤回并编辑」,并同时存在直接分支 / 编辑分支两个入口 | 旧模型残留 | terminal History 不再撤回 Queue 工单;只保留一个「创建分支」入口,打开预填正文的编辑确认,正文未改也可直接确认创建 | + +### Phase F: Carrier 能力真相与副作用出口收敛(normative,2026-09-09) + +#### F.1 配置只有一个 canonical 坐标 + +成员配置统一使用顶层 `carrier`,Hub 文案统一为「接入方式」。兼容只允许存在于成员配置读取边界: + +```text +carrier → legacy top-level transport → cli +``` + +读取完成后,所有 registry、provider、route、capability 与 Web 代码只接收 canonical `carrier`;不得继续读取 +`transport`、`cli.carrier`、`codexCarrier`、`adapterMode` 或全局 `CAT_CAFE_CODEX_CARRIER`。兼容层不替换无效值、 +不跨 provider 猜测,也不静默切到另一种接入方式。 + +| client | 合法 carrier | 能力边界 | +|---|---|---| +| Claude | `cli`, `sdk` | `sdk` 是 live session,可在执行中接收新增正文与显式中断;`cli` 是单轮进程 | +| Codex | `cli`, `app_server` | `app_server` 是 live session;`cli` 是单轮进程 | +| Kimi | `cli`, `acp` | 本轮不新增 Kimi live adapter;只保留既有显式选项 | +| OpenCode | `cli`, `acp` | 本轮不新增 server carrier;OpenCode 1.17.3 的 `/config` 是 directory-scoped 持久配置,不能承载 invocation-scoped MCP 凭据 | +| Gemini | `cli`, `acp` | 由显式配置选择 | +| generic ACP | `acp` | 不允许伪装成 CLI | +| 其他 client | `cli` | 只有单轮能力 | + +这些选项彼此平级,不是 fallback 链。选择的 carrier 启动、鉴权或协议失败时,本次 response 原位 failed; +不得偷偷改用另一 carrier 后继续执行。 + +#### F.2 `post_message` 是纯发送,不是 inbox 门卫 + +F254 Phase A HELD、B1 MCP-result piggyback、B2 hold-ball reminder,以及 Queue 行上的人工「提醒」旁路在本轮退役。Queue +只展示 pending targets 与 History `dispatchRefs` 推导出的已读状态;用户要改变一条 Queue 消息的投递方式时只使用 Steer, +不再另外持久化 reminder attempt 或通过另一条 endpoint 向 active turn 注入提示。`post_message`、 +`cross_post_message`、`multi_mention` 只执行调用方明确请求的写入/分发: + +- 不顺便检查调用猫的 inbox 或 freshness; +- 不因存在 unseen message 拒绝本次发送; +- 不返回 `Message NOT sent (HELD)`; +- 不提供 `acknowledgeHeld` 绕过参数; +- 不在工具结果里教育模型调用另一个 MCP 工具补救。 + +这不是放弃运行中消息,而是把责任放回 delivery/carrier:支持 live input 的 carrier 由 QueueProcessor 对 exact +active invocation 执行 append 或 interrupt;单轮 CLI 对 append 诚实报告不支持,消息继续作为 pending Queue +工作,待当前执行结束后由正常 FIFO drain 启动下一轮。MCP 工具不再代偿 provider transport 的能力差异。 + +`guide_reply` 的保证边界是:正文进入同一 active client session/execution,并在该 session 的下一次模型输入边界 +参与推理;它不声称能改写一个已经发出的底层 LLM HTTP 请求。`interrupt_reply` 则先对 exact active run 发协议 +中断,再把正文交给同一成员的新一轮处理。 + +Codex `app_server` 的 native thread 所有权属于 provider 状态,而 HostPool lease 只能证明本进程内的占用。Queue 与 +invocation 即使完全串行,前一轮 WebSocket 在本地 close 并释放 lease 后,provider 仍可能尚未完成 writer detach;同一 +warm affinity host 上紧接着建立的新连接因此会在 `resume` 暂时收到 active writer。进程重启遗留或另一客户端也可能形成 +同样的 provider 状态,但都不是第二条 Queue dispatch。该冲突不是消息或任务失败,也不得成为公开错误 bubble。运行时 +只在首次冲突时淘汰可能陈旧的本地 affinity host,随后保持同一 native thread、同一 invocation 与同一 History lineage +做可取消的退避等待;不得新建替代 session、不得重放用户消息,也不得把被丢弃 probe 的 failed/closed lifecycle 投影 +给用户。只有用户显式 Stop/取消才结束这段等待。 + +#### F.3 source × target replay 必须在持久真相上 fail closed + +任何 Queue admission、A2A、multi-mention、connector wait continuation 或恢复路径,在唤起 target 前都必须以 +History `dispatchRefs` 对 exact `sourceRecordId × targetCatId` 做原子 join/CAS: + +1. 若该 target 已有 actual dispatch(无论 response 正在执行还是已终局),只清理残留 pending target,绝不再次唤起; +2. multi-mention 必须先创建一条正文完全一致、可引用的真实 Agent History source,再将它与同一 `targets[]` + Queue entry 原子提交;callback response 只作为父 lineage,不能拿它的 id 指向另一段 synthetic Queue 正文; +3. wait/connector carrier 必须同时匹配 canonical task 的 thread、owner、当前 outcome identity、fence 与 generation, + 且 outcome 尚未 delivery terminal;历史 generation、已送达 outcome 或旧 connector message 不得重新准入; +4. 重放可以幂等清理脏 Queue index,但不能生成第二个 response、第二条 source message或第二次 provider side effect。 + +Queue pending 与 History actual dispatch 仍是两个 owner;一致性依赖固定提交顺序和单调幂等 join,不新增第三套 +“已消费 connector”receipt ledger。 + +### Phase G: Managed hold 统一恢复(normative,2026-09-10) + +`hold_ball` 只负责声明一个跨 invocation 的持久等待,并把等待原因、条件和下一步写成一条 owner-bound +History system message。该消息对 operator 与 thread 中所有成员使用同一份持久投影:人通过消息气泡理解等待, +成员通过正常 History/context 读取同一事实;不得另造仅某只猫或仅 UI 可见的 hold 状态。 + +条件满足后的恢复复用完整消息生命周期: + +1. producer 以 exact task/thread/owner/target fence 幂等创建一条 `deliveryStatus='queued'` 的 + `managedHold:true, phase:'wake'` system source; +2. source 进入同一成员的 canonical Queue,priority 为 `urgent`。排队阶段不出现在 History,也不进入任何成员 + context;只有实际 Queue admission/dequeue 后才以同一 message identity 进入 History; +3. admission 创建唯一 response bubble,processing、正文、成功、失败、取消和中断均原位更新该 bubble;provider + 唤起失败同样归这条 response,不新增 hold-specific error row; +4. 其他成员与 operator 在 History 中看到同一等待、唤醒和 response 终局,因此后继成员可直接续上,无需读取 + BallCustody 或解释隐藏 disposition。 + +普通 A2A 和 managed hold 的完成由 `source → Queue admission → response terminal` 自然闭环。它们不再写 +`ball.handed / ball.dispatch_dispositioned / ball.hold_dispositioned`,不再要求 +`cat_cafe_complete_a2a_dispatch` 或 `cat_cafe_complete_managed_hold`,也不进入 F167 turn stop gate。Ball/lease +只保留给真正独立于消息投递的 durable action-successor responsibility;`hold_ball` 自身仍保留等待条件、取消和 +跨 invocation 恢复状态,但它不是第二套消息完成账本。 + +历史 Ball events 与旧 task 字段只读兼容,不参与新写入或执行裁决。新路径不得根据旧 disposition 缺失重试、 +escalate 或阻止 provider 输出提交。 + +### Phase H: 发出者自有逐目标生命周期观察(normative,2026-09-14) + +普通 A2A dispatch 建立一条 source→target delivery lifecycle。terminal observation 不依赖 target 回复正文的 +`@caller`、完成声明或传球措辞;合法的显式 `@caller` 仍按普通 dispatch 路由。completed/canceled/interrupted +不会自动续跑 caller;failed terminal 是例外:它必须原子提交 exact、幂等、Queue-only 的 `a2a_failure` 控制载体, +让 caller 处理既有 canonical failed response。该载体不是第二条结果或新的责任账本,也不得递归 fail-back。 + +客户端单 runtime 在每个 caller 自己的串行 slot 中维护一个**进程内、可丢弃的 outbound dispatch view**。 +source message、Queue 与 `dispatchRefs` 的持久语义保持不变。每个 view item 保存 +`(ownerId, threadId, callerCatId, sourceMessageId, targetId, revision, presentedRevision)`,以及无法从当前 +Queue/History 反查的最小变更事实:`firstAddedBy = initial | steer | unknown`、最近一次已提交的 +`selectionChange = added | removed`,以及 `selectionChangedBy = initial | steer | queue_withdrawal | unknown`。 +这些字段只用于向 caller 解释变化,不参与调度或 actual-result 裁决: + +1. Agent caller 的 source Queue admission 成功提交后,以当时解析出的 exact targets 初始化 view items;同一 caller + 随后发送另一条 source,只追加新的 source×target items,不覆盖先前尚未观察完成的记录。无法解析出 canonical + targetId 的 mention 只保留 routing diagnostic; +2. Steer 对尚未 actual dispatch 的 target 做 remove/add 后,用已经提交成功的 Queue mutation 更新同一 view item + revision 与 selectionChange。C 被移除后即使已不在 Queue,view 仍能说明 not-delivered/withdrawn;E 被加入时 + firstAddedBy=steer。若本进程首次看到的是重启前遗留 target 的 remove/actual 事件,无法证明最初加入来源时必须 + 记为 unknown,不能猜 initial。若 actual admission 已赢得并发,History ref 优先,旧 Steer snapshot 不能把 target 改写为未投递; +3. 已进入 durable Queue、但在 actual admission 前变为不可用的 target 不能在 caller view 中静默消失;canonical + `delivery_failure` 作为它的 actual result。若 target 在 durable Queue admission 前就被 routing preflight 拒绝, + 则由本次发送结果立即返回 typed fail-back,不能伪造 view item。actual admission 与 response terminal 也只刷新对应 view revision,真实 result/status/response + identity 始终从 source `dispatchRefs[]` 与 canonical response 反查,不缓存成第二份权威状态; +4. caller 下一次因正常 Queue/user/member/continuation 原因启动时,pre-prompt projection 先对**所有仍保留的 + items**逐项 join 当前 Queue 与 History:Queue 仍含 target = pending,History ref open = executing,settled ref = + canonical terminal,最近一次 + 已提交 view mutation 为 remove 且没有 actual ref = not-delivered/withdrawn。scope 不符或映射多义时显式 unknown + 并保留,不能从 Active Run 消失、消息未读 cursor 或回复语义推断。projection 校验候选快照仍有效,以 + canonical fingerprint 变化推进 revision,然后才筛选 `revision > presentedRevision` 的未呈现变化进入 prompt; +5. projection 在读取 item 时冻结 `includedRevision`;最终 prompt 中的文字与这个快照绑定。异步 Queue/History + 查询期间发现 item revision 已变化时,必须重读或保留为待核对,不能把旧正文事后标成新 revision。item 删除后 + 同 key 重建也必须获得严格更大的 revision/epoch,不能复用已确认身份; +6. caller invocation 成功提交 `completed` 后,pending/open item 只把 `presentedRevision` 推进到 + `includedRevision` 并继续保留,未变化时后续 turn 不重复注入;terminal/withdrawn item 仅在 current revision + 仍等于 `includedRevision` 时 compare-and-clear。若 prompt 包含“C 已移除@rev2”,但完成前 C 又被重新选择为 + rev3,rev2 的确认不得清除 rev3。token budget 裁剪、启动失败、provider 失败、取消、终局提交失败或 current + turn 新增的 source/revision 都不确认; +7. completed/canceled/interrupted 只刷新 History lifecycle 与 caller 的下次观察结果,不创建 `a2a_result` wake; + failed terminal 则把 canonical response terminalization 与一条 exact caller `a2a_failure` Queue control carrier + 原子提交。控制载体不新增 History/system message,重放按 response identity 幂等,且消费它的 route 禁止再次 + 生成 fail-back;它也不登记为失败方新发起的 outbound business dispatch 或 caller-view item。target 主动 + `@caller` 仍是一条新的普通 dispatch;非失败结果若要求自动续跑,必须显式注册 continuation; +8. `activeRuns` 继续独立回答「当前 thread 中谁确实正在执行」,供 operator 与所有成员共享;caller observation + 回答「我发出的 source 对各 target 已提交了哪些选择变化和实际结果」,不能从 active run 消失、消息未读 cursor + 或最近发言推断; +9. 进程内待观察索引不持久化,也不在 API runtime 启动后扫描 History 重建。每个 + `(ownerId, threadId, callerCatId, processGeneration)` scope 在下一次自然 invocation 获得一条固定大小的 + `process_start` 说明:当前 observation 仅覆盖本进程登记的 dispatch;更早的结果仍在 canonical History, + 需要时按需读取。说明不推断旧 dispatch 状态、不声称指针丢失、不创建 Queue work 或隐藏 wake,也不清理 + 当前进程 observation。它只有在实际进入最终 prompt 且 caller invocation 成功提交 `completed` 后才确认; + 裁剪、启动失败、provider 失败或取消后必须在下一次自然 invocation 重试,同一 process generation 成功交付后 + 不重复。普通 cold/binding mismatch 不重置确认状态。 + +runtime restart 后,actual dispatch/result 仍可从 canonical History 查询;`firstAddedBy`、Steer remove/add 与 +`presentedRevision` 这些只存在于旧 runtime view 的说明不保证恢复。`process_start` 必须诚实说明这一边界,不能 +把新进程里无法证明的旧选择变化重新推断出来。 + +这份 view 专门解决“Agent caller 下一次被唤起时如何知道自己先前发出的多条 dispatch 后来怎样了”。operator +不需要 prompt 注入:用户表面继续直接读取当前 Queue Panel、source `dispatchRefs` 与 canonical responses;两者共享 +同一底层事实,但不能为了统一称呼而把 Agent runtime view 持久化成用户 receipt ledger。 + +这条观察链只优化同一客户端 runtime 内的连续体验;若未来支持多 API worker、滚动切换或跨节点 caller slot, +必须改为从 History 的可重建 caller-dispatch 索引逐 turn 查询,不能把当前进程内集合升级为第二份权威状态。 + ## Acceptance Criteria ### Phase A(后端 — deliveryStatus 真相源) ✅ @@ -108,17 +514,94 @@ operator experience: - [x] AC-B5: QueuePanel 功能不受影响(仍通过 `queue_updated` 正常展示) - [x] AC-B6: queue send 多行消息(Shift+Enter)时不出现 optimistic bubble;delivered 后只出现一次 -### F264 owner-timeline 演进(2026-07-21) +### F264 owner-timeline 演进(2026-07-21,已由 Phase D supersede) + +以下四项是历史验收记录,不再描述 live contract;当前模型的 queued input 只在 Queue Panel,第一次 actual dispatch 后才进入 History。 - [x] AC-B7: durable queued user message 从 Queue admission 起可由 owner-facing history/F5 水合,仍不被 cat callback/context 读取 - [x] AC-B8: explicit queue send 等 202 durable id 后插入;smart-default queued 不删除 optimistic bubble - [x] AC-B9: terminal delivery 更新同一 bubble 的 receipt/deliveredAt 并保留 authoring-time 顺序,不复制正文 - [x] AC-B10: canceled 消息继续由 `message_deleted` 移除,owner history/F5 也不返回 +### Phase C(Dispatch 可视化重建,2026-08-31) + +- [x] AC-C1: user、cat、IM connector、GitHub/系统来源在同一 renderer 中只按 actual dispatch facts 投影头像 +- [x] AC-C2: pending/无 actual dispatch、active、terminal 分别稳定呈现为无头像、闪烁头像、静止保留头像 +- [x] AC-C3: 每个 dispatch-result response 都带 exact `messageRef`;completed response 可作为下一跳 source +- [x] AC-C4: processing 只渲染 canonical response lifecycle 行(脉冲头像 + capability tip/最小动效); + 不创建独立空气泡、假 `Thinking...` 或状态 system row,stream/terminal 原位升级 +- [x] AC-C5: 成功不加 badge/文案;失败与取消复用 terminal 轨迹提示,头像不加结果符号 +- [x] AC-C6: terminal response 不提供旧 Queue attempt 的原位重试;旧 dock、完成时间戳、“普通执行”“查看本轮”删除;头像保留 actual-dispatch 时间与 exact-response 跳转 +- [x] AC-C7: React 渲染层没有 `primary_trigger`、author/kind/scope/channel 分叉或 legacy receipt fallback +- [x] AC-C8: F5 hydration 与 live socket 对同一 source/target lifecycle 产生相同头像、引用与 terminal 投影 + +### Phase D(队列内核单账本,2026-09-02;2026-09-07 canonical 修订) + +- [x] AC-D1: Queue row 独立持久化并可在启动时 hydrate;一 source 一 entry,Message 不镜像 Queue pending 状态 +- [x] AC-D2: multi-target 使用同一 `targets[]`;普通 drain 一次领取本轮完整 exact target set 并并发 fan-out,Steer/未读接管可领取 singleton;每个 target 在 receiver/ref durable 后独立删除,未完成或未领取 siblings 原位保留,空数组删 entry +- [x] AC-D3: `enqueue` / `claim` / target mutation / `restore` / reorder 均由 Redis Lua 原子转换,Memory/Redis 语义同构 +- [x] AC-D4: 第一次实际投递 materialize History;后续 target 复用同一 message;`dispatchRefs` 直接从 `dispatched` 到 `settled` +- [x] AC-D5: Steer 确认时重读 live Queue+History,跳过已投递成员;用户增删 pending targets 不撤销既有 dispatch +- [x] AC-D6: processing/terminal/seen/handled/attempt 不持久化到 Queue;失败、取消、中断均不回队 +- [x] AC-D7: 一次 try-drain 只领取一条 source;相同 target 不合并 Queue admission,每条 source 保持独立 message identity、顺序、ref 与 invocation +- [x] AC-D8: Redis hydrate 对旧/损坏 row fail closed;**同一 QueueLedger key family 内**的 schema v1 per-target rows 幂等迁移为 v2 source row,canonical terminal 不导入 Queue。该迁移不宣称覆盖 live `main` 的 MessageStore `queueCustody*` 基线;跨 owner 的历史切换见 replay ledger §9。 +- [x] AC-D9: terminal work 不可复活;重做必须由新用户意图产生新 source/attempt +- [x] AC-D10: targetless 队首(历史无目标 row 或带无效显式 `@` 的 warning-bearing row)在 thread 有活跃执行时原位等待,不绕过、不猜目标;thread 空闲后才走 canonical resolver。无 `@` 的普通新输入则在 source+Queue admission 前绑定最近 completed responder/default;只有 resolver 最终仍无可用目标时才 terminal failure + +### Phase E(验收修正,2026-09-03)— 代码与跨族复审完成,完整 worktree UAT / fork soak 待完成 + +- [x] AC-E1: 隔离 Redis 下,猫的 terminal 回复获得 `visibilitySeq` 并进入 `msg:visibility` index;另一只猫的 cursor 读(prompt 增量 / `get_thread_context`)返回该回复 +- [x] AC-E2: processing 态 lifecycle 回复行显示脉冲头像 + capability tip;message 下小头像与回复气泡由同一 `activeRun` 驱动;恢复 capability-tip 组件测试 +- [x] AC-E3: 无 filter 完整读取实际接管 exact target,并从同一 source entry 的 `targets[]` 删除;siblings 独立保留;无 Queue seen/handled row;读取仍 200;原消息保持 authored 顺序 +- [x] AC-E4: 默认设置只表达「排队等待 / 立即发送,引导回复」;普通用户输入没有 `@` 时,服务端在 source+Queue 原子 admission 前用 canonical resolver 选择最近 completed response target、再退全局默认,并持久化为 Queue target,source mentions 仍保持空;ChatInput / QueuePanel Steer 对 participants、路由目标与同一 fallback 投影的去重并集提供多选,不在 Web 猜测,并为每个 pending target 独立选择「立即发送,引导回复 / 立即发送,中断回复」;静态 client capability 随成员信息返回,UI 不硬编码;引导只对 exact active parent invocation 生效,并由 adapter 的独立 `activeInvocationGuidance` 声明授权,不能从 `deliverySemantics` 推断;`exact_active_turn` 只证明当前可见 provider turn 精确读取,`queued_internal_turn` 只证明下一内部轮次读取;不支持则禁用并说明;打开弹窗不写 membership,确认时重读 live Queue+History,跳过已投递/不可用成员并加入新选成员;历史 targetless recovery 的首目标与新增 targets 在同一 source entry 原子完成,后续各 target 独立终局 +- [x] AC-E5: 失败 response 只呈现一次;正文逐字采用实际 client/provider error message,同一 logical dispatch 的多次错误按发生顺序合入同一气泡,不由 lifecycle 层追加成员/source/error-code/建议;另一只猫的 cursor 读能读到同一 failed response 并可把它作为下游 source,无需用户转达;live 与 hydration 不生成第二条 system/provider error;成功的 `runtime_replacement` 不生成 source-less continuation;A2A failed terminal 与 exact caller 的幂等 Queue-only `a2a_failure` 控制载体原子提交,控制载体引用 canonical response、不进入 History且禁止递归 +- [x] AC-E6: QueuePanel 横幅、浮窗轨迹按钮移除;轨迹 chip 位置符合验收描述 +- [x] AC-E7: 对已确认死亡的 exact execution,Stop 返回 200 `reconciled` 而非 409;进程快照不完整时服务端有界重试后按 failed(reason `control_plane_unavailable`)终局并返回 200,失败沿 Phase C/Phase H 收敛(源 dispatchRef settle、exact A2A caller 由 Queue control edge 及时唤起、其他观察反查 History、pre-start 走 `delivery_failure`);不做平台兼容分支,Windows 子进程不可观测时同样走 fail 收敛;确认无 owner 的 read-repair 使用 `execution_owner_lost`,pre-start processing 超时使用 `prestart_timeout`;单个 child 失败不终局仍有 tracker/process-owner 见证的 sibling parent;`ForceResetDialog` 退役,`ThreadExecutionBar` 无常驻/卡死触发的强制重置入口、无「运行状态待确认」横幅;投影 read-repair 落地,pre-start 预留 TTL 收窄到 create→startAll 窗口 +- [x] AC-E8: UI 消息头像、Agent invocation 导航和 `cat_cafe_get_thread_context.situation` 共用 A79 exact predicate;返回 target/source/response/invocation,完整空集明确表示无其他成员执行,证据失配返回 `complete=false`;发言新近性只标「最近发言」,不得成为运行态 fallback +- [x] AC-E9: routing preflight 的 fail-open `warned` 只进入 evidence / telemetry,串行、并行和 A2A admission 均不生成聊天消息;`rejected` 仍生成一次可见 receipt +- [x] AC-E10: terminal History 无「撤回并编辑」;只保留一个「创建分支」入口,以原正文预填编辑框,正文不变也可确认创建 +- [ ] AC-E11: co-creator 在 feature worktree 完成上述完整旅程体验验收,随后合入 fork 并通过 soak;在这两道硬门前不得推进上游 merge +- [x] AC-E12: 空 processing lifecycle 只有头像/tip、没有 message action dock;未分类 provider warning、routing `warned` 与 Agent protocol correction 不生成聊天气泡,只有显式 `user_action_required` warning 可持久化,`transient_status` 只走 live provider status + +### Phase F(carrier 与副作用出口,2026-09-09)— UAT 修订中 + +- [x] AC-F1: 成员配置只向下游暴露 canonical `carrier`;唯一兼容读取顺序为 `carrier → transport → cli`,非法 client/carrier 组合 fail closed +- [x] AC-F2: Claude `sdk` 与 Codex `app_server` 在 exact active session 上支持 guide/interrupt;OpenCode `server` 因 directory-scoped 持久配置无法隔离 invocation MCP 凭据而退役,OpenCode 仅保留 `cli` / `acp`;单轮 `cli` 不谎报 append 能力,且任何 carrier 失败都不静默 fallback +- [x] AC-F3: `post_message` / `cross_post_message` / `multi_mention` 不检查 inbox、不 HELD、不接受 `acknowledgeHeld`、不附加 freshness/hold-ball 教学;F254 Phase A/B1/B2 active wiring 退役 +- [x] AC-F4: A2A 与 response terminal admission 在 exact `sourceRecordId × targetCatId` 已有 History dispatch 时幂等 no-op;已消费 Queue source replay 不产生第二次唤起 +- [x] AC-F5: multi-mention 的 Queue source 是一条真实、正文一致、可引用的 Agent History message;callback response 只作 parent lineage,不能充当 synthetic source identity +- [x] AC-F6: connector wait continuation 在准入前验证 canonical task 的 exact outcome、fence、generation 与 delivery terminal;历史或已交付 carrier fail closed +- [ ] AC-F7: co-creator 在 feature worktree 验证 live carrier append/interrupt、单轮排队、multi-mention lineage 与 connector replay;随后进入 fork soak +- [x] AC-F8: terminal coordination 后,只有**不带任何显式路由凭据**的礼貌回复可记为 quiet ACK;显式行首 `@` 与 structured `targetCats` 都是新的 active routing hop,必须入 Queue 唤醒目标,且继续受统一 loop-streak 防乒乓约束 + +### Phase G(managed hold 统一恢复,2026-09-10)— 代码与测试完成,待体验 + +- [x] AC-G1: hold registration 只持久化一条 owner-bound waiting History message;operator 与所有 thread 成员读取同一内容 +- [x] AC-G2: timer/command condition 只创建一条 exact fenced、urgent、queued wake source;actual Queue admission 前不进入 History/context +- [x] AC-G3: wake dequeue 后复用普通 response lifecycle,唯一 response bubble 原位承载 processing 与任意终态 +- [x] AC-G4: ordinary A2A/managed-hold 新路径不写 Ball disposition,不要求 completion MCP,也不触发 turn stop gate;action-successor gate 保留 +- [x] AC-G5: completion MCP 从 callback/API/tool registry/governance baseline 同批删除,且不存在可调用别名或双轨 fallback +- [ ] AC-G6: co-creator 在 feature worktree 验证「等待可见 → 条件满足进入优先 Queue → 出队后单一 response 终局 → 其他成员可读」完整旅程 + +### Phase H(caller outbound dispatch view,2026-09-14)— 实现与跨族复审完成,完整 UAT 待完成 + +- [x] AC-H1: Agent source Queue admission 成功后,按 exact `(owner, thread, caller, sourceMessageId, targetId)` 初始化 runtime view;item 保存 monotonic revision/presentedRevision、firstAddedBy、最近 selectionChange 与 selectionChangedBy;若当前进程首次观察到的是遗留 remove/actual 事件,firstAddedBy 必须为 unknown;同一 caller 的后续新 source 只追加 items,不覆盖旧 source;无 canonical targetId 的解析失败只记 routing diagnostic +- [x] AC-H2: Steer remove/add 只在对应 Queue mutation 成功后更新同一 view item revision 与最小变更事实;actual admission 赢得并发时 History ref 优先,旧 snapshot 不得制造假的 withdrawn;重启不承诺恢复 runtime-only 增删说明 +- [x] AC-H3: 已进入 durable Queue、随后在 actual admission 前失效的 target 不从 view 静默消失,关联 canonical `delivery_failure`;durable Queue admission 前被 routing preflight 拒绝的 target 则在本次发送结果中立即返回 typed fail-back,不登记 view;dispatchRef 只关联已经发生的投递或投递失败事实,不能仅凭 ref 存在断言 provider 已收到消息;真实状态与 response identity 始终从 Queue + source `dispatchRefs[]` + canonical response 反查 +- [x] AC-H4: caller 下一自然 invocation 先反查所有保留 items,以 canonical fingerprint 变化推进 revision,再只注入 `revision > presentedRevision` 的变化;能表达 pending、executing、completed/failed/canceled/interrupted、delivery_failure 与 not-delivered/withdrawn;pending/open 成功呈现后仍保留 item但不重复推送未变化版本;executing 已确认且无 Queue mutation 时,response 单独转 terminal 仍必须被下一 turn 发现 +- [x] AC-H5: projection 冻结生成正文时的 includedRevision;查询期间再次 Steer 必须重读或保留待核对,不能用最新 revision 标记旧正文。成功后 pending/open 推进 presentedRevision,terminal/withdrawn 仅在 current revision 匹配时 compare-and-clear;失败、裁剪、取消与本 turn 新 revision 均不确认 +- [x] AC-H6: active-runs 共享快照与 caller 私有 dispatch view 职责分离;completed/canceled/interrupted 的自动 continuation 只来自显式 owner;failed terminal 只创建一条 exact、幂等、Queue-only `a2a_failure` 控制载体,不创建第二条 status message且不得递归 +- [x] AC-H7: runtime view 不持久化、不扫描 History 重建;每个 caller scope 获得一次成功交付的有界 `process_start` 说明,失败后重试、成功后同 generation 不重复,且确认说明不清理当前 view items +- [x] AC-H8: 回归覆盖 A 发 M1→B/C/D 后 B unavailable、C 被 Steer 移除再重新加入、D actual terminal、E 由 Steer 新增,以及 M1 未终结时 A 再发 M2→B/D;另锁住 pending 连续两轮不变不重复、投影查询期间再次 Steer、terminal 清理后同 key 重建不会复用旧 revision + ## Scope Boundary - **In scope**: undelivered user message 对 cat cognition (`callback / thread context / prompt / pending-mentions`) 的泄漏,以及 canceled message 对 owner timeline/history 的 resurfacing -- **Out of scope but related**: `cat_cafe_post_message` callback 路由的 @mention 解析/路由异常(走 `callbacks.ts`,不经过 queue/delivery lifecycle) +- **Phase C in scope**: 所有公开 History source 的统一 dispatch 头像、response lineage、terminal 与 retry 投影 +- **Phase F in scope**: canonical member carrier、live append/interrupt、MCP 发送工具去门卫化,以及 A2A / multi-mention / connector 的 exact source×target replay 防线 +- **Phase G in scope**: managed hold 的共享等待消息、queued urgent wake、统一 response 终局,以及 ordinary A2A/hold completion MCP 与 Ball disposition 双轨退役 +- **Phase H in scope**: caller 自有 outbound dispatch 的进程内 revision view、预期 target 与 Steer 增删更新、caller invocation 对 Queue/History 的反查/注入/compare-and-clear,以及有界 process-start 说明;不改变 source `dispatchRefs` 语义,不自动重建旧 view;failed terminal 保留 exact 幂等 fail-back,其他普通终态不自动回唤 +- **Phase F callback boundary**: 显式行首 `@` 与 structured `targetCats` 都是不可被 terminal ACK 投影吞掉的路由指令;礼貌 ACK 与新 active hop 在 Queue admission 前分流 ## Dependencies @@ -126,8 +609,8 @@ operator experience: - **Related**: F047(Queue Steer)、community issue [#20](https://github.com/zts212653/clowder-ai/issues/20)、PR [#25](https://github.com/zts212653/clowder-ai/pull/25) Architecture cell: `dispatch` + `bubble-pipeline` -Map delta: none — F264 只把既有 message visibility 拆成 owner timeline 与 cat delivery 两个 typed read option,未改变 cell ownership。 -Why: Queue custody 仍归 dispatch,时间线投影与 receipt 合并仍归 bubble-pipeline。 +Map delta: dispatch cell 仍拥有 Queue pending;bubble-pipeline 仍拥有 History materialization、dispatchRefs 与 response lifecycle;caller outbound view 只是单 runtime 的变化索引。 +Why: 持久真相边界不变;view 只让成员在自己的后续自然 turn 核对自己发出的多条 source×target,不复制或裁决 Queue/History 状态。 ## Risk @@ -146,8 +629,35 @@ Why: Queue custody 仍归 dispatch,时间线投影与 receipt 合并仍归 bub | KD-3 | 修完后走全量 sync 而非 hotfix | 有多个已完成 F 待同步,hotfix 增加后续同步难度(operator决定)| 2026-03-14 | | KD-4 | Bug 3 拆分:queued @mention 泄漏 in scope / post_message callback 路由 out of scope | post_message 走 callback 路由不经 queue,硬塞进 F117 会混 scope(Maine Coon Design Gate 提出)| 2026-03-14 | | KD-5 | owner timeline publication 与 cat delivery 分成两个 typed read option | F264 receipt 必须让operator持续看见原消息;复用全局 `isTimelinePublished` 会把未投递正文泄给猫 | 2026-07-21 | +| KD-6 | Phase C 以 actual dispatch facts 建立单一 UI projection,不按消息来源分类 | user/cat/connector/GitHub 通知都可能成为 source;分类例外会再次制造多套生命周期 | 2026-08-31 | +| KD-7 | 删除旧 dock/占位/fallback,而不是继续收敛到 dock | dock 自身表达了第二套 receipt 模型,且增加用户无需理解的时间、执行类型与跳转信息 | 2026-08-31 | +| KD-8 | 成功静默、终态头像统一静止;结果只由 canonical terminal response 表达 | 头像只回答“谁在处理/处理过”,不复制 outcome;终态不携带旧 Queue attempt 的 retry 能力 | 2026-08-31 | +| KD-9 | 验收否决 `833aa0587` 对 pending bubble / tips 的删除:processing 回复行承接脉冲头像与 tips,两个尺寸共用一份 `activeRun` 状态 | co-creator:用户要看见被触发成员在动,且不需要两套定制 | 2026-09-03 | +| KD-10 | 停止是唯一用户动作;投影与真相不一致由服务端对账,不由用户「强制重置」 | co-creator:用户只有在运行/未运行两态;force-reset 是对多 SoT 分叉的补丁(F220 KD-3 曾推迟根因),根因已由 F194 / TurnExecution / ADR-043 关闭,剩余归 reconciler(ADR-043 D9) | 2026-09-03 | +| KD-11 | Agent 与 UI 共享 A79 exact lifecycle predicate;扩展现有 `get_thread_context`,不新增执行状态 MCP | co-creator:人和猫应看到、理解同一生命周期;工具数量不应因同一只读事实增加。最近发言与运行态必须在文案和证据层彻底分开 | 2026-09-04 | +| KD-12 | `carrier` 是唯一接入方式配置;兼容只在配置读取边界,选项彼此平级且不 fallback | client 能力必须由实际 transport 决定,不能让 UI、provider 与全局 env 各维护一套真相 | 2026-09-09 | +| KD-13 | 退役 MCP side-effect freshness gate;运行中正文由 live carrier 接住,单轮 carrier 继续排队 | `post_message` 顺便查 inbox、阻止发送并教学补救把 transport 缺口转嫁给工具,制造 Agent/用户视图分叉 | 2026-09-09 | +| KD-14 | 重放防线以 History exact source×target dispatch 与 wait outcome generation 为准 | 已处理 connector source 被旧 Queue/回调重新准入时,只有持久 execution truth 能阻止重复 side effect | 2026-09-09 | +| KD-15 | managed hold 的恢复是普通优先 Queue 消息,不是 Ball disposition 协议 | 跨 invocation 等待需要持久 task/condition;条件满足后的执行已经由 source/Queue/response 完整表达,再加 completion tool 与 stop gate只会制造第二套终局 | 2026-09-10 | +| KD-16 | completed/canceled/interrupted 不自动回唤 caller;failed terminal 保留 exact 幂等 Queue-only fail-back,caller view 在任何后续 invocation 补充未呈现变化 | canonical response 是唯一公开结果;`a2a_failure` 仅负责及时驱动 caller 处理失败,不能被误判为第二条结果。客户端单 runtime view 优化连续体验,重启后回到持久 History | 2026-09-15 | +| KD-17 | source `dispatchRefs` 只关联已发生的投递或投递失败事实;caller 另有单 runtime、按 revision 更新的 outbound dispatch view | 发出者要在后续自然 turn 同时核对预期目标、Steer 增删和 actual result,但不应为此改写持久 Queue/History 模型或新增 attemptId;view 只索引变化,状态仍反查 canonical owners;ref 存在本身不等于 provider 已收到消息 | 2026-09-14 | +| KD-18 | 「能否引导当前 invocation」与「当前可见 provider turn 是否精确读取」是两个独立声明 | `activeInvocationGuidance` 只回答 concrete adapter 能否非中断地追加到当前 invocation;`deliverySemantics` 只回答 provider 何时读取。Claude Agent SDK 显式声明前者 `supported`、后者 `queued_internal_turn`:允许「引导回复」,但绝不冒充当前可见同轮已读取。API admission、catalog 与 Web 只消费前者决定可用性,展示层消费后者说明精度 | 2026-09-20 | +| KD-19 | 内部协议诊断只进入 telemetry/private evidence,不先写 History 再由 API/Web 隐藏 | 展示层屏蔽不能修复错误的生产边界;新代码停止生成 `routing-guard-failure`,API 读取过滤只保留为旧版本存量兼容 | 2026-09-20 | +| KD-20 | terminal 后的任何显式路由凭据永远开启新 active hop;只有无行首 `@`、无 structured `targetCats` 的礼貌文本可 quiet ACK | 文本与结构化目标都是明确路由指令,生命周期投影不能静默吞掉;新的 generation 与统一 loop-streak 足以防止 ACK 乒乓 | 2026-09-20 | ## Review Gate +The latest-main replay continuity and retirement account is recorded in +[`f117-latest-main-replay-ledger.md`](../architecture/f117-latest-main-replay-ledger.md). Review of +the replay must use that ledger together with the mechanical A1/A2 audits; compilation alone is not +evidence that main composition roots survived. + - Phase A: 跨家族 review(Maine Coon review 后端 delivery lifecycle 改动) - Phase B: 跨家族 review(Maine Coon review 前端适配) +- Phase C spec: co-creator 定稿;Ragdoll Opus 做内容一致性 review 后,实现才可开始 +- Phase C implementation: Opus 按 INV-C1..C8 逐条 review,并额外审计新增 fallback 与 kind/scope 分支; + co-creator worktree 体验验收仍是 fork/上游前硬门 +- Phase E: Fable 做 exact-HEAD delta 复审,硬门为 AC-E1、AC-E3 的「typed custody 行存在时仍 200」与 AC-E7 的 `reconciled` / pre-start TTL 收窄; + co-creator worktree 体验验收与 fork soak 仍是上游前硬门 +- Phase G: 跨族 reviewer 核验等待可见性、queued-before-admission、唯一 response 终局、MCP/route/Ball writer 全链 absence;co-creator 验证 AC-G6 后才进入 fork soak +- Phase H: 「source dispatchRefs 语义不变 + caller runtime view + revision compare-and-clear + failed-only exact fail-back」实现与跨族复审已完成;co-creator 的完整 worktree UAT 仍覆盖初始多目标、Steer 增删、不可用目标、连续新 source、正常终局与 runtime restart diff --git a/docs/features/F122-unified-dispatch-queue.md b/docs/features/F122-unified-dispatch-queue.md index 5c6c5f3bcc..64b5c6ded7 100644 --- a/docs/features/F122-unified-dispatch-queue.md +++ b/docs/features/F122-unified-dispatch-queue.md @@ -4,12 +4,22 @@ related_features: [F108, F117, F027] topics: [a2a, queue, dispatch, steer, multi_mention, architecture] doc_kind: spec created: 2026-03-14 +tips_exempt: 2026-09-03 ADR-043 consolidates existing A2A and Queue dispatch truth without adding a new user action or discoverable capability --- # F122: 执行通道统一 — A2A/multi_mention 入 Dispatch Queue > **Status**: done | **Owner**: Ragdoll | **Priority**: P1 | **Completed**: 2026-03-18 +> ⚠️ **队列内核设计已于 2026-09-02 收敛。** 本文档中关于队列条目状态、per-target 投递簿记与 Steer 预留的描述反映的是收敛前的实现。当前设计真相源:[ADR-043](../decisions/043-queue-durable-single-ledger.md) 与 [F117 Phase D](F117-message-delivery-lifecycle.md)。本文档保留作为演进历史。 + +> **当前校准(2026-09-09)**:A2A、multi-mention、用户与 connector 均进入同一持久 Queue ledger。 +> 一条 source message 始终只有一条 Queue entry,`targets[]` 只保存 pending recipients;Message 与该 single-source +> entry 原子 admission,不再拆成 per-target Queue rows、不再 `backfillMessageId`,也不再用 `message.queueCustody` +> 保存投递状态。multi-mention 必须先创建正文一致、可引用的真实 Agent History source,不能让 Queue payload +> 挂在另一条 callback response identity 上。实际投递与终局只写 History `dispatchRefs` / response lifecycle; +> 重放以 exact `sourceRecordId × targetCatId` 幂等跳过。 + ## Why operator experience(2026-03-14 19:25): diff --git a/docs/features/F175-unified-message-queue.md b/docs/features/F175-unified-message-queue.md index a9bfb805e9..0873e38e6a 100644 --- a/docs/features/F175-unified-message-queue.md +++ b/docs/features/F175-unified-message-queue.md @@ -5,6 +5,7 @@ topics: [queue, dispatch, priority, invocation, connector, architecture] doc_kind: spec created: 2026-04-24 intake_source: clowder-ai#575 +tips_exempt: 2026-09-03 ADR-043 replaces internal Queue custody and settlement mechanics while preserving the existing priority and reorder user journey --- # F175: 消息队列统一设计 — 优先级排序 + 用户可控编排 @@ -19,6 +20,8 @@ intake_source: clowder-ai#575 > > **Fixes**: clowder-ai#564 — urgent connector 消息不再通过 bypass 抢占 A2A 链,改走队列内优先级排序。 +> ⚠️ **2026-09-20 终态修订**:priority / position comparator 与拖拽重排语义**保持有效**;reorder 契约使用 `expectedQueueRevision + orderedVisibleEntryIds` 原子整批替换(RFC #1356 §4.1),且「吸走相邻条目」的 `exactSteerBatch` fence 已被 Lua 原子 claim 取代。持久模型是一条 source message 对应一条 Queue row,`targets[]` 保存全部尚未投递成员;普通 drain 以整条 source 为领取单位,完整 target set 可 admission 时一次并发 fan-out,不再做相邻 source 的前缀批处理,也不合并 Message identity 或正文。下方 Phase A 的 batching 方案仅保留为演进历史;当前真相源见 [ADR-043](../decisions/043-queue-durable-single-ledger.md) 与 [F117 Phase D](F117-message-delivery-lifecycle.md)。 + ## Why clowder-ai#564 现场案例:opus 在 A2A round 2 执行中,CI failure 通知以 `priority: 'urgent'` 到达 → `handleUrgentTrigger()` 直接抢占 → `signal.abort()` → opus 回复中的 `@gpt52` mention 被检测但路由被 `signal.aborted` 门控阻止 → 静默丢弃。用户无任何提示。 @@ -70,12 +73,12 @@ peekOldestAcrossUsers(threadId): ``` QueueProcessor 出队时: if entry.source === 'user': - 收集队列中紧随其后的连续 entries,满足: + 原子 claim 队列中紧随其后的连续 entries,满足: - source === 'user' - 同 userId - 同 intent - - 同完整 targetCats 集合(Set equality) - → 一起作为一次 invocation 的上下文 + - 同一个标量 target + → 一起作为一次 invocation 的独立 prompt messages(不拼正文) if entry.source === 'connector' or 'agent': 单条处理(当前行为不变) @@ -122,9 +125,9 @@ QueueProcessor 出队时: ## 实现不变量(maintainer review 要求) -1. **User-message batching 判定条件须等价现有 merge 语义**:source=user + 同 userId + 同 intent + 同完整 targetCats 集合(Set equality) +1. **User-message batching 不合并事实**:from=user + 同 owner + 同 intent + 同标量 target 的相邻 rows 可一次 claim,但每条 Message 的身份、正文与顺序保持独立 2. **Drag > priority 的精确边界**:只有显式手动 position 的 entry 才覆盖 priority -3. **Batching 不重新推断 target**:用 entry 上已解析好的 targetCats,不在 batching 逻辑里重新推断 +3. **Batching 不重新推断 target**:用持久 row 上已解析好的标量 target,不在 batching 逻辑里重新推断 ## Acceptance Criteria @@ -134,7 +137,7 @@ QueueProcessor 出队时: - [x] AC-A3: 出队逻辑 priority-first — urgent 消息在 normal 前面被处理 - [x] AC-A4: 用户手动 position 覆盖 priority 排序(仅显式设置时) - [x] AC-A5: 用户消息不再强制 merge — 每条独立 QueueEntry -- [x] AC-A6: 出队时 user-message batching — 连续同 userId + 同 intent + 同 targetCats 的 user entries 汇聚为一次 invocation +- [x] AC-A6: 出队时 user-message batching — 连续同 owner + 同 intent + 同标量 target 的 user rows 原子 claim,并作为独立 prompt messages 进入一次 invocation - [x] AC-A7: connector/agent 消息不受 MAX_QUEUE_DEPTH 限制 - [x] AC-A8: reorder API 可用(`PATCH /queue/reorder`) - [x] AC-A9: 回归:urgent connector 不打断 A2A 链(#564 原始场景修复) diff --git a/docs/features/F220-a2a-collab-reliability.md b/docs/features/F220-a2a-collab-reliability.md index 3c732f2db4..59eee6bb1b 100644 --- a/docs/features/F220-a2a-collab-reliability.md +++ b/docs/features/F220-a2a-collab-reliability.md @@ -4,12 +4,12 @@ related_features: [F216, F175, F153, F118, F194, F215, F224] topics: [a2a, observability, liveness, invocation, queue, interrupt, recovery, ux] doc_kind: spec created: 2026-06-02 -tips_exempt: automatic zombie-ownership and queue-publication hardening; no new user action or standalone capability surface +tips_exempt: 2026-09-03 KD-9 renews automatic zombie-ownership and Stop reconciliation hardening while deleting the separate force-reset UI; no new user action or standalone capability surface --- # F220: A2A 协作的可观测 · 可靠 · 可恢复 -> **Status**: in-progress | **Owner**: 小太阳·Maine Coon (Sol/GPT-5.6-sol;2026-08-04 operator 重分配) | **Priority**: P1 | **Source**: internal +> **Status**: done | **Owner**: 小太阳·Maine Coon (Sol/GPT-5.6-sol;2026-08-04 operator 重分配) | **Priority**: P1 | **Source**: internal > > **Thread legend**:`[thread-id]` = 驱动/owner thread(Layer 1 现场调查 + 落地,Ragdoll Opus-4.8)|`[thread-id]` = 立项 thread(平行 opus-48:立项 + 设计沉淀 + 交接,已收工)。 @@ -26,7 +26,7 @@ Why(一句话): A2A 触发与卡死恢复都落在既有 invocation/queue/tr 2. **可观测**:目标猫"启动中/排队中"占位及时出现在主聊天 chrome(复用 F118 D2 `spawn_started`)——用户看得见"猫在路上",不是空等一片 3. 猫正常跑完 → 回复出现;若在排队,用户看到队列占位而非"消失" 4. **可靠**:即使上一棒猫被判僵尸清理,它残留的 stale `processing` 队列条目也被一并收敛(clowder-ai#972)——用户后发的消息不会卡在一具"尸体"后面永远不跑 -5. **可恢复**:若猫真卡死(补一刀仍不进 running),thread 出现情境化 force-reset 逃生口(带确认弹窗,**只清运行态、绝不动消息/历史/记忆**,LL-048);用户点确认 → thread 解放 +5. **可恢复**:若猫真卡死,用户仍只点同一个「停止」;服务端自动从精确取消升级为就地对账(只清运行态、绝不动消息/历史/记忆,LL-048)→ thread 解放。进程快照不可用时服务端有界重试后按 dispatch terminal 把该执行终局为 failed 并沿 F117 Phase C 失败传播回溯上游(猫来源 A2A 报回、origin 来源可见失败终态);没有任何确认弹窗(2026-09-03 KD-9 取代原「force-reset 逃生口」,现行真相为 ADR-043 D9) 6. 全程用户对"谁在跑 / 卡没卡 / 能不能自救"有一致、可信的感知——猫间协作**看得见、信得过、能恢复** --- @@ -53,7 +53,7 @@ operator要"**信得过的猫间协作**"。但今天 A2A(猫@猫)协作在 - ✅ `POST /api/threads/:id/force-reset` 端点**已存在**(2026-05-29 bug-report 加的):cancelAll + 清 slot/record + 广播 done,user-scoped。 - 🔧 **Layer 1 缺口(Phase 1 implementation in `feat/f220-a2a-liveness-signal`)**:组件能渲染 spawning,但现代 InvocationQueue 路径缺少与 direct `/api/messages` 对等的 `spawn_started`。QueueProcessor 开始 processing 后只发 `queue_updated`,`intent_mode` 又按 #768 延迟到 CLI 第一条事件;CLI 冷启动/首事件延迟期间,主聊天 chrome 拿不到"目标猫正在启动"信号。修复:`QueueProcessor.executeEntry` 在 `startAll` + running record 后、`intent_mode` 前广播既有 `spawn_started`。 - ⚠️ **Layer 2**:has()=false 占用 slot 的真 hang(dead-Redis create 等病态);#2053 已证 steer 无法 sound force-recover,只能靠 75min sweep / force-reset。 -- ⚠️ **Layer 3**:force-reset 端点有,**前端无 UI 入口**——用户卡死时没有可点的逃生口。 +- ⚠️ **Layer 3**:force-reset 端点有,**前端无 UI 入口**——用户卡死时没有可点的逃生口。→ Phase 3 曾接入常驻/升级入口;**2026-09-03 KD-9 退役**:入口与「运行状态待确认」横幅删除,端点只作为 Stop 阶梯最后一级的 thread 级对账。 ## What @@ -99,6 +99,8 @@ bug 链:opus parent 结束本轮 → tracker 没了/无 fresh draft → 过 gr 收敛顺序固定为:`InvocationRecord CAS failed` → 精确移除 stale queue row / rollback owned siblings → owner-guarded slot release → snapshot 进入 `emitQueueUpdated` 的 `(socket,thread,user)` publication tail → predecessor settled → **在 enrichment deadline 内按调用顺序发布 enriched 或 frozen raw removal snapshot** → dispatch replacement;所有其他 queue publisher 也必须进入同一 tail,queue mutation、record/slot 收敛与其他 scope 不等待 enrichment,任何单次 enrichment stall 不能无限保留 tail,TaskProgress cleanup 与后续 zombie 的关键收敛解耦并行执行,但自身必须按 invocation owner 原子删除。 ### Phase 3 — 可恢复:force-reset 逃生口 UI(Layer 3) + +> **2026-09-03 退役(KD-9)**:逃生口不再是用户概念——「停止」是唯一动作,服务端阶梯自动对账(ADR-043 D9)。本节与下方设计稿保留为历史记录。 把已有的 `force-reset` 端点接到一个**情境化、带确认弹窗**的 UI 入口。**设计稿见下方 §设计稿(operator 2026-06-02 已审过概念 + 确认要弹窗确认)**。 ## Acceptance Criteria @@ -115,10 +117,11 @@ bug 链:opus parent 结束本轮 → tracker 没了/无 fresh draft → 过 gr - [x] AC-2.2: 修复按 seam 收敛——F194 PR #2928 的 tracker/slot recovery + F220 PR #2917 的 queue-row convergence + PR #3047 的 serial-child parent liveness + clowder-ai#1150 的 owner-CAS/publication fence 均已合入;`/active-pane` no-op 已删除;pre-start slot↔row TTL slice 已由 PR #3415(`e76dec000`)合入。 **Phase 3(可恢复)✅ code+test done @ main 4e80ec889(PR #2065 squash);runtime 截图验收 → operator quickpath** -- [x] AC-3.1: 卡死/有活跃调用时 thread 出现情境化 force-reset 入口(非常驻)。→ `ThreadExecutionBar` 内 `ForceResetEntry`,有猫在跑才显示,`suspected_stall`/`alive_but_silent` 时上浮升级(`data-escalated`)。测试 4 绿。 -- [x] AC-3.2: 点击弹**确认弹窗**(做什么/保留什么/何时用),确认才执行。→ `ForceResetDialog`(取消默认 focus / 强制重置危险红)。测试 4 绿。 -- [x] AC-3.3: 确认 → force-reset → thread 解放;消息/历史**不丢**(LL-048 只清运行态)。→ `apiFetch POST force-reset` + toast「已重置」。复核(截图前后 + 消息条数不变)→ operator quickpath runtime 验。 +- [x] AC-3.1: 卡死/有活跃调用时 thread 出现情境化 force-reset 入口(非常驻)。→ `ThreadExecutionBar` 内 `ForceResetEntry`,有猫在跑才显示,`suspected_stall`/`alive_but_silent` 时上浮升级(`data-escalated`)。测试 4 绿。→ **superseded 2026-09-03(KD-9)**:入口删除,卡死时用户仍只点「停止」。 +- [x] AC-3.2: 点击弹**确认弹窗**(做什么/保留什么/何时用),确认才执行。→ `ForceResetDialog`(取消默认 focus / 强制重置危险红)。测试 4 绿。→ **superseded 2026-09-03(KD-9)**:弹窗退役;进程快照不可用时按 failed 终局并沿失败传播回溯上游,不弹任何确认。 +- [x] AC-3.3: 确认 → force-reset → thread 解放;消息/历史**不丢**(LL-048 只清运行态)。→ `apiFetch POST force-reset` + toast「已重置」。复核(截图前后 + 消息条数不变)→ operator quickpath runtime 验。(端点保留为 Stop 阶梯最后一级的 thread 级对账;LL-048 边界不变) - [x] AC-3.4: force-reset 对 record-present hung 和 **recordless canonical Queue owner** 都能清。后者从 user/thread-scoped `processingSlots` 发现 exact Queue group,在首个终态 await 前安装 replacement barrier,完成 durable row/custody terminalization 后才释放;迟到 cleanup 无法删除 replacement owner。真实 route + QueueProcessor 回归并验证 `queue_updated` 发布。2026-08-19 新 runtime(revision `1d1e774d4`,包含 PR #3782 merge `e240a8183`)live acceptance:exact cancel 后 Queue/active 归零,180 秒 foreground tree 与其 exact app-server host 消失,无关 host 存活;同 thread 后继在新 host 完成 exact token,未续跑旧指令;随后 Queue-backed 120 秒 invocation 经 force-reset 再次收敛至 queue=0、active=0、无 marker/host 残留。 +- [x] AC-3.5: **逃生口退役 + Stop 阶梯(KD-9 / ADR-043 D9)**:删除 `ThreadExecutionBar` 常驻与 `suspected_stall` 触发的 force-reset 入口、「运行状态待确认」横幅与 `ForceResetDialog`;exact Stop 无活候选时服务端就地对账返回 `reconciled`,进程快照不可用时按 failed 终局;`/force-reset` 仅作内部 reconciler。→ PR #1398(F117 Phase E,AC-E7)代码与自动化验收完成,进入跨族复审 / worktree 体验。 ## Dependencies - force-reset 端点(`queue.ts`,已存在)。 @@ -139,8 +142,9 @@ bug 链:opus parent 结束本轮 → tracker 没了/无 fresh draft → 过 gr - KD-5(2026-06-17 Ragdoll opus-48,接 Maine Coon routing #972):Phase 2 答案按 **F220↔F224 轴接缝**切两层——**2a 局部修**(active-pane canonical-liveness SoT / reconcileZombies→queue 收敛 / slot↔queue 一致 / 回归)= F220 已祝福方向内、可逆 → **自决实现,不上 operator**;**2b 架构 seam**(serial-continuation-child ↔ parent/queue liveness 桥接,牵动"统一 liveness SoT"+ 轴边界重画)= 架构级 → 出 Decision Packet 交 **operator 拍板拆 feat**。遵 KD-3:2b 不在根因报告+repro 前动手大改。**#972 是"两轴不共享根因"假设的反例**(轴在此 failure mode 交互)——若 2b operator 决定重画边界,序言断言需同步修订。 - KD-7(2026-08-04 小太阳·Maine Coon,operator 接管后 closure audit):KD-5 的 Decision Packet 前提已被 2026-07-18 PR #3047 的后续事实消解。child lifecycle 继续由既有 `TurnExecutionStore` 持有,F194 canonical read 只消费它;没有新增 store、extension point 或 ownership cell。结论是 **不拆新 feat、不改 `/active-pane`、不再向 operator提交过期的架构 A/B 题**,直接按已合入证据同步 truth。 - KD-8(2026-08-18 小太阳·Maine Coon):#1371 live acceptance 暴露的两个 residual 按确定契约分治。Queue reset 复用既有 pre-start exact-group retirement barrier,不新造 slot 恢复机制;Codex 取消一旦被 lease 观察就永久污染 exact host,cooperative close 和 timeout 都只能走 host eviction 与既有 supervisor owner-token/PID-start-identity 收敛。裸 `@target` 仍是 composer 语法,literal 日志/示例用反引号或代码块,不修 runtime parser。 +- KD-9(2026-09-03 co-creator,#1398 worktree 验收):**force-reset 逃生口退役为用户概念**。用户只有在运行/未运行两态和一个动作「停止」(单只/全部);停止的成功判据是投影进入未运行。服务端阶梯:exact 活取消 → 无活候选则就地对账(pre-start 退回 / running 记录置 canceled / 孤儿锁与槽位释放 / 终态广播)返回 `reconciled`,永不对自有执行 409;进程快照不完整时服务端有界重试后按 dispatch terminal 将该 exact child 终局为 failed 并沿 F117 Phase C 失败传播回溯上游(猫来源 A2A 报回、origin 来源可见失败终态;重做只来自新的用户意图),没有任何确认弹窗;有活性见证的 sibling 保持运行且 parent 不被提前终局;read-repair 与 pre-start timeout 使用真实失败原因。不做平台兼容分支,Windows 子进程不可观测同样走这条 fail 分支收敛。「运行状态待确认」横幅与基于 `suspected_stall` 上浮的入口删除。根因定性:Phase 2 报告(#972)的「多 liveness SoT 无收敛点」是设计缺陷,F194 / TurnExecution / ADR-043 已关闭进程内分叉,剩余基础设施故障归 reconciler(启动 / 读取 read-repair / 停止三处复用)。现行真相:ADR-043 D9。 -## 设计稿(Phase 3 — force-reset 逃生口 UI,operator 2026-06-02 已审概念) +## 设计稿(Phase 3 — force-reset 逃生口 UI,operator 2026-06-02 已审概念;**2026-09-03 KD-9 退役,仅存档**) **它 reset 什么**:reset 这个 thread 的**执行状态**——取消你在这个 thread 里所有在跑的猫调用 + 清掉卡住的"正在回复中"/"队列繁忙"状态 → thread 重新能用。**不删消息/历史/thread,不碰别人的调用,不碰记忆数据**。只擦"谁在跑"的临时态。 diff --git a/docs/features/F233-ball-custody-observability.md b/docs/features/F233-ball-custody-observability.md index 667609c49e..38f977516d 100644 --- a/docs/features/F233-ball-custody-observability.md +++ b/docs/features/F233-ball-custody-observability.md @@ -4,7 +4,8 @@ related_features: [F167, F153, F117, F064, F081, F232, F192, F055, F052, F193, F topics: [observability, a2a, ball-custody, cvo-experience, harness-engineering] doc_kind: spec created: 2026-06-12 -tips_exempt: harness-internal observability — ball custody events/projections are backend plumbing, no user-facing tip needed +updated: 2026-09-10 +tips_exempt: 2026-09-18 F117 lifecycle cutover remains harness-internal observability — ball custody events/projections are backend plumbing, no user-facing tip needed user_journey_exempt: harness-internal observability — ball custody events/projections/admin panel have no user-perceivable UX changes for operator --- @@ -16,6 +17,11 @@ user_journey_exempt: harness-internal observability — ball custody events/proj Architecture cell: `ball-custody` Map delta: new cell required — Phase B 新增 append-only event log + projector + projection store;Phase A rich block surface 仍属既有 hub-action-surface。 +> **2026-09-10 lifecycle cutover**:F233 Ball event log继续保留历史审计与真正独立责任对象, +> 但不再承接 ordinary A2A 或 managed-hold message completion。新写入由 F117 Phase G 的 +> `source → Queue admission → response terminal` 唯一表达;等待中/唤醒/终局对 operator 与所有成员共用 +> History 投影。旧 `ball.handed` / dispatch/hold disposition events 只读兼容,不参与新执行裁决。 + ## Why **整个系统里唯一没有掉球保护的 agent,是operator。** 过去几个月我们给猫修了一整圈球权 harness(F064 漏传球 / F167 乒乓熔断+虚空传球 / F117 投递生命周期 / mention auto-ack / F177-G 路由守卫),但 operator 收球(@co-creator / 待验收 / 待拍板)后没有收件箱、没有晾龄计、没有超时提醒——记忆的活全压给人。 diff --git a/docs/features/F244-capability-tips-system.md b/docs/features/F244-capability-tips-system.md index 0036ce02b0..d7a800461c 100644 --- a/docs/features/F244-capability-tips-system.md +++ b/docs/features/F244-capability-tips-system.md @@ -29,11 +29,11 @@ operator 2026-06-18 收敛: > "直接立项吧,反正第一个用户就是我啊!" -等待态不是单纯的 dead time。用户盯着猫猫思考、执行、等待外部条件时,注意力被自然锁住;这几秒最适合把 Clowder AI 的能力、家规、magic words、工作流边界和新 feature 用法轻量投影出来。目标不是把 loading 文案变可爱,而是把 W7 Knowledge Feed 和 F223 capability registry 变成用户能自然吸收的产品表面。 +等待态不是单纯的 dead time。用户盯着猫猫思考、执行、等待外部条件时,注意力被自然锁住;这几秒最适合把产品能力、家规、magic words、工作流边界和新 feature 用法轻量投影出来。目标不是把 loading 文案变可爱,而是把 W7 Knowledge Feed 和 F223 capability registry 变成用户能自然吸收的产品表面。 ## Current State / 现状基线 -- 现有等待/执行 UI 已有真实状态层:`packages/web/src/components/ThinkingIndicator.tsx` 显示 `启动中` / `思考中` / `回复中` / `静默等待中` / `可能卡住了`,`packages/web/src/components/ThreadExecutionBar.tsx` 显示 `执行中`、计时、停止与 `卡住了?强制重置`。 +- 现有等待/执行 UI 已有真实状态层:`packages/web/src/components/ThinkingIndicator.tsx` 显示 `启动中` / `思考中` / `回复中` / `静默等待中` / `可能卡住了`,`packages/web/src/components/ThreadExecutionBar.tsx` 显示 `执行中`、计时与停止(`卡住了?强制重置` 常驻入口已于 2026-09-03 随 ADR-043 D9 退役)。 - 现有能力真相源已分散存在:F223 产出 Capability Surface Registry,`cat-cafe-skills/refs/capability-wakeup-index.md` 维护 L0 §8 能力速查,F114/L0/shared-rules 维护 magic words,F155 guide engine 有场景 tips,F227 Event Memory 索引 magic word 事件。 - 当前没有一个用户可见的等待态 tips 投影层。用户要知道"家里有什么能力 / 什么时候用 / 怎么用",仍主要靠聊天中被动问、读文档、或猫主动解释。 - F229 猫猫球已经承担"前台猫 / 功能发现 / 常驻入口"职责;它需要 tips 时必须消费 F244 的 tip contract 和 selector,不维护自己的 tips 文案清单。 @@ -129,13 +129,13 @@ F229 猫猫球是 F244 的未来展示面,不是第二个 tips 系统: 在等待/执行 UI 中增加 tips 投影,但与真实状态分层: -- 真实状态仍由 liveness/runtime signal 驱动,`ThreadExecutionBar` / `ThinkingIndicator` 继续放状态、计时、停止与强制重置入口。 -- Tip 的第一展示面是 `ThreadExecutionBar` 内的 neutral execution strip:无猫头像、无名字、无 - `MessageBubble` identity;时间线只渲染服务端真实消息。 -- 一个 thread 只展示一个 execution tip strip;多猫执行不为每只猫复制 tip,也不产生 - `pending-*` 占位消息。 -- `suspected_stall` / `alive_but_silent` 时,故障与取消入口优先;tips 不得遮挡或弱化 `卡住了?强制重置`。 -- execution strip 中 tip 立即展示(`firstDelayMs=0`);其他 surface(如 +- 真实状态仍由 liveness/runtime signal 驱动,`ThreadExecutionBar` / `ThinkingIndicator` 继续放状态、计时与停止入口。 +- Tip 的第一展示面是服务端已创建的 bodyless processing response:它带 exact invocation identity、 + 猫头像和响应消息 ID,正文到达后原位升级成真实回复,不创建 `pending-*` 合成消息。 +- 一个 thread 只让一个 processing response 展示 tip;其他并行响应只显示最小等待动效,避免复制 + tip,也不在 `ThreadExecutionBar` / QueuePanel 再投影同一等待信息。 +- `suspected_stall` / `alive_but_silent` 时,故障与停止入口优先;tips 不得遮挡或弱化停止(强制重置常驻入口已退役,见 ADR-043 D9)。 +- processing response 中 tip 立即展示(`firstDelayMs=0`);其他 surface(如 `assistant_stream_bubble`)保留首次延迟。单条至少停留 30s,避免每几秒闪动制造噪音。 - 上下文选择优先级:当前执行阶段 > thread workflow > feature dev/review mode > 通用 capability/magic word;`ideate`/review 等待仍使用 `review` + `long_running` contexts,而不是退化成通用 `thinking` tips。 - 支持 action:hover 提示"了解更多";click 拉起 F229 猫猫球并把解释请求预填进输入框,不自动发送。需要直达时可附 source/guide/capability surface secondary action;没有 action 的 tip 不能冒充可执行能力。 @@ -202,7 +202,7 @@ Bloom filter 不用于 D1。原因:当前 inventory 量级是几十到几百 ### 3. Regression Fixture - 等待态 `thinking` 展示 capability tip,但主状态仍只显示真实 `思考中/回复中`。 -- `suspected_stall` 状态下故障文案、取消和强制重置入口可见且优先,tips 不遮挡。 +- `suspected_stall` 状态下故障文案与停止入口可见且优先,tips 不遮挡。 - Magic word tip 带 shared-rules/L0 sourceRef,CI 验证 anchor 可定位;body 语义由 owner/reviewer/stale review 维护,不声称静态 CI 可证明语义一致。 - 新 feature PR fixture 缺 tips 且无 `tips_exempt` 时 hard check red;补 sourceRef + context 后 green。 @@ -304,6 +304,7 @@ Bloom filter 不用于 D1。原因:当前 inventory 量级是几十到几百 | KD-13 | #997 曝光均匀性用 Set/Map 轮转,不用 Bloom filter | tips inventory 是小集合,Set/Map 简单、可删除、可按 scope 清轮且无假阳性;Bloom filter 为百万级省内存去重设计,假阳性会漏 tip,不适合本场景 | 2026-06-22 | | KD-14 | ~~执行状态只能属于 execution chrome;时间线不得有 pending member projection~~(2026-07-28 superseded) | #3261 把“同一执行重复头像”误泛化成“任何 pending 头像都不合法”,删除了已 dogfood 的正常 pre-output 身份与 tip 位置 | 2026-07-27 | | KD-15 | Pending member projection 必须按 exact invocation 因果去重 | 正常首个等待头像有价值;真正 bug 是 later user message 切断扫描边界,使已经有真实输出的同一 invocation 再生成第二头像。去重键使用 `(catId, parent/turn invocationId)`,不使用“最后一条 user message”或单纯 catId;缺 causal identity 的 hydration slot fail closed,显式 post 不算真实输出 | 2026-07-28 | +| KD-16 | `PendingMemberBubble` 的 presentation 合入 durable processing response | ADR-043 后服务端先创建带 exact invocation 的 response;复用它承载头像与 tip,正文到达后原位升级,才能同时保留 pre-output 体验并消除 synthetic wait bubble 与真实回复的重复身份 | 2026-09-03 | ## Review Gate diff --git a/docs/features/F254-side-effect-freshness-gate.md b/docs/features/F254-side-effect-freshness-gate.md index 3064bafc60..d66caab200 100644 --- a/docs/features/F254-side-effect-freshness-gate.md +++ b/docs/features/F254-side-effect-freshness-gate.md @@ -5,13 +5,15 @@ related_decisions: [040, 041, 042] topics: [freshness, glass-box, supplement, inbox-notice, runtime-descriptor, side-effect-gate, codex-app-server, lifecycle, liveness, ax] doc_kind: spec created: 2026-06-27 -updated: 2026-09-05 +updated: 2026-09-20 tips_exempt: F254's automatic freshness runtime and eval-measurement correctness surfaces add no user action, setting, or discoverable capability. --- # F254: Side-Effect Freshness Gate — 副作用出口 freshness 拦截 -> **Status**: in-progress, **D2 live app-server canary lifecycle/parity hardening + E-C complete** (**ADR-042 glass-box publish-then-supplement merged via PR #2906 (`ace5412c0`); durable Queue custody / migration / replayable `eval:freshness` merged via PR #2912 (`07f46f5aa`); D2 default-off provider-native carrier merged via PR #3004 (`680ab702f`); typed causal provenance + durable child lifecycle merged via PR #3036 (`9ae942beb`). The operator has explicitly enabled `CAT_CAFE_CODEX_CARRIER=app_server` in the live runtime canary. PR #3079 (`3b83fb43c`) fixed LF-only JSONL framing plus pump rejection isolation; PR #3082 (`7dd7a4d51`) restored provider-neutral Codex diagnostics; PR #3097 (`54aef5e74`) merged AC-D14 lifecycle parity/no-replay after Terra exact-HEAD review; PR #3285 (`65ef23d17`) merged exact capacity checkpoint continuation after gpt52 exact-HEAD review. Code default remains `exec_json`; broad rollout is still gated by AC-D15~D17 and explicit rollout authorization. 2026-08-04 normal-runtime UAT proves the live app-server path is loaded, but also proves Codex 0.146.0 protocol drift: a completed `collabAgentToolCall/wait` crossed four real boundaries without a notice because the classifier/eval census knows only command/file/MCP/dynamic. F264 owns the separate author-intent visibility and capability-aware UI close gate.**) | **Owner**: 小太阳·Maine Coon (@codex-sol, GPT-5.6 Sol) | **Priority**: P1 +> **Status**: partially superseded — **Phase A HELD + Phase B1 MCP piggyback + Phase B2 hold reminder + D1.2 Queue seen/handled ownership are retired by F117/#1398**; exact output-commit closure, glass-box supplements, provider-native freshness evaluation and honest carrier precision remain independently owned here. Live delivery capability follows each member's canonical `carrier`; `activeInvocationGuidance` is a separate adapter capability and must not be inferred from freshness precision. | **Owner**: 小太阳·Maine Coon (@codex-sol, GPT-5.6 Sol) | **Priority**: P1 + +> ⚠️ **Current ownership boundary (2026-09-20)**:本文中 `queued_seen` / `queued_handled` / per-target `TargetStatus`、`post_message` 强迫读取与 HELD escape hatch、以及 Queue receipt/reminder 的已勾选段落是历史交付记录,不再描述 live contract。Queue 当前只拥有 pending `targets[]`、顺序、author intent 与短 claim;完整 same-target read 是一次 actual dispatch adoption;普通 callback send 没有 freshness side-effect gate。当前投递真相源见 [F117 Phase D/F](F117-message-delivery-lifecycle.md) 与 [ADR-043](../decisions/043-queue-durable-single-ledger.md)。 Architecture cell: ball-custody, dispatch, bubble-pipeline, transport, harness-eval @@ -648,11 +650,13 @@ interface RuntimeCapabilityDescriptor { 3. 触发前必须检查已有 coverage:queued/newer invocation/freshness re-invoke 已存在则 skip 4. prompt 必须指向能 ack 的读取路径(无 filter 的 `get_thread_context`),或实现显式 D1 ack;不能只说 `list_recent` -**D1.2 queued read/ack journey 重开(2026-07-01 operator 实测)**: +**D1.2 queued read/ack journey(历史实现,已由 ADR-043 / F117 退役)**: + +> 以下四点只记录 2026-07-01 当时的问题与过渡方案,不是当前 Queue contract。现行实现不把 queued 正文注入普通 History read,也不在 Queue 保存 `queued_seen / queued_handled`;完整 same-target read 是 actual-dispatch adoption,当前边界见本节 AC-D8 后的 Ownership boundary。 - PR #2664 让 gate/notice 能看到 `InvocationQueue` 中的 queued 消息,但 `get_thread_context` 仍只返回 delivered 消息,猫无法 fetch queued 正文并确认自己看过 - 单纯让猫 fetch queued 正文还不够:`read/seen`、`handled`、`delivery`、`target consumption` 是四个不同语义;把 fetch 直接当 consume 会吞掉多目标并行任务 -- 当前推荐方向:full read 只产生 per-cat `queued_seen`;只有成功回应或显式 disposition 才产生 per-cat `queued_handled`;多目标 entry 必须保留其他 target +- 当时采用的过渡方向:full read 产生 per-cat `queued_seen`;成功回应或显式 disposition 产生 per-cat `queued_handled`;多目标 entry 保留其他 target - 稀疏读取(`keyword` / `messageId` / `catId` filter)默认不 ack queued work,避免猫只看局部结果却把未读队列标成已处理 #### D2: Provider-native 工具的 same-turn notice 覆盖(reopened 2026-07-16) @@ -670,7 +674,12 @@ Phase A 写门也只保护显式 Clowder AI MCP 发消息路径,普通 stdout 写完首条 prompt 即关闭 stdin;Claude Code 2.1.210 虽支持 stream-json input,但官方允许工作中消息排为 自己的 internal turn,必须经 live fixture 区分 `exact_active_turn` 与 `queued_internal_turn`。共享 Queue、 notice broker、seen/handled truth 和 eval 不分叉,只在 Codex/Claude 最后一米 adapter 分叉。 -operator 未授权 shared core、carrier 迁移与 Claude capability spike 前不写正式实现。 +这段是 Phase 0 的历史 gate。[2026-09-20 的 F117 公开 owner 决策](https://github.com/zts212653/clowder-ai/pull/1398#issuecomment-5747658094)只授权 Claude Agent SDK 已有 streaming +adapter 的**当前 invocation 非中断追加能力**,不授权把 queued acceptance 提升为 exact-active-turn 证据。 +共享契约因此拆为两个独立字段:`activeInvocationGuidance='supported'` 表示可追加到同一运行, +`deliverySemantics='queued_internal_turn'` 表示只能承诺下一内部轮次读取。未来若要把后者提升为 +`exact_active_turn`,仍须生产形状 fixture 证明 same-result UUID consumption、queue count 不增长、模型实际认知、 +以及 terminal race 的诚实降级;本决策不豁免这些证据。 ### Phase E: Catch Closure(检测 → 消费 → 处理 → 最终交付) @@ -747,13 +756,13 @@ Phase E 不再增加另一层“提醒猫去读”的 fallback。它改变输出 - [x] AC-D5: 猫的 stream output 是自回复(thread 中最新消息是自己发的)→ 不标 stale(self-message 排除,与 Phase A 一致) - [x] AC-D6: **D1.1 regression fix**:`stream output stale` 不得无条件强制 re-invoke。同一 stale set 必须 single-flight 去重;已有 queued/newer invocation/freshness/current-user same-cat pending coverage 时只标记 stale + 记录 skip,不 enqueue 第二次。**merged**: PR #2701 — D1 single-flight claim + enqueue-outcome release + current-user pending coverage - [x] AC-D7: **D1.1 ack path**:D1 re-invoke prompt 必须要求可推进 seenCursor 的读取路径(无 `catId`/keyword/messageId filter 的 `get_thread_context`),或实现独立 D1 ack 事件;`list_recent` / filtered context read 不能作为闭环完成凭据。**merged**: PR #2701 — prompt 指向 full `get_thread_context` -- [x] AC-D8a: **D1.2 queued read + freshness suppression**:无 filter 的 `get_thread_context?responseMode=full` 必须能返回 same-target queued 正文;读取后在 `QueueEntry` 上记录 durable per-cat `queued_seen`(suppress duplicate freshness nag for same cat+message)。Sparse reads(keyword/messageId/catId filter)不得返回 queued 正文或记录 `queued_seen`。读取不得 `markDelivered`,不得 consume target。**merged**: PR #2707 — queued bodies remain readable after `queued_seen`, while duplicate same-cat freshness nags are suppressed. -- [x] AC-D8b: **D1.2 handled closure**:cat 对已 `queued_seen` 的 entry 成功回应后,产出 `queued_handled` 证据供 freshness loop 闭环;v1 handled 证据由 `queued_seen(entry, cat, I) + cat-level invocation terminal succeeded(I, cat)` 推断,显式 disposition 延后。**Evidence identity contract**:`queued_handled(entry, cat)` 成立当且仅当存在唯一 invocation `I`,seen 记录时 `I` 是 `(thread, cat)` 的 active invocation,且 seen 与 succeeded 锚定同一个 outer `InvocationRecord.id`。失败/取消不得 consume;delivery persistence + `messages_delivered` emit 未完成时不得提交 `queued_handled`。**merged**: PR #2712 — successful same-invocation handled closure consumes only the completed target, preserves failed/canceled work, emits delivery events atomically, and closes duplicate/retry/connector evidence edges. +- [x] AC-D8a **(历史已交付、现已退役)**:D1.2 queued read + freshness suppression 曾让 full `get_thread_context` 返回 queued 正文并在 Queue 记录 `queued_seen`;ADR-043 / F117 已用 actual-dispatch adoption 取代该 Queue receipt。 +- [x] AC-D8b **(历史已交付、现已退役)**:D1.2 handled closure 曾从 `queued_seen + succeeded` 推导 `queued_handled` 并消费 target;ADR-043 / F117 已删除该 Queue lifecycle,terminal truth 归 exact response / structured owner。 - [x] AC-D9: **A2A handoff reply suppression**:如果 B 的消息 `replyTo` 指向 A 自己的消息,且该父消息 mentions B,则 B 的回复是 A 主动发起的 handoff 覆盖,不得让 Phase A gate / D1 / B1 把它当作需要 hold 或重新唤醒 A 的未读 freshness;普通未被父消息 mention 的其他 cat 回复仍按 freshness 候选处理。 - [x] AC-D10: **Current trigger suppression**:connector/event/user message 如果就是当前 invocation 的触发消息(`currentUserMessageId` / trigger `messageId`),说明它已经进入本次 prompt;D1 stream freshness 不得再把同一个 message 计为 unseen 并 enqueue `Freshness -> cat`。其他非触发 connector 消息仍按 freshness 候选处理。 - [x] AC-D11: **Routable freshness input**:Phase A gate / B1 notice / D1 stream stale 只看可路由 conversation 内容。空正文且仅含 tool events 的 cat stream、`routing-guard-failure` 等内部诊断、`system` display-only 消息、`context_briefing`/`origin=briefing` 不得产生 held/notice/stale/re-invoke;rich blocks / contentBlocks 仍算可路由内容。`userId=scheduler` 的 hold-ball / scheduled-task trigger 含正文且会进入下一次 prompt,必须保留为 routable freshness input。 -**D1.2 Ownership boundary**:F039/F117 的 `QueueEntry` / `QueueProcessor` 单一拥有普通 queued user message 及其 per-target `queued / notified / seen / failed / handled` transition;F254 只消费该真相来压制 freshness nag,且不得为同一消息生成 supplement carrier。F086 继续拥有 canonical `TargetStatus` 语义与 per-recipient UI/routing consumption;F108 拥有独立 fan-out context cutoff。 +**D1.2 Ownership boundary(已被 ADR-043 / F117 Phase D supersede)**:现行 `QueueEntry` / `QueueProcessor` 只拥有普通 queued source 的 pending `targets[]`、顺序、author intent 与短 claim;不再保存 `notified / seen / handled / failed` 或 `TargetStatus`。完整 same-target read 通过 actual-dispatch adoption 从 Queue 删除 exact target,并把 delivery 写入 History lifecycle。F254 只消费外部 cursor/presentation/closure truth,不能为普通 Queue source 生成 supplement carrier;F108 继续拥有独立 fan-out context cutoff。 ### Phase D2(Provider-native safe-boundary notice;Codex exact-turn + Claude capability split) @@ -877,8 +886,8 @@ piggyback 推断 native coverage。 - [x] AC-E18: **carrier 终态覆盖**:queue full、scheduler 缺失、排队撤回、provider/cancel/policy failure 全部落 durable terminal;进程启动重建 pending carrier,running 若已有幂等正文则直接 commit,若无正文才持久失败,禁止重跑已发表 supplement。 - [x] AC-E19: **精确边界与兼容迁移**:annotation 只扫描 atomic pre-append frontier;幂等 retry 复用原消息与原 observation boundary;legacy running closure 在新原文发表后可 terminalize,但旧 `superseded_positive_stale` 只保留为未完成/历史兼容语义。 - [x] AC-E20: **可观测性**:记录 `published_with_unseen`、`supplement_offered`、`supplement_produced`、`supplement_declined`,并用 focused API/Web/Redis/connector fixtures 覆盖原文不消失、补充不替换、失败不静默。 -- [x] AC-E21: **Queue 单一所有权与发表不变量**:普通 queued user message 不得同时进入 supplement lifecycle。猫实际读到 exact message 才记录 `(messageId, targetCat, invocationId)` ACK;成功后 handled 且不再 spawn,未读则自然下轮 spawn,失败/取消标记 failed 并回队。Steer 只允许“取消当前 invocation + 立即以同一条消息启动一次”。所有路径都不得扣押 completed original;per-target 五态必须在 F5/reconnect 后水合一致。 -- [x] AC-E22: **Queue restart-durable custody**:普通 queued user message 在 MessageStore 上持久保存 revisioned、TTL=0 custody;API restart 必须按原 `messageId / entryId / position / target` 确定性重建 Queue owner。exact seen 仅在同一 invocation 的 immutable per-target `successfulCatIds` witness 包含该猫时 handled;aggregate parent `succeeded` / target membership 不得代替逐猫证明,缺失 witness、失败、取消与 restart-crash 均回 `failed/queued`,多 target 独立恢复。不得用 `markDelivered` 把尚未执行的责任降级成仅可见 timeline 消息。Formal review 进一步证明 A2A worklist growth、provider failure + bare done 与 fail-silent writer 会破坏该 witness;`d93dda62e` 固化 immutable target domain、typed rejection、checked terminal writers 与 per-record startup isolation,聚焦与隔离 Redis 回归已绿,待 exact-head gate/re-review 与 PR/cloud/CI。 +- [x] AC-E21 **(发表不变量保留;旧 Queue receipt 部分已退役)**:普通 queued user message 不得同时进入 supplement lifecycle,completed original 不得被扣押;旧的 Queue ACK / handled / failed 五态已由 ADR-043 / F117 删除,actual dispatch 与 terminal truth 分别归 MessageStore dispatchRefs 与 exact response / structured owner。 +- [x] AC-E22 **(历史 custody 已退役)**:旧实现曾在 MessageStore `queueCustody*` 中保存 per-target Queue lifecycle 并由 restart 重建;现行 schema v2 直接持久化一条 source entry + pending `targets[]`,重启只恢复 pending Queue truth,不从 terminal witness 反推或回写 Queue lifecycle。 - [x] AC-E23: **legacy closure 全量核销迁移**:迁移以全部 active legacy closure 为根集合,逐 attached withheld invocation 分类为 `already_formal_exact / already_recovered_exact / recoverable_text / no_text / conflict`;conflict fail-closed。只有所有 invocation 均有 exact message/evidence 或审计化 no-text 归宿后,closure 才以显式 `legacy_migrated` disposition terminalize;不得伪装成用户 dismissed。恢复复用 exact transcript proof + idempotent append,零 route/Queue/Socket/A2A;重跑不复制正文或递增 revision。PR #3572(squash `d1044074a`)补齐日志轮转后的 35 个 `closure_state` provenance 缺口:51 个 active closure 均进入 root set,134 个 attachment 按 `legacy_census > runtime_log > closure_state` 归一化;exact bundle `250cc9d81938d4a3bcdaef28313d02aefe9bf2101cb08c5c4e8c038a2ba03a7f` 在 production-RDB 隔离克隆上证明 dry-run、33 条恢复、51 个 terminal transition、第二次 apply 零重复与 write-ahead journal。生产 apply 仍未执行,且必须取得绑定该 bundle SHA 的新 operator 授权。 ### Phase E-C ✅(Child Execution Truth + Typed Causal Relevance,2026-07-16 实弹重开) @@ -946,12 +955,14 @@ Map delta: none — `eval:freshness` 在既有 harness-eval、ball-custody 与 d - **Related**: F167(A2A Chain Quality)— 上游传球质量;F254 是"传球那一刻的 freshness 检查" - **Related**: F069(Thread Read State)— ThreadReadStateStore 可复用 - **Related**: F193(Message Routing)— post_message 路由守卫 -- **Related**: F086(Multi-Mention Orchestration)— per-target `TargetStatus` 语义和 per-recipient UI/routing consumption 归 F086(ADR-040) +- **Related**: F086(Multi-Mention Orchestration)— 工具/协作入口保留;其旧 per-target `TargetStatus` / Queue receipt follow-up 已被 ADR-043 / F117 Phase D 取代 - **Related**: F108(Side-Dispatch Concurrent Invocation)— 独立 fan-out context cutoff 归 F108(ADR-040) - **Related**: F117/F039 — queued delivery lifecycle 底层语义 -- **Architecture**: ADR-040(Per-Target Queued Message State Model)— 统一 read/handled/consume 四层语义 +- **Architecture**: ADR-040 仅作历史设计记录;现行 Queue / read-adoption / History lifecycle 真相源为 ADR-043 + F117 + +## Historical risk register -## Risk +> 下表包含已退役 Phase A/B/D1.2 的风险与缓解记录,不是当前 `post_message` / Queue 的运行契约。 | 风险 | 缓解 | |------|------| @@ -965,7 +976,7 @@ Map delta: none — `eval:freshness` 在既有 harness-eval、ball-custody 与 d | **内部/空消息被当成 freshness 输入**(2026-07-05 实测) | Phase A/B/D 共享 `isFreshnessRoutableMessage`:排除空 tool-only stream、route-guard 失败诊断、system display-only、context/briefing;避免将“猫正在工具调用”或内部提示误报成另一只猫/operator的新消息。Scheduler trigger 消息是 prompt-visible work,不在内部噪音过滤内 | | 跨 thread cross_post_message 的 freshness 判据不清 | 检查**目标 thread** 的 seenCursor(猫要发到的地方),不是源 thread;目标 thread 无 cursor 时 fail-open | | **排队中消息对 gate 不可见**(2026-06-29 实测发现,**已修复** PR #2664) | ~~F117 设计冲突~~ → 已通过 `QueuedMessageChecker` interface 解决:gate 在 delivered-message check 无结果或全 self-message 时 fallback 查 `InvocationQueue.list()`,三条 freshness 路径全部 wired。合成 `maxMessageId` 用 `generateSortableId(Date.now())` 确保 notice 可 resolve | -| **排队中消息可检测但 read/handled/target consumption 语义未定**(2026-07-01 实测发现) | D1.2 讨论重开:full read 应解决 queued 正文 fetch + per-cat `queued_seen`;consume-on-read / markDelivered-on-read 会破坏 delivery/read/handled 分层,需改成 seen/handled 两阶段。Durable per-target storage/execution 在 QueueEntry/QueueProcessor,F086/F108 消费语义分别处理 multi-recipient 状态和独立 fan-out cutoff | +| **排队中消息可检测但 read/handled/target consumption 语义未定**(2026-07-01 历史实测) | 当时以 `queued_seen / queued_handled` 两阶段止血;该过渡层现已由 ADR-043 / F117 退役。现行 Queue 只保存 pending `targets[]`,完整 same-target read 走 actual-dispatch adoption,History lifecycle 拥有投递与终局。 | | **operator消息 vs 猫消息优先级未区分** | 当前 gate 对所有 unseen 消息一视同仁;B3 re-invoke 已区分高优先级(人类消息 > 猫 chatter,KD-9),但 gate 本身没有。operator消息("算了不做了")的时效性高于猫间 chatter,可能需要 gate 层也引入优先级——例如operator消息即使 queued 也 hold,猫消息只 notice | ## Key Decisions @@ -1018,8 +1029,8 @@ Map delta: none — `eval:freshness` 在既有 harness-eval、ball-custody 与 d 7. **stream output 路径**:operator发消息 → 猫 invocation 启动 → operator又发一条 → 猫 stream 输出文本 → 检测到 unseen → 标记 stale + 触发 re-invoke(不只靠 B3 cursor 判断) 8. stream output 路径:所有 unseen 消息是自己发的 → 不标 stale(self-message 排除) 9. **D1.1 回归**:同一 stale set(same seenCursor/highWatermark + same senders/count)已触发过 freshness re-invoke,但猫只调用 `list_recent` / filtered `get_thread_context?catId=...` 未推进 seenCursor → 下一次 stream output 不得再次 enqueue freshness re-invoke;只能记录 stale skip / unresolved ack -10. **D1.2a queued read/seen**:猫运行期间用户消息进入 queue → 猫调用无 filter `get_thread_context?responseMode=full` → 返回 same-target queued 正文并记录 per-cat `queued_seen` → 三条 freshness 路径不再因同一 queued entry 重复 hold/notice/stale;entry 仍在 queue,且不 `markDelivered` -11. **D1.2b queued handled**:猫对已 `queued_seen` entry 的 invocation succeeded → 记录/推断 per-cat `queued_handled` → consume only that target;failed/canceled 不 consume;多目标 entry 不移除其他 target +10. **历史 D1.2a fixture(已退役)**:曾验证 full read 返回 queued 正文并记录 `queued_seen`;现行回归改由 F117 的 actual-dispatch adoption 用例覆盖 +11. **历史 D1.2b fixture(已退役)**:曾验证 `queued_seen + succeeded → queued_handled`;现行 Queue 不保存该 lifecycle 12. **A2A handoff reply**:A 输出 line-start `@B` 并存为 trigger message → B 的回复 `replyTo` 该 trigger → A 的 Phase A gate / D1 stream freshness / B1 notice 不得因此 hold 或 enqueue `Freshness -> A`;若父消息没有 mention B,则 B 的 reply 仍是普通 unseen 候选 13. **event trigger coverage**:GitHub CI/CD / Review Feedback 等 connector event 先创建消息并用该 `messageId` 唤醒目标猫 → 该猫本轮 stream output 结束时,不得再因同一 connector message enqueue `Freshness -> cat`;另一个非 trigger connector message 仍会触发 freshness 14. **routable freshness input**:另一只猫运行中产生空正文 tool-only stream,或 route-guard/system 产生内部诊断消息 → Phase A gate / B1 notice / D1 stream stale 都不得把这些消息计入 unseen;含正文或 rich block 的真实消息仍计入;`userId=scheduler` 的 hold-ball / scheduled-task trigger 因为会进入 prompt,必须计入 @@ -1067,9 +1078,9 @@ Map delta: none — `eval:freshness` 在既有 harness-eval、ball-custody 与 d | R18 | stream output freshness check | D | AC-D1 | stream 存储前 freshness 验证 | ✅ | | R19 | stale output re-invoke | D | AC-D2 | 强制 re-invoke 绕过 cursor_caught_up | ✅ | | R20 | stale audit trail | D | AC-D4 | 事件流记录 | ✅ | -| R21 | queued read + freshness suppression | D | AC-D8a | queued 正文返回 + durable per-cat `queued_seen` + no duplicate freshness nag;no consume/markDelivered on read | ✅ | -| R22 | queued handled closure | D | AC-D8b | `queued_seen + succeeded` handled v1 evidence + per-target consume; failed/canceled preserves work | ✅ | -| R23 | D1.2 eval observability | D | AC-B5/D8 | `queued_seen` + `queued_handled` OTel counters, freshness eval registry/glossary, fail-closed eval-cat instructions | ✅ | +| R21 | queued read + freshness suppression(历史) | D | AC-D8a | 过渡实现已交付后由 ADR-043 / F117 actual-dispatch adoption 退役 | ✅ historical | +| R22 | queued handled closure(历史) | D | AC-D8b | 过渡 Queue receipt 已由 exact response / structured owner terminal truth 取代 | ✅ historical | +| R23 | D1.2 eval observability(兼容 telemetry) | D | AC-B5/D8 | `queued_seen` / `queued_handled` counter 只作历史兼容与诊断,不是现行 Queue authority | ✅ compatibility | | R24 | trigger-message coverage suppression | D | AC-D10 | connector trigger message excluded from D1 stream stale; non-trigger connector messages still count | ✅ | | R25 | routable freshness input | D | AC-D11 | shared routable predicate excludes empty tool-only stream + internal diagnostics from Phase A/B/D freshness while preserving prompt-visible scheduler triggers | ✅ | | R26 | catch-closure final commit predicate | E | AC-E1/E2 | stale draft cannot commit as final | ✅ merged PR #2853 | diff --git a/docs/features/F295-cancelable-execution-projection.md b/docs/features/F295-cancelable-execution-projection.md index 79b7103db1..604acae049 100644 --- a/docs/features/F295-cancelable-execution-projection.md +++ b/docs/features/F295-cancelable-execution-projection.md @@ -56,28 +56,15 @@ Why: F295 adds the canonical live/managed-command execution read-and-cancel proj - 合法 shared/indexed thread 继续显示 foreign scheduler/system masked occupancy;session record 的 per-user filter 不适用于 occupancy,精确取消仍由 execution/principal fence 决定。 -### Post-close: Project resource convergence ✅ - -- project-wide 投影使用稳定的 `GET /api/executions/active?projectPath=...`;服务端先规范化 - project selector,再以当前用户的 durable thread index 校验 scope,不信任 caller 提供的数据边界。 -- Web 同 project 的 A→B 导航复用同一 exact-GET resource;既有 request generation 继续阻止晚到结果 - 覆盖新 anchor,4 秒 background-discovery、online、visibility、reconnect 与 exact cancel 保持不变。 -- canonical `buildActiveExecutionList` 外层按 `userId + canonical projectPath` single-flight;不同 user/project - 独立,foreign principal 仍只投影 masked occupancy。 -- F153 structured trace 记录 `candidate_enumeration`、`owner_truth`、`classification_assembly`、`total` - 与 `single_flight_join`;耗时只作运行健康证据,不进入确定性单测阈值。 - -F303 evidence: - -Architecture cell: dispatch -Map delta: none -Why: 只把既有 canonical read projection 的资源 identity 与 project-wide 数据范围对齐;不改变 lifecycle owner、writer 或 execution truth。 -- Canonical source: `packages/api/src/routes/active-execution-routes.ts#buildActiveExecutionList` -- Consumer evidence: `rg -n "useActiveExecutionProjection\\(|executions/active" packages/web packages/api/test` -- Claim guard: stable resource + late-generation guard → `useActiveExecutionProjection.test.tsx`; per-key single-flight - and user/project isolation → `f295-active-execution-projection.test.js` -- Characterization/contract test: `pnpm exec vitest run src/hooks/__tests__/useActiveExecutionProjection.test.tsx` - and API F295/F297 sparse route tests; code-derived census is the `rg` command above. +### Post-close: Stop reconciles confirmed-dead executions(2026-09-03,ADR-043 D9) + +- exact live cancel 找不到可控候选时不再 409 `EXECUTION_NOT_ACTIVE`:同一请求对该 target 就地对账 + (pre-start 退回 / running 记录置 canceled / 孤儿锁与槽位释放 / 终态广播),返回 + `{ ok, cancelled: false, reconciled }`。foreign occupancy 与更新回合替代仍是 409。 +- 进程快照不完整时不再让用户面对「不可取消」:服务端有界重试后按 dispatch terminal 将该执行终局为 failed + (reason `control_plane_unavailable`)并返回 200,失败沿 F117 Phase C 失败传播回溯上游;不做平台兼容分支(Windows 子进程不可观测同样走这条 fail 分支收敛)。`ForceResetDialog` 退役;503 只保留给持久层不可用。 +- Primary Journey 第 3 步收窄为「仅 liveness 未知时显示原因」;旧 `useExecutionRecoveryVerification` 的 + 「运行状态待确认」死角与基于 `suspected_stall` 上浮的强制重置入口退役。 ## User Journey diff --git a/docs/features/index.json b/docs/features/index.json index 9aaeaf9461..e81288801b 100644 --- a/docs/features/index.json +++ b/docs/features/index.json @@ -278,7 +278,7 @@ }, { "id": "F047", - "name": "Queue Steer(取消当前轮并以同一消息立即重启)", + "name": "Queue Steer(逐成员立即引导或中断回复)", "status": "done(2026-07-12 语义修订) | **Owner**: Maine Coon/Maine Coon(Codex)", "file": "F047-queue-steer.md" }, @@ -711,7 +711,7 @@ { "id": "F117", "name": "Message Delivery Lifecycle — 消息投递生命周期真相源", - "status": "done | **Owner**: Ragdoll + Maine Coon | **Priority**: P1", + "status": "implementing — PR #1398 已收敛单 source Queue Entry、History actual-dispatch lifecycle、carrier 能力真相、managed-hold 统一恢复与 caller runtime outbound dispatch view;exact-HEAD 本地跨族代码复审已通过,co-creator 已完成基础旅程 1–5 验收,剩余完整 worktree UAT、fork soak 与上游 owner gates | **Owner**: Ragdoll + Maine Coon | **Priority**: P1", "file": "F117-message-delivery-lifecycle.md" }, { @@ -1329,7 +1329,7 @@ { "id": "F220", "name": "A2A 协作的可观测 · 可靠 · 可恢复", - "status": "in-progress | **Owner**: 小太阳·Maine Coon (Sol/GPT-5.6-sol;2026-08-04 operator 重分配) | **Priority**: P1 | **Source**: internal", + "status": "done | **Owner**: 小太阳·Maine Coon (Sol/GPT-5.6-sol;2026-08-04 operator 重分配) | **Priority**: P1 | **Source**: internal", "file": "F220-a2a-collab-reliability.md" }, { @@ -1515,7 +1515,7 @@ { "id": "F254", "name": "Side-Effect Freshness Gate — 副作用出口 freshness 拦截", - "status": "in-progress, **D2 live app-server canary lifecycle/parity hardening + E-C complete** (**ADR-042 glass-box publish-then-supplement merged via PR #2906 (`ace5412c0`); durable Queue custody / migration / replayable `eval:freshness` merged via PR #2912 (`07f46f5aa`); D2 default-off provider-native carrier merged via PR #3004 (`680ab702f`); typed causal provenance + durable child lifecycle merged via PR #3036 (`9ae942beb`). The operator has explicitly enabled `CAT_CAFE_CODEX_CARRIER=app_server` in the live runtime canary. PR #3079 (`3b83fb43c`) fixed LF-only JSONL framing plus pump rejection isolation; PR #3082 (`7dd7a4d51`) restored provider-neutral Codex diagnostics; PR #3097 (`54aef5e74`) merged AC-D14 lifecycle parity/no-replay after Terra exact-HEAD review; PR #3285 (`65ef23d17`) merged exact capacity checkpoint continuation after gpt52 exact-HEAD review. Code default remains `exec_json`; broad rollout is still gated by AC-D15~D17 and explicit rollout authorization. 2026-08-04 normal-runtime UAT proves the live app-server path is loaded, but also proves Codex 0.146.0 protocol drift: a completed `collabAgentToolCall/wait` crossed four real boundaries without a notice because the classifier/eval census knows only command/file/MCP/dynamic. F264 owns the separate author-intent visibility and capability-aware UI close gate.**) | **Owner**: 小太阳·Maine Coon (@codex-sol, GPT-5.6 Sol) | **Priority**: P1", + "status": "partially superseded — **Phase A HELD + Phase B1 MCP piggyback + Phase B2 hold reminder + D1.2 Queue seen/handled ownership are retired by F117/#1398**; exact output-commit closure, glass-box supplements, provider-native freshness evaluation and honest carrier precision remain independently owned here. Live delivery capability follows each member's canonical `carrier`; `activeInvocationGuidance` is a separate adapter capability and must not be inferred from freshness precision. | **Owner**: 小太阳·Maine Coon (@codex-sol, GPT-5.6 Sol) | **Priority**: P1", "file": "F254-side-effect-freshness-gate.md" }, { diff --git a/packages/api/config/public-test-exclusions.json b/packages/api/config/public-test-exclusions.json index 53d1daee8b..3393fa2c79 100644 --- a/packages/api/config/public-test-exclusions.json +++ b/packages/api/config/public-test-exclusions.json @@ -18,12 +18,12 @@ "matchedFilesHash": "613776cced1400d311fd529f4b000a0c76c0306cd7e55fc9061b4115d8435a57" }, "publicAudit": { - "reviewedOn": "2026-09-05", - "sourceHead": "c5633cf643447e040af125d361d2156e38fdb081", - "publicHead": "f3dba7a1abb9474a88764f21283297cbe9935e2b", + "reviewedOn": "2026-09-18", + "sourceHead": "2cab8ae174f4c6bf72bbe978617e56fc43290b8b", + "publicHead": "94d9d7784ea58d4f21c42204741f545844c835fc", "status": "resource_contract", - "matchedFileCount": 64, - "matchedFilesHash": "5e21c0b349928349fbe2de8cb929538c801b354db1f1eedb07420658fbd508c6" + "matchedFileCount": 66, + "matchedFilesHash": "d2015b8bcb0fb4568ada3e1e2d7bde20debb50a7d15b79833351eacd50a7a31e" } }, { diff --git a/packages/api/package.json b/packages/api/package.json index 6cffe36908..7d9170a48f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -55,6 +55,7 @@ "test:artifact-review": "CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash ./scripts/with-test-home.sh node --import tsx --test --test-concurrency=1 --test-timeout=60000 test/artifact-review-*.test.ts test/content-owner-media-history.test.ts test/published-media-*.test.ts test/persisted-queue-delivery.test.ts" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.267", "@cat-cafe/collective-connector": "workspace:*", "@cat-cafe/collective-service": "workspace:*", "@cat-cafe/shared": "workspace:*", @@ -67,7 +68,7 @@ "@fastify/static": "7", "@fastify/websocket": "^10.0.0", "@larksuiteoapi/node-sdk": "^1.59.0", - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.214.0", "@opentelemetry/core": "^2.6.1", diff --git a/packages/api/src/config/cat-config-loader.ts b/packages/api/src/config/cat-config-loader.ts index 55c21ad9cc..510af1f579 100644 --- a/packages/api/src/config/cat-config-loader.ts +++ b/packages/api/src/config/cat-config-loader.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'; import type { CatBreed, CatCafeConfig, + CatCarrier, CatConfig, CatFeatures, CatId, @@ -19,7 +20,14 @@ import type { ReviewPolicy, Roster, } from '@cat-cafe/shared'; -import { type ClientId, catRegistry, createCatId, normalizeCliEffortForProvider } from '@cat-cafe/shared'; +import { + CAT_CARRIERS, + type ClientId, + catClientSupportsCarrier, + catRegistry, + createCatId, + normalizeCliEffortForProvider, +} from '@cat-cafe/shared'; import { z } from 'zod'; import { createModuleLogger } from '../infrastructure/logger.js'; import { bootstrapCatCatalog, type CatCatalogReadOptions, readCatCatalogRaw } from './cat-catalog-store.js'; @@ -64,7 +72,7 @@ const cliConfigSchema = z.object({ /** Read-only migration input. Invalid legacy values are inert, not catalog-fatal. */ contextWindow: legacyPositiveContextWindowSchema, // Legacy autoCompactTokenLimit is intentionally absent: Zod strips it as inert input. - /** F254 D2: Codex carrier override (openai only). Absent = follow CAT_CAFE_CODEX_CARRIER env. */ + /** Legacy F254 read input. Canonical writes use variant.carrier. */ carrier: z.enum(['exec_json', 'app_server']).optional(), }); @@ -83,6 +91,7 @@ const agyProfileSchema = z const mentionPatternSchema = z.string().min(2).regex(/^@/, 'mentionPattern must start with @'); const colorSchema = z.object({ primary: z.string(), secondary: z.string() }); +const catCarrierSchema = z.enum(CAT_CARRIERS); const timeZoneSchema = z .string() @@ -102,6 +111,11 @@ const catVariantSchema = z source: z.string().optional(), // #441: legacy field, ignored — kept in schema for old catalog read compat accountRef: z.string().min(1).optional(), // F127: concrete account binding clientId: z.string().min(1), // #252: accept unknown providers to avoid full config crash + carrier: catCarrierSchema.optional(), + /** Legacy member access-mode field. Read only; canonical writes use carrier. */ + transport: catCarrierSchema.optional(), + /** ACP launch details remain config data; carrier decides whether they are active. */ + acp: z.unknown().optional(), defaultModel: z.string(), // OAuth/subscription CLIs have built-in defaults; api_key validated at route level mcpSupport: z.boolean(), @@ -142,18 +156,46 @@ const catVariantSchema = z restrictions: z.array(z.string().min(1)).optional(), // F167 Phase E: hard task bans }) .superRefine((variant, ctx) => { - // F254 D2: cli.carrier is a Codex-only override. The cats API rejects it for - // non-openai clients at write time; this is the read-time counterpart so a - // hand-edited catalog cannot smuggle the field onto another provider. - if (variant.cli?.carrier !== undefined && variant.clientId !== 'openai') { + const carrier = resolveCatCarrier(variant); + if (!catClientSupportsCarrier(variant.clientId, carrier)) { ctx.addIssue({ code: 'custom', - path: ['cli', 'carrier'], - message: `cli.carrier is codex-only, but variant "${variant.id}" has clientId "${variant.clientId}"`, + path: ['carrier'], + message: `carrier "${carrier}" is not supported by clientId "${variant.clientId}"`, }); } }); +type CarrierCompatInput = { + carrier?: CatCarrier; + transport?: CatCarrier; + clientId?: string; + cli?: { carrier?: 'exec_json' | 'app_server' }; + acp?: unknown; +}; + +/** + * The only compatibility boundary for member access-mode configuration. + * Canonical carrier wins, followed by the legacy top-level transport field. + * When neither exists, migrate the two historical runtime selectors: an ACP + * launch block selected ACP before provider routing, while OpenAI's CLI field + * (then the retired process env selector) selected app-server vs exec-json. + * Every downstream projection receives only the canonical vocabulary. + */ +export function resolveCatCarrier( + input: CarrierCompatInput, + codexCarrierEnv = process.env.CAT_CAFE_CODEX_CARRIER, +): CatCarrier { + if (input.carrier) return input.carrier; + if (input.transport) return input.transport; + if (input.clientId && input.acp != null) return 'acp'; + if (input.clientId === 'openai') { + const legacy = input.cli?.carrier ?? codexCarrierEnv; + if (legacy === 'app_server') return 'app_server'; + } + return 'cli'; +} + /** F33 Phase 2: session strategy config (matches SessionStrategyConfig from shared). * Exported for reuse by Phase 3 API route validation. */ export const sessionStrategySchema = z @@ -279,8 +321,10 @@ const catCafeConfigSchemaV2 = z return rest; }); -/** Union of all versions — loader handles migration */ -const catCafeConfigSchema = z.union([catCafeConfigSchemaV1, catCafeConfigSchemaV2]); +/** Parse the discriminator before the full config so branch-specific errors retain their exact paths. */ +const catCafeConfigVersionSchema = z.object({ + version: z.union([z.literal(1), z.literal(2)]), +}); /** clowder-ai#340: Read cat-template.json directly — cat-config.json is no longer a runtime source. */ function readTemplate(templatePath: string): string { @@ -507,7 +551,14 @@ function mergeTemplateWithCatalog(templatePath: string, options: CatCatalogReadO function parseCatConfig(raw: string): CatCafeConfig { const json: unknown = JSON.parse(raw); - const result = catCafeConfigSchema.safeParse(json); + const versionResult = catCafeConfigVersionSchema.safeParse(json); + if (!versionResult.success) { + const issues = versionResult.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`); + throw new Error(`Invalid cat config:\n${issues.join('\n')}`); + } + + const result = + versionResult.data.version === 1 ? catCafeConfigSchemaV1.safeParse(json) : catCafeConfigSchemaV2.safeParse(json); if (!result.success) { const issues = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`); throw new Error(`Invalid cat config:\n${issues.join('\n')}`); @@ -640,6 +691,7 @@ export function toAllCatConfigs(config: CatCafeConfig): Record CAT_CAFE_CODEX_CARRIER env > default - * (exec_json). Used by the production agent-service assembly (index.ts) and by - * GET /api/cats so the Hub displays the same truth the runtime executes. - */ -export function resolveCodexCarrierTruth( - perCatCarrier: CodexCarrierMode | undefined, - env: NodeJS.ProcessEnv = process.env, -): CodexCarrierTruth { - if (perCatCarrier !== undefined) return { effective: perCatCarrier, source: 'per-cat' }; - const raw = env.CAT_CAFE_CODEX_CARRIER?.trim(); - if (raw) return { effective: raw === 'app_server' ? 'app_server' : 'exec_json', source: 'env' }; - return { effective: 'exec_json', source: 'default' }; -} - /** * Prefer Codex's built-in OpenAI provider so upstream can select its current * transport behavior. `https` remains an operational escape hatch for diff --git a/packages/api/src/config/env-registry.ts b/packages/api/src/config/env-registry.ts index 15e9099724..4f71b32582 100644 --- a/packages/api/src/config/env-registry.ts +++ b/packages/api/src/config/env-registry.ts @@ -694,7 +694,7 @@ export const ENV_VARS: EnvDefinition[] = [ name: 'CAT_CAFE_RUNTIME_ROOT', defaultValue: '(未设置 → process.cwd())', description: - 'F061: Clowder AI runtime 二进制根目录(runtime startup 自动 export 为 $RUNTIME_DIR),优先级高于 capability orchestrator 的 auto-detection,用于 Antigravity MCP config args 路径', + 'F061: Cat Cafe runtime 二进制根目录(runtime startup 自动 export 为 $RUNTIME_DIR),优先级高于 capability orchestrator 的 auto-detection,用于 Antigravity MCP config args 路径', category: 'server', sensitive: false, runtimeEditable: false, @@ -1394,13 +1394,13 @@ export const ENV_VARS: EnvDefinition[] = [ }, { name: 'CAT_CAFE_CODEX_CARRIER', - defaultValue: 'exec_json', + defaultValue: 'cli', description: - 'F254 Codex 双向 carrier(exec_json 默认;app_server 仅用于显式 canary)。支持 per-cat 覆盖:Hub 成员编辑器「接入方式(Carrier)」写入 cli.carrier,优先级高于本 env', + '旧版 Codex carrier 兼容选择器;仅在成员配置未声明 canonical carrier/transport/cli.carrier 时读取,新配置应使用成员 carrier 字段', category: 'codex', sensitive: false, runtimeEditable: false, - allowedValues: ['exec_json', 'app_server'], + allowedValues: ['cli', 'exec_json', 'app_server'], }, { name: 'CAT_CAFE_CODEX_OAUTH_TRANSPORT', @@ -1586,7 +1586,7 @@ export const ENV_VARS: EnvDefinition[] = [ { name: 'COLLECTIVE_SERVICE_PORT', defaultValue: '5201', - description: '独立 Collective Service 端口;不得使用 Clowder AI runtime 3003/3004', + description: '独立 Collective Service 端口;不得使用 Cat Cafe runtime 3003/3004', category: 'connector', sensitive: false, runtimeEditable: false, @@ -1604,7 +1604,7 @@ export const ENV_VARS: EnvDefinition[] = [ { name: 'COLLECTIVE_SERVICE_ALLOWED_HOST_ORIGINS', defaultValue: '(空)', - description: '允许嵌入并配对的 Clowder AI Web origin,多个用逗号分隔', + description: '允许嵌入并配对的 Cat Cafe Web origin,多个用逗号分隔', category: 'connector', sensitive: false, runtimeEditable: false, @@ -1650,7 +1650,7 @@ export const ENV_VARS: EnvDefinition[] = [ { name: 'NEXT_PUBLIC_COLLECTIVE_SERVICE_URL', defaultValue: 'http://localhost:5201', - description: 'Clowder AI「进入 Collective」默认打开的独立 Service 地址', + description: 'Cat Cafe「进入 Collective」默认打开的独立 Service 地址', category: 'frontend', sensitive: false, runtimeEditable: false, diff --git a/packages/api/src/config/runtime-cat-catalog.ts b/packages/api/src/config/runtime-cat-catalog.ts index 978638c74e..a3b244930c 100644 --- a/packages/api/src/config/runtime-cat-catalog.ts +++ b/packages/api/src/config/runtime-cat-catalog.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'; import type { CatBreed, CatCafeConfig, + CatCarrier, CatColor, CatVariant, CliConfig, @@ -37,6 +38,7 @@ export interface RuntimeCatInput { strengths?: string[]; sessionChain?: boolean; clientId: ClientId; + carrier: CatCarrier; defaultModel: string; mcpSupport: boolean; /** F247 KD-17: cloud-only cats (Remote MCP) omit cli to skip local dispatch. @@ -69,6 +71,7 @@ export interface RuntimeCatUpdate { strengths?: string[]; sessionChain?: boolean; clientId?: ClientId; + carrier?: CatCarrier; defaultModel?: string; mcpSupport?: boolean; /** F247 KD-17: cli null to remove (cloud-only mode), CliConfig to update, undefined to skip. */ @@ -263,6 +266,7 @@ function createBreedFromInput(input: RuntimeCatInput): CatBreed { { id: variantId, clientId: input.clientId, + carrier: input.carrier, ...(input.variantLabel != null && input.variantLabel.trim().length > 0 ? { variantLabel: input.variantLabel.trim() } : {}), @@ -473,6 +477,13 @@ export function updateRuntimeCat(projectRoot: string, catId: string, patch: Runt } } if (patch.clientId !== undefined) variant.clientId = patch.clientId; + if (patch.carrier !== undefined) { + variant.carrier = patch.carrier; + // Access-mode compatibility exists only at read time. Any write converges + // the catalog to the canonical top-level field. + delete variant.transport; + if (variant.cli && typeof variant.cli === 'object') delete variant.cli.carrier; + } if (patch.defaultModel !== undefined) variant.defaultModel = patch.defaultModel; if (patch.mcpSupport !== undefined) variant.mcpSupport = patch.mcpSupport; // F247 KD-17: patch.cli === null means remove (cloud-only mode); object means update. diff --git a/packages/api/src/config/user-preferences-store.ts b/packages/api/src/config/user-preferences-store.ts index c916e51b8f..555bb85279 100644 --- a/packages/api/src/config/user-preferences-store.ts +++ b/packages/api/src/config/user-preferences-store.ts @@ -66,7 +66,6 @@ function sanitizeDispositionPreferences(value: unknown): MessageDispositionPrefe return { ...(isDisposition(candidate.global) ? { global: candidate.global } : {}), ...(Object.keys(threads).length > 0 ? { threads } : {}), - ...(candidate.onboardingSeen === true ? { onboardingSeen: true } : {}), }; } @@ -84,7 +83,6 @@ export function resolveMessageDispositionPreference( thread, effective: thread, source: 'thread', - onboardingSeen: preference.onboardingSeen === true, }; } if (global) { @@ -94,7 +92,6 @@ export function resolveMessageDispositionPreference( thread: null, effective: global, source: 'global', - onboardingSeen: preference.onboardingSeen === true, }; } return { @@ -103,7 +100,6 @@ export function resolveMessageDispositionPreference( thread: null, effective: MESSAGE_DISPOSITION_PRODUCT_DEFAULT, source: 'product', - onboardingSeen: preference.onboardingSeen === true, }; } @@ -111,14 +107,10 @@ export function saveMessageDispositionPreference( projectRoot: string, input: | { scope: 'global'; disposition: MessageWorkDisposition | null } - | { scope: 'thread'; threadId: string; disposition: MessageWorkDisposition | null } - | { scope: 'onboarding'; seen: true }, + | { scope: 'thread'; threadId: string; disposition: MessageWorkDisposition | null }, ): MessageDispositionPreferenceSnapshot { updateUserPreferences(projectRoot, (current) => { const existing = sanitizeDispositionPreferences(current.messageDisposition); - if (input.scope === 'onboarding') { - return { ...current, messageDisposition: { ...existing, onboardingSeen: true } }; - } if (input.scope === 'global') { const next = { ...existing }; if (input.disposition) next.global = input.disposition; diff --git a/packages/api/src/domains/approval-hub/ApprovalIngress.ts b/packages/api/src/domains/approval-hub/ApprovalIngress.ts index 5e1737cff7..5bff5b7050 100644 --- a/packages/api/src/domains/approval-hub/ApprovalIngress.ts +++ b/packages/api/src/domains/approval-hub/ApprovalIngress.ts @@ -117,8 +117,8 @@ export class ApprovalIngress { if (!stored) { try { stored = await this.deps.messageStore.append({ + from: { kind: 'agent', catId: draft.requesterCatId }, userId: draft.ownerUserId, - catId: draft.requesterCatId, content: draft.cardContent, mentions: [], timestamp: draft.createdAt, diff --git a/packages/api/src/domains/auto-dream/ProactiveRelationshipService.ts b/packages/api/src/domains/auto-dream/ProactiveRelationshipService.ts index 9140d5ac67..5f6fd5d406 100644 --- a/packages/api/src/domains/auto-dream/ProactiveRelationshipService.ts +++ b/packages/api/src/domains/auto-dream/ProactiveRelationshipService.ts @@ -1,4 +1,5 @@ import type { CatId } from '@cat-cafe/shared'; +import { messageFrom } from '../cats/services/stores/message-from.js'; import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; import type { AutoDreamStore } from './AutoDreamStore.js'; import { ownedSeedSourceRevision } from './private-seed-contract.js'; @@ -155,7 +156,9 @@ export class ProactiveRelationshipService { let reconciled = 0; for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; - if (!message || message.userId !== ownerUserId || message.catId !== null) continue; + if (!message || message.userId !== ownerUserId || messageFrom(message).kind !== 'user') { + continue; + } const existing = await this.options.store.proactive.findNaturalEchoBySource( ownerUserId, config.bedroomThreadId, @@ -219,9 +222,9 @@ function buildCanonicalMessage( timestamp: number, ) { return { + from: { kind: 'agent' as const, catId: visit.catId as CatId }, threadId: visit.homeThreadId, userId: visit.ownerUserId, - catId: visit.catId as CatId, content, mentions: [] as CatId[], origin: 'callback' as const, diff --git a/packages/api/src/domains/ball-custody/A2ADispatchDispositionService.ts b/packages/api/src/domains/ball-custody/A2ADispatchDispositionService.ts deleted file mode 100644 index 3fca93f4d7..0000000000 --- a/packages/api/src/domains/ball-custody/A2ADispatchDispositionService.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { type BallCustodyEvent, isCrossThreadProvenance } from '@cat-cafe/shared'; -import type { InvocationRecord } from '../cats/services/agents/invocation/InvocationRegistry.js'; -import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; -import { - type A2ADispatchHandoffInspection, - type A2ADispatchHandoffSource, - type A2ADispatchReplacement, - resolveA2ADispatchHandoff, -} from './A2ADispatchReplacementResolver.js'; -import type { IBallCustodyEventLog } from './BallCustodyEventLog.js'; -import type { IBallCustodyFencedIngest } from './BallCustodyIngest.js'; -import type { IBallCustodyProjectionStore } from './BallCustodyProjectionStore.js'; -import { - type A2ADispatchDisposition, - buildDispatchDispositionEvent, - dispatchDispositionEventSourceId, - handedEventSourceId, -} from './ball-custody-events.js'; - -export interface A2ADispatchDispositionResult { - readonly outcome: 'applied' | 'replayed'; - readonly disposition: A2ADispatchDisposition; - readonly invocationId: string; - readonly sourceMessageId: string; - readonly fromCatId: string; - /** The exact dispatch ended without advancing an unrelated thread-level holder. */ - readonly retired: boolean; -} - -export type { A2ADispatchHandoffInspection, A2ADispatchReplacement } from './A2ADispatchReplacementResolver.js'; - -export class A2ADispatchDispositionError extends Error { - constructor( - readonly code: string, - readonly replacement?: A2ADispatchReplacement, - ) { - super(code); - this.name = 'A2ADispatchDispositionError'; - } -} - -interface A2ADispatchDispositionDeps { - readonly registry: { isLatest(invocationId: string): Promise }; - readonly messageStore: Pick; - readonly ballCustodyEventLog: Pick; - readonly ballCustodyProjectionStore: Pick; - readonly ballCustody: IBallCustodyFencedIngest; - readonly log?: { warn(obj: unknown, msg?: string): void }; - readonly repairProjection?: (subjectKey: string) => Promise; - readonly now?: () => number; -} - -type A2ADispatchDispositionAuth = Pick< - InvocationRecord, - 'invocationId' | 'catId' | 'threadId' | 'a2aTriggerMessageId' | 'originTriggerMessageId' ->; - -type DispatchSource = A2ADispatchHandoffSource; - -/** Invocation-bound terminal producer for one exact ordinary A2A dispatch. */ -export class A2ADispatchDispositionService { - private readonly now: () => number; - - constructor(private readonly deps: A2ADispatchDispositionDeps) { - this.now = deps.now ?? Date.now; - } - - async complete( - auth: A2ADispatchDispositionAuth, - disposition: A2ADispatchDisposition, - ): Promise { - try { - return await this.completeLatestInvocation(auth, disposition); - } catch (error) { - if (!(error instanceof A2ADispatchDispositionError) || error.code !== 'a2a_dispatch_disposition_fence_conflict') { - throw error; - } - // Heartbeats share the subject log. Retry once from fresh authority and - // lineage, never by appending the previously inspected disposition. - return this.completeLatestInvocation(auth, disposition); - } - } - - private async completeLatestInvocation( - auth: A2ADispatchDispositionAuth, - disposition: A2ADispatchDisposition, - ): Promise { - await this.assertLatestInvocation(auth.invocationId); - const source = await this.resolveSource(auth); - return this.completeResolved(auth, source, disposition); - } - - /** - * A consumed coordination terminal is the exact completion witness for the - * ordinary dispatch that invoked its author. It shares the existing - * disposition event/fence; no second terminal lifecycle is created. - */ - async completeFromCoordinationTerminal(terminalMessageId: string): Promise { - const terminal = await this.deps.messageStore.getById(terminalMessageId); - const coordination = terminal?.extra?.coordination; - const provenance = terminal?.extra?.crossPost; - const causal = terminal?.extra?.causal; - const turnInvocationId = terminal?.extra?.stream?.turnInvocationId; - if ( - !terminal || - !terminal.catId || - coordination?.phase !== 'terminal' || - !provenance?.sourceThreadId || - !provenance.sourceInvocationId || - !causal?.triggerMessageId || - !turnInvocationId - ) { - throw new A2ADispatchDispositionError('a2a_dispatch_coordination_terminal_identity_missing'); - } - - const auth: A2ADispatchDispositionAuth = { - invocationId: provenance.sourceInvocationId, - catId: terminal.catId, - threadId: provenance.sourceThreadId, - a2aTriggerMessageId: causal.triggerMessageId, - originTriggerMessageId: causal.triggerMessageId, - }; - const sourceMessage = await this.deps.messageStore.getById(causal.triggerMessageId); - const sourceCoordination = sourceMessage?.extra?.coordination; - const sourceOriginThreadId = sourceMessage?.extra?.crossPost?.sourceThreadId; - const terminalTargetsSource = Boolean( - sourceMessage?.catId && this.messageTargetsCat(terminal, sourceMessage.catId), - ); - if ( - !sourceMessage || - sourceCoordination?.phase !== 'active' || - coordination.id !== sourceCoordination.id || - coordination.hop !== sourceCoordination.hop + 1 || - !coordination.subjectRef || - !sourceCoordination.subjectRef || - coordination.subjectRef !== sourceCoordination.subjectRef || - sourceOriginThreadId !== terminal.threadId || - terminal.threadId === provenance.sourceThreadId || - terminal.userId !== sourceMessage.userId || - turnInvocationId !== provenance.sourceInvocationId || - !terminalTargetsSource - ) { - throw new A2ADispatchDispositionError('a2a_dispatch_coordination_terminal_mismatch'); - } - - const source = await this.resolveSource(auth); - return this.completeResolved(auth, source, 'completed'); - } - - private async completeResolved( - auth: A2ADispatchDispositionAuth, - source: DispatchSource, - disposition: A2ADispatchDisposition, - ): Promise { - const subjectKey = `ball:thread:${auth.threadId}`; - const events = await this.deps.ballCustodyEventLog.read(subjectKey); - const eventSourceId = dispatchDispositionEventSourceId({ - invocationId: auth.invocationId, - sourceMessageId: source.sourceMessageId, - }); - const prior = events.find((event) => event.sourceEventId === eventSourceId); - if (prior) { - this.assertMatchingDispositionEvent(prior, auth, source, disposition); - await this.repairProjectionIfNeeded(subjectKey, events, prior); - return { - outcome: 'replayed', - disposition, - invocationId: auth.invocationId, - sourceMessageId: source.sourceMessageId, - fromCatId: source.fromCatId, - retired: prior.payload.retired === true, - }; - } - - const inspection = await this.inspectResolvedHandoff(auth.threadId, auth.catId, source, events); - if (inspection.outcome === 'replaced') { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_replaced', inspection.replacement); - } - const retired = await this.resolveRetired(subjectKey, auth.catId, source.handoffSourceEventId, events); - await this.recordDisposition(auth, source, disposition, subjectKey, eventSourceId, events.length, retired); - const committed = (await this.deps.ballCustodyEventLog.read(subjectKey)).find( - (event) => event.sourceEventId === eventSourceId, - ); - this.assertMatchingDispositionEvent(committed, auth, source, disposition); - return { - outcome: 'applied', - disposition, - invocationId: auth.invocationId, - sourceMessageId: source.sourceMessageId, - fromCatId: source.fromCatId, - retired, - }; - } - - /** Queue-start and callback completion share this exact source/event fence. */ - async inspectHandoff(input: { - readonly threadId: string; - readonly catId: string; - readonly sourceMessageId: string; - }): Promise { - const source = await this.resolveSourceCoordinates(input); - const events = await this.deps.ballCustodyEventLog.read(`ball:thread:${input.threadId}`); - return this.inspectResolvedHandoff(input.threadId, input.catId, source, events); - } - - private async assertLatestInvocation(invocationId: string): Promise { - if (!(await this.deps.registry.isLatest(invocationId))) { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_stale_invocation'); - } - } - - private async resolveSource(auth: A2ADispatchDispositionAuth): Promise { - const sourceMessageId = auth.a2aTriggerMessageId; - if (!sourceMessageId || auth.originTriggerMessageId !== sourceMessageId) { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_source_missing'); - } - return this.resolveSourceCoordinates({ threadId: auth.threadId, catId: auth.catId, sourceMessageId }); - } - - private async resolveSourceCoordinates(input: { - readonly threadId: string; - readonly catId: string; - readonly sourceMessageId: string; - }): Promise { - const message = await this.deps.messageStore.getById(input.sourceMessageId); - if (!this.isExactA2ASource(message, input)) { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_source_mismatch'); - } - const fromCatId = message.catId; - return { - sourceMessageId: input.sourceMessageId, - fromCatId, - handoffSourceEventId: handedEventSourceId(input.sourceMessageId, input.catId), - }; - } - - private isExactA2ASource( - message: StoredMessage | null, - auth: { readonly threadId: string; readonly catId: string }, - ): message is StoredMessage & { catId: NonNullable } { - return Boolean( - message && - message.threadId === auth.threadId && - message.catId && - (message.catId !== auth.catId || - isCrossThreadProvenance(message.extra?.crossPost?.sourceThreadId, message.threadId)) && - this.messageTargetsCat(message, auth.catId), - ); - } - - private messageTargetsCat(message: StoredMessage, catId: string): boolean { - if (message.mentions.some((candidate) => candidate === catId)) return true; - return Boolean(message.extra?.targetCats?.includes(catId)); - } - - private async resolveRetired( - subjectKey: string, - catId: string, - handoffSourceEventId: string, - events: readonly BallCustodyEvent[], - ): Promise { - let projection = await this.deps.ballCustodyProjectionStore.get(subjectKey); - if (!projection && this.deps.repairProjection) { - await this.deps.repairProjection(subjectKey); - projection = await this.deps.ballCustodyProjectionStore.get(subjectKey); - } - if (!projection) { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_holder_mismatch'); - } - const handoffIndex = events.findIndex((event) => event.sourceEventId === handoffSourceEventId); - const acquiredAfterDispatch = events - .slice(handoffIndex + 1) - .some((event) => event.kind === 'ball.handed' || event.kind === 'ball.held'); - return ( - acquiredAfterDispatch || - (projection.state !== 'active' && projection.state !== 'blocked') || - projection.holder !== catId - ); - } - - private async inspectResolvedHandoff( - threadId: string, - catId: string, - source: DispatchSource, - events: readonly BallCustodyEvent[], - ): Promise { - const inspection = await resolveA2ADispatchHandoff({ - threadId, - catId, - source, - events, - messageStore: this.deps.messageStore, - ...(this.deps.log ? { log: this.deps.log } : {}), - }); - if (inspection.outcome === 'missing') { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_handoff_missing'); - } - return inspection; - } - - private assertMatchingDispositionEvent( - event: BallCustodyEvent | undefined, - auth: A2ADispatchDispositionAuth, - source: DispatchSource, - disposition: A2ADispatchDisposition, - ): void { - if ( - !event || - event.kind !== 'ball.dispatch_dispositioned' || - event.payload.catId !== auth.catId || - event.payload.fromCatId !== source.fromCatId || - event.payload.invocationId !== auth.invocationId || - event.payload.sourceMessageId !== source.sourceMessageId || - event.payload.disposition !== disposition - ) { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_replay_mismatch'); - } - } - - private async recordDisposition( - auth: A2ADispatchDispositionAuth, - source: DispatchSource, - disposition: A2ADispatchDisposition, - subjectKey: string, - eventSourceId: string, - expectedSequence: number, - retired: boolean, - ): Promise { - let conflictSequence: number | undefined; - try { - const result = await this.deps.ballCustody.recordFenced( - buildDispatchDispositionEvent({ - threadId: auth.threadId, - catId: auth.catId, - fromCatId: source.fromCatId, - invocationId: auth.invocationId, - sourceMessageId: source.sourceMessageId, - disposition, - retired, - at: this.now(), - }), - expectedSequence, - ); - if (result.outcome === 'conflict') { - conflictSequence = result.actualSequence; - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_fence_conflict'); - } - } catch (error) { - const events = await this.deps.ballCustodyEventLog.read(subjectKey); - if (conflictSequence !== undefined) { - this.deps.log?.warn( - { - threadId: auth.threadId, - invocationId: auth.invocationId, - sourceMessageId: source.sourceMessageId, - expectedSequence, - actualSequence: conflictSequence, - interveningEvents: events - .slice(expectedSequence, Math.min(conflictSequence, expectedSequence + 8)) - .map(({ sourceEventId, kind, at }) => ({ sourceEventId, kind, at })), - omittedEventCount: Math.max(0, conflictSequence - expectedSequence - 8), - }, - '[F167] A2A dispatch disposition CAS conflict', - ); - } - const appended = events.find((event) => event.sourceEventId === eventSourceId); - if (!appended || !this.deps.repairProjection) throw error; - await this.deps.repairProjection(subjectKey); - } - } - - private async repairProjectionIfNeeded( - subjectKey: string, - events: readonly BallCustodyEvent[], - dispositionEvent: BallCustodyEvent, - ): Promise { - if (!this.deps.repairProjection) return; - const dispositionIndex = events.findIndex((event) => event.sourceEventId === dispositionEvent.sourceEventId); - const reopenedAfterDisposition = events - .slice(dispositionIndex + 1) - .some((event) => event.kind === 'ball.handed' || event.kind === 'ball.held'); - const projection = await this.deps.ballCustodyProjectionStore.get(subjectKey); - // An inert retirement never promises a resolved thread. Rebuilding a healthy - // active/parked projection on every recovery pass would rewrite unrelated work. - const rejectedDisposition = projection?.lastRejectedEvent?.sourceEventId === dispositionEvent.sourceEventId; - if ( - !projection || - rejectedDisposition || - (dispositionEvent.payload.retired !== true && !reopenedAfterDisposition && projection.state !== 'resolved') - ) { - await this.deps.repairProjection(subjectKey); - } - } -} diff --git a/packages/api/src/domains/ball-custody/A2ADispatchReplacementResolver.ts b/packages/api/src/domains/ball-custody/A2ADispatchReplacementResolver.ts deleted file mode 100644 index c1eeede37b..0000000000 --- a/packages/api/src/domains/ball-custody/A2ADispatchReplacementResolver.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { BallCustodyEvent, CrossThreadCoordination } from '@cat-cafe/shared'; -import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; - -export interface A2ADispatchHandoffSource { - readonly sourceMessageId: string; - readonly fromCatId: string; - readonly handoffSourceEventId: string; -} - -export type A2ADispatchReplacement = - | { - readonly kind: 'handed'; - readonly sourceEventId: string; - readonly sourceMessageId: string; - readonly fromCatId?: string; - readonly toCatId: string; - readonly coordination?: CrossThreadCoordination; - } - | { - readonly kind: 'handed_cvo'; - readonly sourceEventId: string; - readonly sourceMessageId: string; - readonly fromCatId: string; - readonly intent: 'handoff' | 'done_notify' | 'fyi'; - readonly coordination?: CrossThreadCoordination; - }; - -export type A2ADispatchHandoffInspection = A2ADispatchHandoffSource & - ({ readonly outcome: 'live' } | { readonly outcome: 'replaced'; readonly replacement: A2ADispatchReplacement }); - -type HandedReplacementEvent = Omit & { - readonly kind: 'ball.handed'; - readonly payload: { readonly fromCatId?: string; readonly toCatId: string }; -}; - -type HandedCvoReplacementEvent = Omit & { - readonly kind: 'ball.handed_cvo'; - readonly payload: { - readonly fromCatId: string; - readonly intent: 'handoff' | 'done_notify' | 'fyi'; - }; -}; - -type ReplacementEvent = HandedReplacementEvent | HandedCvoReplacementEvent; - -export async function resolveA2ADispatchHandoff(input: { - readonly threadId: string; - readonly catId: string; - readonly source: A2ADispatchHandoffSource; - readonly events: readonly BallCustodyEvent[]; - readonly messageStore: Pick; - readonly log?: { warn(obj: unknown, msg?: string): void }; -}): Promise { - const exactHandoffIndex = input.events.findIndex( - (event) => - event.kind === 'ball.handed' && - event.sourceEventId === input.source.handoffSourceEventId && - event.payload.fromCatId === input.source.fromCatId && - event.payload.toCatId === input.catId, - ); - if (exactHandoffIndex === -1) return { outcome: 'missing' }; - - const lineageMessageIds = new Set([input.source.sourceMessageId]); - const lineageCoordinations: CrossThreadCoordination[] = []; - await seedSourceCoordination(input, lineageCoordinations); - - // Walk forward so a later successor may prove a transitive chain through an - // earlier verified successor. Cat participation alone is never lineage. - let replacement: A2ADispatchReplacement | undefined; - for (let index = exactHandoffIndex + 1; index < input.events.length; index += 1) { - const candidate = input.events[index]; - if (!candidate || !isReplacementEvent(candidate, input.catId)) continue; - const candidateMessageId = replacementSourceMessageId(candidate); - if (!candidateMessageId) continue; - const candidateMessage = await readLineageMessage( - input.messageStore, - candidateMessageId, - input.threadId, - input.log, - ); - if (!isVerifiedReplacementMessage(candidateMessage, input.threadId, candidate)) continue; - if (!hasCausalLineage(candidateMessage, lineageMessageIds, lineageCoordinations)) continue; - - replacement = describeReplacement(candidate, candidateMessage); - lineageMessageIds.add(candidateMessage.id); - if (candidateMessage.extra?.coordination) lineageCoordinations.push(candidateMessage.extra.coordination); - } - if (!replacement) return { outcome: 'live', ...input.source }; - return { outcome: 'replaced', ...input.source, replacement }; -} - -async function seedSourceCoordination( - input: { - readonly threadId: string; - readonly source: A2ADispatchHandoffSource; - readonly messageStore: Pick; - readonly log?: { warn(obj: unknown, msg?: string): void }; - }, - lineageCoordinations: CrossThreadCoordination[], -): Promise { - const sourceMessage = await readLineageMessage( - input.messageStore, - input.source.sourceMessageId, - input.threadId, - input.log, - ); - if (sourceMessage?.threadId === input.threadId && sourceMessage.extra?.coordination) { - lineageCoordinations.push(sourceMessage.extra.coordination); - } -} - -function isReplacementEvent(event: BallCustodyEvent, catId: string): event is ReplacementEvent { - if (event.kind === 'ball.handed') { - const fromCatId = event.payload.fromCatId; - const toCatId = event.payload.toCatId; - return ( - (fromCatId === undefined || typeof fromCatId === 'string') && - typeof toCatId === 'string' && - (fromCatId === catId || toCatId === catId) - ); - } - return ( - event.kind === 'ball.handed_cvo' && - typeof event.payload.fromCatId === 'string' && - event.payload.fromCatId === catId && - (event.payload.intent === 'handoff' || event.payload.intent === 'done_notify' || event.payload.intent === 'fyi') - ); -} - -function describeReplacement(event: ReplacementEvent, message: StoredMessage): A2ADispatchReplacement { - if (event.kind === 'ball.handed') { - return { - kind: 'handed', - sourceEventId: event.sourceEventId, - sourceMessageId: message.id, - ...(event.payload.fromCatId ? { fromCatId: event.payload.fromCatId } : {}), - toCatId: event.payload.toCatId, - ...(message.extra?.coordination ? { coordination: { ...message.extra.coordination } } : {}), - }; - } - return { - kind: 'handed_cvo', - sourceEventId: event.sourceEventId, - sourceMessageId: message.id, - fromCatId: event.payload.fromCatId, - intent: event.payload.intent, - ...(message.extra?.coordination ? { coordination: { ...message.extra.coordination } } : {}), - }; -} - -async function readLineageMessage( - messageStore: Pick, - sourceMessageId: string, - threadId: string, - log?: { warn(obj: unknown, msg?: string): void }, -): Promise { - try { - return await messageStore.getById(sourceMessageId); - } catch (err) { - log?.warn( - { err, threadId, sourceMessageId }, - '[F167] replacement lineage metadata unavailable; keeping the dispatch live unless another candidate proves lineage', - ); - return null; - } -} - -function replacementSourceMessageId(event: ReplacementEvent): string | null { - if (event.kind === 'ball.handed') return handedSourceMessageId(event); - return event.sourceEventId.startsWith('route:') ? event.sourceEventId.slice('route:'.length) || null : null; -} - -function handedSourceMessageId(event: HandedReplacementEvent): string | null { - const prefix = 'route:'; - const suffix = `:${event.payload.toCatId}`; - if (!event.sourceEventId.startsWith(prefix) || !event.sourceEventId.endsWith(suffix)) return null; - const messageId = event.sourceEventId.slice(prefix.length, -suffix.length); - return messageId || null; -} - -function isVerifiedReplacementMessage( - message: StoredMessage | null, - threadId: string, - event: ReplacementEvent, -): message is StoredMessage { - if (!message || message.threadId !== threadId) return false; - if (event.kind === 'ball.handed_cvo') return message.catId === event.payload.fromCatId; - return Boolean( - (!event.payload.fromCatId || message.catId === event.payload.fromCatId) && - (message.mentions.some((candidate) => candidate === event.payload.toCatId) || - message.extra?.targetCats?.includes(event.payload.toCatId)), - ); -} - -function hasCausalLineage( - message: StoredMessage, - lineageMessageIds: ReadonlySet, - lineageCoordinations: readonly CrossThreadCoordination[], -): boolean { - if (message.replyTo && lineageMessageIds.has(message.replyTo)) return true; - const causalSource = message.extra?.causal?.triggerMessageId; - if (causalSource && lineageMessageIds.has(causalSource)) return true; - const coordination = message.extra?.coordination; - return Boolean( - coordination && - lineageCoordinations.some( - (candidate) => candidate.id === coordination.id && candidate.subjectRef === coordination.subjectRef, - ), - ); -} diff --git a/packages/api/src/domains/ball-custody/ActionSuccessorProjectionRetirementService.ts b/packages/api/src/domains/ball-custody/ActionSuccessorProjectionRetirementService.ts index 0cffc126d2..c1da816b1f 100644 --- a/packages/api/src/domains/ball-custody/ActionSuccessorProjectionRetirementService.ts +++ b/packages/api/src/domains/ball-custody/ActionSuccessorProjectionRetirementService.ts @@ -3,10 +3,6 @@ import type { ActionSuccessorQueueRetirement, InvocationQueue, } from '../cats/services/agents/invocation/InvocationQueue.js'; -import type { - QueuedMessageCustodyCoordinator, - RetireActionSuccessorQueueCustodyResult, -} from '../cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { ITaskStore } from '../cats/services/stores/ports/TaskStoreContract.js'; import { buildActionSuccessorFence } from './ActionSuccessorAdmissionContract.js'; import type { ActionSuccessorLease } from './action-successor-state-machine.js'; @@ -18,8 +14,7 @@ interface QueuePublication { } interface ActionSuccessorProjectionRetirementDeps { - queueCustodyCoordinator: Pick; - invocationQueue: Pick; + invocationQueue: Pick; taskStore: Pick; publishQueue: (publication: QueuePublication) => Promise; } @@ -56,28 +51,12 @@ export class ActionSuccessorProjectionRetirementService { private async retireQueue(lease: ActionSuccessorLease): Promise { const fence = buildActionSuccessorFence(lease, lease.dispatchId); const processCandidates = this.deps.invocationQueue.listActionSuccessorFence(fence); - const sourceMessageIds = [ - ...new Set([ - ...(lease.dispatchDeliveredMessageId ? [lease.dispatchDeliveredMessageId] : []), - ...processCandidates.flatMap((candidate) => candidate.messageIds), - ]), - ]; - const durableRetirements = ( - await Promise.all( - sourceMessageIds.map((messageId) => - this.deps.queueCustodyCoordinator.retireActionSuccessorFence(messageId, fence), - ), - ) - ).filter((result): result is RetireActionSuccessorQueueCustodyResult => result !== null); - const processRetirements = this.deps.invocationQueue.retireActionSuccessorFence(fence); - const publications = this.queuePublications(durableRetirements, [...processCandidates, ...processRetirements]); + const durableRetirements = await this.deps.invocationQueue.retireActionSuccessorFenceDurable(fence); + const publications = this.queuePublications([...processCandidates, ...durableRetirements]); await Promise.all([...publications.values()].map((publication) => this.deps.publishQueue(publication))); } - private queuePublications( - durableRetirements: RetireActionSuccessorQueueCustodyResult[], - processRetirements: ActionSuccessorQueueRetirement[], - ): Map { + private queuePublications(retirements: ActionSuccessorQueueRetirement[]): Map { const publications = new Map(); const add = (threadId: string, userId: string, messageIds: readonly string[]): void => { const key = publicationKey({ threadId, userId, receiptMessageIds: [] }); @@ -88,8 +67,7 @@ export class ActionSuccessorProjectionRetirementService { receiptMessageIds: [...new Set([...(current?.receiptMessageIds ?? []), ...messageIds])], }); }; - for (const durable of durableRetirements) add(durable.threadId, durable.userId, [durable.messageId]); - for (const retired of processRetirements) add(retired.threadId, retired.userId, retired.messageIds); + for (const retired of retirements) add(retired.threadId, retired.userId, retired.messageIds); return publications; } diff --git a/packages/api/src/domains/ball-custody/BallCustodyProbeScheduler.ts b/packages/api/src/domains/ball-custody/BallCustodyProbeScheduler.ts index 7b2a75b027..9156d3cbcb 100644 --- a/packages/api/src/domains/ball-custody/BallCustodyProbeScheduler.ts +++ b/packages/api/src/domains/ball-custody/BallCustodyProbeScheduler.ts @@ -22,7 +22,7 @@ export interface BallCustodyWakeSender { } export type BallCustodyWakeAdmissionReceipt = - | { readonly kind: 'admitted'; readonly messageId: string; readonly outcome: 'dispatched' | 'enqueued' } + | { readonly kind: 'admitted'; readonly messageId: string; readonly outcome: 'enqueued' } | { readonly kind: 'not_admitted'; readonly messageId: string; diff --git a/packages/api/src/domains/ball-custody/BallCustodyWakeSender.ts b/packages/api/src/domains/ball-custody/BallCustodyWakeSender.ts index 85723abbc6..77e0f85202 100644 --- a/packages/api/src/domains/ball-custody/BallCustodyWakeSender.ts +++ b/packages/api/src/domains/ball-custody/BallCustodyWakeSender.ts @@ -34,7 +34,7 @@ export class SchedulerBallCustodyWakeSender implements BallCustodyWakeSender { const messageId = await this.opts.deliver({ threadId: input.task.threadId, content, - userId: 'scheduler', + userId, idempotencyKey: `ball-custody-wake:${input.task.id}:${ input.projection.blockedSinceAt ?? input.projection.lastStateChangeAt }`, diff --git a/packages/api/src/domains/ball-custody/CoordinationTerminalRetirement.ts b/packages/api/src/domains/ball-custody/CoordinationTerminalRetirement.ts deleted file mode 100644 index 94f6c9accf..0000000000 --- a/packages/api/src/domains/ball-custody/CoordinationTerminalRetirement.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { IMessageStore, MessageScanCursor } from '../cats/services/stores/ports/MessageStore.js'; -import type { A2ADispatchDispositionService } from './A2ADispatchDispositionService.js'; - -type RetirementService = Pick; -type RetirementMessages = Pick; - -/** Only the source author's committed consumption can close the reverse handoff. */ -export async function retireConsumedCoordinationTerminal( - messageId: string, - messageStore: Pick, - service: RetirementService | undefined, -): Promise<'skipped' | 'applied' | 'replayed'> { - const message = await messageStore.getById(messageId); - if (message?.extra?.coordination?.phase !== 'terminal') return 'skipped'; - const sourceMessageId = message.extra.causal?.triggerMessageId; - if (!sourceMessageId) return 'skipped'; - const source = await messageStore.getById(sourceMessageId); - if ( - source?.extra?.coordination?.phase !== 'active' || - !source.catId || - !message.queueCustody?.handledByCatIds.includes(source.catId) - ) - return 'skipped'; - if (!service) throw new Error('a2a_dispatch_disposition_service_unavailable'); - return (await service.completeFromCoordinationTerminal(messageId)).outcome; -} - -/** - * Rebuild retirement work from existing consumed source records after a crash or - * failed side effect. The cursor and timer only bound reads; neither owns work. - * No provider invocation, Queue insertion or message-state mutation occurs here. - */ -export class CoordinationTerminalRetirement { - private cursor: MessageScanCursor | undefined; - private timer: ReturnType | undefined; - private running: Promise<{ inspected: number; applied: number; failed: number }> | undefined; - - constructor( - private readonly deps: { - messageStore: RetirementMessages; - service: RetirementService; - log: { warn(obj: unknown, message: string): void; info(obj: unknown, message: string): void }; - }, - ) {} - - runPage(): Promise<{ inspected: number; applied: number; failed: number }> { - if (this.running) return this.running; - const run = this.scanPage(); - this.running = run; - const release = () => { - if (this.running === run) this.running = undefined; - }; - void run.then(release, release); - return run; - } - - private async scanPage(): Promise<{ inspected: number; applied: number; failed: number }> { - const scan = this.deps.messageStore.scanCoordinationTerminalMessageIds; - if (!scan) return { inspected: 0, applied: 0, failed: 0 }; - const page = await scan.call(this.deps.messageStore, this.cursor); - let applied = 0; - let failed = 0; - for (const messageId of page.messageIds) { - try { - if ( - (await retireConsumedCoordinationTerminal(messageId, this.deps.messageStore, this.deps.service)) === 'applied' - ) { - applied += 1; - } - } catch (err) { - failed += 1; - this.deps.log.warn({ err, messageId }, '[coordination-retirement] consumed source retirement deferred'); - } - } - // A failed source is retried on the next pass, without starving later pages. - this.cursor = page.nextCursor; - const result = { inspected: page.messageIds.length, applied, failed }; - if (applied > 0 || failed > 0) this.deps.log.info(result, '[coordination-retirement] recovery page'); - return result; - } - - start(): void { - if (this.timer) return; - const run = () => { - void this.runPage().catch((err) => { - this.deps.log.warn({ err }, '[coordination-retirement] message page unavailable; cursor retained'); - }); - }; - this.timer = setInterval(run, 30_000); - this.timer.unref?.(); - run(); - } - - async stop(): Promise { - if (this.timer) clearInterval(this.timer); - this.timer = undefined; - await this.running?.catch(() => {}); - } -} diff --git a/packages/api/src/domains/ball-custody/DirectActionSuccessorCarrierRecovery.ts b/packages/api/src/domains/ball-custody/DirectActionSuccessorCarrierRecovery.ts index 45bb9b81ba..2dfe1fd368 100644 --- a/packages/api/src/domains/ball-custody/DirectActionSuccessorCarrierRecovery.ts +++ b/packages/api/src/domains/ball-custody/DirectActionSuccessorCarrierRecovery.ts @@ -1,6 +1,5 @@ -import type { QueueReceiptTargetState } from '@cat-cafe/shared'; -import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; -import { projectQueueReceipt } from '../cats/services/stores/ports/queued-message-receipt.js'; +import type { InvocationQueue } from '../cats/services/agents/invocation/InvocationQueue.js'; +import type { QueueLedgerEntry } from '../cats/services/agents/invocation/queue-ledger/QueueLedger.js'; import { type ActionSuccessorAdmissionInput, type ActionSuccessorFence, @@ -10,10 +9,6 @@ import { import { canonicalizeActionTerminalPredicate } from './ActionTerminalPredicateCatalog.js'; import type { ActionSuccessorLease } from './action-successor-state-machine.js'; -const LIVE_TARGET_STATES = new Set(['queued', 'notified', 'awakened', 'seen', 'steering']); - -type ObservedCarrierState = QueueReceiptTargetState | 'admitted'; - export type DirectActionSuccessorCarrierUnavailableReason = | 'authority_mismatch' | 'carrier_missing' @@ -24,7 +19,6 @@ export type DirectActionSuccessorCarrierUnavailableReason = export type DirectActionSuccessorCarrierDecision = | { disposition: 'live'; fence: ActionSuccessorFence } - | { disposition: 'restart_interrupted'; fence: ActionSuccessorFence } | { disposition: 'unavailable'; reason: DirectActionSuccessorCarrierUnavailableReason }; function sameMembers(left: readonly string[], right: readonly string[]): boolean { @@ -85,79 +79,39 @@ export function isExactDirectActionSuccessorReentry( } } -function observeAdmission( - message: StoredMessage, +function observeLedgerEntry( + entry: QueueLedgerEntry, holders: readonly string[], fence: ActionSuccessorFence, - observed: Map>, + observed: Set, ): void { - const admission = message.queueCustodyAdmission; - if (!admission || !actionSuccessorFencesMatch(admission.actionSuccessorFence, fence)) return; - if (!sameMembers(holders, admission.targetCats)) return; - for (const holder of holders) { - if (admission.targetCats.includes(holder as (typeof admission.targetCats)[number])) { - observed.get(holder)?.add('admitted'); - } - } -} - -function observeCustody( - message: StoredMessage, - holders: readonly string[], - fence: ActionSuccessorFence, - observed: Map>, -): void { - const custody = message.queueCustody; - if (!custody) return; - const receipt = projectQueueReceipt(custody); - for (const holder of holders) { - const binding = custody.carrierByTargetCatId?.[holder]; - if (!actionSuccessorFencesMatch(binding?.actionSuccessorFence, fence)) continue; - if (binding?.idempotencyKey !== `action:${fence.leaseId}:${fence.generation}:${holder}`) continue; - const target = receipt.targets.find((candidate) => candidate.catId === holder); - if (target) observed.get(holder)?.add(target.state); + if (!actionSuccessorFencesMatch(entry.execution.actionSuccessorFence, fence)) return; + for (const targetId of entry.targets) { + if (holders.includes(targetId)) observed.add(targetId); } } /** Classify only durable, exact-fence custody; message recency and process state are irrelevant. */ export function classifyDirectActionSuccessorCarrier( lease: ActionSuccessorLease, - messages: readonly StoredMessage[], + entries: readonly QueueLedgerEntry[], ): DirectActionSuccessorCarrierDecision { const fence = buildActionSuccessorFence(lease, lease.dispatchId); - const observed = new Map(lease.holderCatIds.map((catId) => [catId, new Set()])); + const observed = new Set(); - for (const message of messages) { - if (message.threadId !== lease.holderThreadId || message.userId !== lease.tenantScope) continue; - observeAdmission(message, lease.holderCatIds, fence, observed); - observeCustody(message, lease.holderCatIds, fence, observed); + for (const entry of entries) { + const ownerUserId = entry.owner.kind === 'user' ? entry.owner.userId : `system:${entry.owner.service}`; + if (entry.threadId !== lease.holderThreadId || ownerUserId !== lease.tenantScope) continue; + observeLedgerEntry(entry, lease.holderCatIds, fence, observed); } - const holderStates = lease.holderCatIds.map((catId) => observed.get(catId) ?? new Set()); - const everyHolderLive = holderStates.every((states) => - [...states].some((state) => state === 'admitted' || LIVE_TARGET_STATES.has(state as QueueReceiptTargetState)), - ); - if (everyHolderLive) return { disposition: 'live', fence }; - - const everyHolderRestartInterrupted = holderStates.every( - (states) => states.size > 0 && [...states].every((state) => state === 'interrupted'), - ); - if (everyHolderRestartInterrupted) return { disposition: 'restart_interrupted', fence }; - - if (holderStates.some((states) => states.size === 0)) { - return { disposition: 'unavailable', reason: 'carrier_missing' }; - } - if (holderStates.some((states) => states.has('handled') || states.has('withdrawn'))) { - return { disposition: 'unavailable', reason: 'carrier_terminal' }; - } - if (holderStates.some((states) => states.has('failed'))) { - return { disposition: 'unavailable', reason: 'carrier_failed' }; - } - return { disposition: 'unavailable', reason: 'carrier_mixed' }; + return lease.holderCatIds.every((catId) => observed.has(catId)) + ? { disposition: 'live', fence } + : { disposition: 'unavailable', reason: 'carrier_missing' }; } export async function resolveDirectActionSuccessorCarrier(input: { - messageStore: Pick; + invocationQueue: Pick; lease: ActionSuccessorLease; admissionInput: ActionSuccessorAdmissionInput; }): Promise { @@ -165,14 +119,8 @@ export async function resolveDirectActionSuccessorCarrier(input: { return { disposition: 'unavailable', reason: 'authority_mismatch' }; } try { - const messages = await input.messageStore.getByThreadAfter( - input.lease.holderThreadId, - undefined, - undefined, - input.lease.tenantScope, - { includeQueuedCatMessages: true, includeQueuedUserMessages: true }, - ); - return classifyDirectActionSuccessorCarrier(input.lease, messages); + const entries = await input.invocationQueue.listAllDurable(input.lease.holderThreadId); + return classifyDirectActionSuccessorCarrier(input.lease, entries); } catch { return { disposition: 'unavailable', reason: 'lookup_failed' }; } diff --git a/packages/api/src/domains/ball-custody/ManagedCommandWakeRecoveryEngine.ts b/packages/api/src/domains/ball-custody/ManagedCommandWakeRecoveryEngine.ts new file mode 100644 index 0000000000..149518f8e4 --- /dev/null +++ b/packages/api/src/domains/ball-custody/ManagedCommandWakeRecoveryEngine.ts @@ -0,0 +1,340 @@ +import { createModuleLogger } from '../../infrastructure/logger.js'; +import { managedCommandDispatchRetryTotal } from '../../infrastructure/telemetry/instruments.js'; +import type { InvocationRecord } from '../cats/services/stores/ports/InvocationRecordStore.js'; +import { classifyInvocationRecoveryStatus } from '../cats/services/stores/ports/invocation-state-machine.js'; +import { ManagedCommandWakeActionLeaseAdmissionError } from './managed-command-wake-action-lease-admission.js'; +import { + type ManagedCommandWakeCarrierTerminalReason, + type ManagedCommandWakeProjection, + type ManagedCommandWakeRecoveryDeps, + type ManagedCommandWakeRecoveryResult, + type ManagedCommandWakeTriggerOutcome, + type ParsedManagedCommandWakeTask, + parseManagedCommandWakeTask as parseWakeTask, +} from './managed-command-wake-lifecycle.js'; +import { publishManagedCommandWakeMessage } from './managed-command-wake-message-fence.js'; +import { + isDispatchableManagedCommandWakeState, + recordManagedCommandWakeSlaBreach, +} from './managed-command-wake-recovery-policy.js'; + +const log = createModuleLogger('ball-custody/managed-command-wake-recovery-engine'); + +/** Reconciles one managed wake after its command has produced durable evidence. */ +export class ManagedCommandWakeRecoveryEngine { + private readonly now: () => number; + private readonly dispatchedCarrierGraceMs: number; + private readonly wakeSlaMs: number; + + constructor(private readonly deps: ManagedCommandWakeRecoveryDeps) { + this.now = deps.now ?? Date.now; + this.dispatchedCarrierGraceMs = deps.dispatchedCarrierGraceMs ?? 15_000; + this.wakeSlaMs = deps.wakeSlaMs ?? 60_000; + } + + async retireCarrier(messageIds: readonly string[], reason: ManagedCommandWakeCarrierTerminalReason): Promise { + const getById = this.deps.messageStore.getById?.bind(this.deps.messageStore); + if (!getById) return 0; + let retired = 0; + for (const messageId of new Set(messageIds)) { + const message = await getById(messageId); + const meta = message?.source?.meta; + if ( + message?.source?.connector !== 'hold-ball' || + meta?.wakeWhen !== true || + typeof meta.taskId !== 'string' || + meta.taskId.length === 0 + ) { + continue; + } + if (this.retireTask(meta.taskId, reason, messageId)) retired += 1; + } + return retired; + } + + async retireThread( + threadId: string, + userId: string, + reason: ManagedCommandWakeCarrierTerminalReason, + ): Promise<{ retired: number; messageIds: string[] }> { + let retired = 0; + const messageIds = new Set(); + const taskIds = this.deps.dynamicTaskStore.getAll().flatMap((task) => { + const parsed = parseWakeTask(task); + if (!parsed || parsed.threadId !== threadId || parsed.userId !== userId) return []; + if (parsed.command.messageId) messageIds.add(parsed.command.messageId); + return [parsed.task.id]; + }); + for (const taskId of taskIds) { + if (this.retireTask(taskId, reason)) retired += 1; + } + return { retired, messageIds: [...messageIds] }; + } + + async recoverTask(taskId: string): Promise { + let parsed = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); + if (!parsed) return 'missing'; + parsed = recordManagedCommandWakeSlaBreach(this.deps, parsed, this.now, this.wakeSlaMs); + if (parsed.command.state === 'lost') return this.persistLostCommandStatus(parsed); + if (parsed.command.state === 'condition_met') { + const published = await publishManagedCommandWakeMessage(this.deps, parsed, this.now); + if (!published) return 'pending'; + parsed = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); + if (!parsed) return 'missing'; + } + if (isDispatchableManagedCommandWakeState(parsed.command.state)) { + return this.recoverDispatchableTask(parsed); + } + return parsed.command.state === 'consumed' ? 'recovered' : 'pending'; + } + + private async recoverDispatchableTask( + parsed: ParsedManagedCommandWakeTask, + ): Promise { + const eventCarrier = parsed.command.messageId + ? await this.deps.getEventCarrier?.({ + threadId: parsed.threadId, + userId: parsed.userId, + catId: parsed.catId, + messageId: parsed.command.messageId, + }) + : undefined; + if (eventCarrier?.state === 'handled') return this.consume(parsed, eventCarrier.invocationId); + if (eventCarrier?.state === 'terminal') return this.consume(parsed, undefined, eventCarrier.reason); + if (eventCarrier?.state === 'failed') return this.consume(parsed, eventCarrier.invocationId, 'failed'); + if (eventCarrier?.state === 'pending') return 'pending'; + + const carrier = await this.findInvocationCarrier(parsed); + if (carrier) { + const recoveryStatus = classifyInvocationRecoveryStatus(carrier.status); + if (recoveryStatus === 'completed') return this.consume(parsed, carrier.id); + if (recoveryStatus === 'in_flight' || recoveryStatus === 'terminal') return 'pending'; + } + const lastDispatchAt = parsed.command.lastDispatchAt ?? 0; + if (lastDispatchAt > 0 && this.now() - lastDispatchAt < this.dispatchedCarrierGraceMs) return 'pending'; + return this.dispatch(parsed); + } + + private async persistLostCommandStatus( + parsed: ParsedManagedCommandWakeTask, + ): Promise { + const idempotencyKey = `hold-ball-lost:${parsed.task.id}`; + try { + const existing = await this.deps.messageStore.getByIdempotencyKey(parsed.userId, parsed.threadId, idempotencyKey); + if (!existing) await this.appendLostStatus(parsed, idempotencyKey); + const latest = parseWakeTask(this.deps.dynamicTaskStore.getById(parsed.task.id)); + return latest?.command.state === 'lost' ? this.consume(latest, undefined, 'failed') : 'pending'; + } catch (err) { + log.warn( + { err, taskId: parsed.task.id, threadId: parsed.threadId }, + 'lost managed-command terminal status persistence failed — will retry', + ); + return 'pending'; + } + } + + private async appendLostStatus(parsed: ParsedManagedCommandWakeTask, idempotencyKey: string): Promise { + const detail = parsed.command.lostDetail ? `(${parsed.command.lostDetail})` : ''; + const content = + parsed.command.lostReason === 'spawn_failed' + ? `等待失败:命令「${parsed.command.command}」未启动${detail}。` + : parsed.command.lostReason === 'runner_failed' + ? `等待已结束:命令「${parsed.command.command}」执行进程异常终止${detail}。` + : `等待已结束:服务重启导致命令「${parsed.command.command}」的本地执行进程丢失。`; + const recoverySource = + parsed.command.lostReason === 'spawn_failed' + ? 'spawn_admission' + : parsed.command.lostReason === 'runner_failed' + ? 'command_runner' + : 'startup_sweep'; + const stored = await this.deps.messageStore.append({ + from: { kind: 'system', service: 'hold-ball' }, + userId: parsed.userId, + content, + mentions: [], + timestamp: this.now(), + threadId: parsed.threadId, + idempotencyKey, + source: { + connector: 'hold-ball', + label: '持球状态', + icon: '🏓', + meta: { + managedHold: true, + phase: 'status', + taskId: parsed.task.id, + threadId: parsed.threadId, + catId: parsed.catId, + recoverySource, + }, + }, + }); + this.deps.socketManager.broadcastToRoom(`thread:${parsed.threadId}`, 'connector_message', { + threadId: parsed.threadId, + message: { + id: stored.id, + type: 'connector', + content: stored.content, + source: stored.source, + timestamp: stored.timestamp, + }, + }); + } + + private async dispatch(parsed: ParsedManagedCommandWakeTask): Promise { + const messageId = parsed.command.messageId; + const wakeContent = parsed.command.wakeContent; + if (!messageId || !wakeContent) return 'pending'; + + const attemptCount = (parsed.command.dispatchAttemptCount ?? 0) + 1; + if (attemptCount > 1) managedCommandDispatchRetryTotal.add(1); + if ( + !this.updateCommand(parsed, { + ...parsed.command, + state: 'dispatch_pending', + dispatchAttemptCount: attemptCount, + lastDispatchAt: this.now(), + }) + ) { + return 'pending'; + } + + const trigger = this.deps.getInvokeTrigger(); + if (!trigger) { + this.persistDispatchOutcome(parsed.task.id, 'unavailable'); + return 'pending'; + } + return this.invokeDispatchTrigger(parsed, trigger, messageId, wakeContent); + } + + private async invokeDispatchTrigger( + parsed: ParsedManagedCommandWakeTask, + trigger: NonNullable>, + messageId: string, + wakeContent: string, + ): Promise { + let outcome: ManagedCommandWakeTriggerOutcome; + try { + outcome = await trigger.trigger( + parsed.threadId, + parsed.catId, + parsed.userId, + `[定时任务] ${wakeContent}`, + messageId, + undefined, + { sourceCategory: 'scheduled', priority: 'urgent' }, + ); + } catch (err) { + return this.handleDispatchError(parsed, messageId, err); + } + return this.commitDispatchOutcome(parsed, outcome); + } + + private async handleDispatchError( + parsed: ParsedManagedCommandWakeTask, + messageId: string, + err: unknown, + ): Promise { + if (err instanceof ManagedCommandWakeActionLeaseAdmissionError) { + try { + const canceled = await this.deps.messageStore.markCanceled(messageId); + if (canceled?.deliveryStatus === 'canceled' && this.retireTask(parsed.task.id, 'canceled', messageId)) { + log.info({ code: err.code, taskId: parsed.task.id, messageId }, 'stale managed-command wake retired'); + return 'recovered'; + } + } catch (cancelError) { + log.warn({ err, cancelError, taskId: parsed.task.id, messageId }, 'managed-command wake retirement failed'); + } + } else { + log.warn( + { err, taskId: parsed.task.id, threadId: parsed.threadId, messageId }, + 'managed-command execution-plane dispatch failed', + ); + } + this.persistDispatchOutcome(parsed.task.id, 'failed'); + return 'pending'; + } + + private async commitDispatchOutcome( + parsed: ParsedManagedCommandWakeTask, + outcome: ManagedCommandWakeTriggerOutcome, + ): Promise { + const latest = parseWakeTask(this.deps.dynamicTaskStore.getById(parsed.task.id)); + if (!latest) return 'missing'; + if (outcome === 'full') { + this.updateCommand(latest, { ...latest.command, state: 'dispatch_pending', lastDispatchOutcome: 'full' }); + return 'pending'; + } + if (!this.updateCommand(latest, { ...latest.command, state: outcome, lastDispatchOutcome: outcome })) { + return 'pending'; + } + const acknowledged = parseWakeTask(this.deps.dynamicTaskStore.getById(parsed.task.id)); + if (!acknowledged) return 'missing'; + const carrier = await this.findInvocationCarrier(acknowledged); + return carrier && classifyInvocationRecoveryStatus(carrier.status) === 'completed' + ? this.consume(acknowledged, carrier.id) + : 'pending'; + } + + private persistDispatchOutcome(taskId: string, outcome: 'failed' | 'unavailable'): void { + const latest = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); + if (!latest) return; + this.updateCommand(latest, { ...latest.command, state: 'dispatch_pending', lastDispatchOutcome: outcome }); + } + + private async findInvocationCarrier(parsed: ParsedManagedCommandWakeTask): Promise { + if (!parsed.command.messageId) return null; + const targetScoped = await this.deps.invocationRecordStore.getByIdempotencyKey( + parsed.threadId, + parsed.userId, + `connector-${parsed.command.messageId}:${parsed.catId}`, + ); + if (targetScoped) return targetScoped; + return this.deps.invocationRecordStore.getByIdempotencyKey( + parsed.threadId, + parsed.userId, + `connector-${parsed.command.messageId}`, + ); + } + + retireTask(taskId: string, reason: ManagedCommandWakeCarrierTerminalReason, expectedMessageId?: string): boolean { + for (let attempt = 0; attempt < 8; attempt += 1) { + const parsed = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); + if (!parsed || (expectedMessageId && parsed.command.messageId !== expectedMessageId)) return false; + if (this.consume(parsed, undefined, reason) === 'recovered') return true; + } + return false; + } + + private consume( + parsed: ParsedManagedCommandWakeTask, + invocationId?: string, + carrierTerminalReason?: ManagedCommandWakeCarrierTerminalReason, + ): ManagedCommandWakeRecoveryResult { + const updated = this.deps.dynamicTaskStore.updateParamsIfCurrent(parsed.task.id, parsed.task.params, { + ...parsed.task.params, + holdLifecycle: { + ...parsed.lifecycle, + status: 'fired', + managedCommand: { + ...parsed.command, + state: 'consumed', + ...(invocationId ? { invocationId } : {}), + ...(carrierTerminalReason ? { carrierTerminalReason } : {}), + consumedAt: this.now(), + }, + }, + }); + if (!updated) return 'missing'; + this.deps.dynamicTaskStore.setEnabled(parsed.task.id, false); + this.deps.taskRunner.unregister(parsed.task.id); + return 'recovered'; + } + + updateCommand(parsed: ParsedManagedCommandWakeTask, command: ManagedCommandWakeProjection): boolean { + return this.deps.dynamicTaskStore.updateParamsIfCurrent(parsed.task.id, parsed.task.params, { + ...parsed.task.params, + holdLifecycle: { ...parsed.lifecycle, managedCommand: command }, + }); + } +} diff --git a/packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts b/packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts index 2bd8c85dcb..5fff589d4d 100644 --- a/packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts +++ b/packages/api/src/domains/ball-custody/ManagedCommandWakeRecoverySweep.ts @@ -1,42 +1,28 @@ import { createModuleLogger } from '../../infrastructure/logger.js'; import type { DynamicTaskDef } from '../../infrastructure/scheduler/DynamicTaskStore.js'; -import { - managedCommandCompletionUnconsumedTotal, - managedCommandDispatchRetryTotal, -} from '../../infrastructure/telemetry/instruments.js'; -import { normalizeOwnerAuthProvenance } from '../cats/services/owner-auth-provenance.js'; -import type { InvocationRecord } from '../cats/services/stores/ports/InvocationRecordStore.js'; -import { classifyInvocationRecoveryStatus } from '../cats/services/stores/ports/invocation-state-machine.js'; +import { managedCommandCompletionUnconsumedTotal } from '../../infrastructure/telemetry/instruments.js'; import { cancelDurableManagedGateJob, inspectDurableManagedGateJob, validateDurableManagedGateJob, } from './durable-managed-gate-job.js'; -import { ManagedCommandWakeActionLeaseAdmissionError } from './managed-command-wake-action-lease-admission.js'; +import { ManagedCommandWakeRecoveryEngine } from './ManagedCommandWakeRecoveryEngine.js'; import { buildAdmissionFactIdempotencyKey, type RecordManagedCommandCompletionInput as CompletionInput, type ManagedCommandWakeCarrierTerminalReason, - type ManagedCommandWakeProjection, type ManagedCommandWakeRecoveryDeps, type ManagedCommandWakeRecoveryResult, type ManagedCommandWakeRecoveryStats, - type ManagedCommandWakeTriggerOutcome, - type ParsedManagedCommandWakeTask, parseManagedCommandWakeTask as parseWakeTask, persistManagedCommandCompletionEvidence, } from './managed-command-wake-lifecycle.js'; -import { publishManagedCommandWakeMessage } from './managed-command-wake-message-fence.js'; -import { - isDispatchableManagedCommandWakeState, - recordManagedCommandWakeSlaBreach, - recoverManagedCommandMissingDisposition, -} from './managed-command-wake-recovery-policy.js'; import { persistManagedCommandFallbackDue, recordCancelledManagedCommandCompletion, } from './managed-command-wake-recovery-transitions.js'; import { + type ManagedCommandWakeLostReason, parseRetiredManagedCommandWakeTask, readManagedCommandWakeProjection, } from './managed-command-wake-task-projection.js'; @@ -66,12 +52,10 @@ const log = createModuleLogger('ball-custody/managed-command-wake-recovery'); export class ManagedCommandWakeRecoverySweep { private readonly now: () => number; - private readonly dispatchedCarrierGraceMs: number; - private readonly wakeSlaMs: number; + private readonly engine: ManagedCommandWakeRecoveryEngine; constructor(private readonly deps: ManagedCommandWakeRecoveryDeps) { this.now = deps.now ?? Date.now; - this.dispatchedCarrierGraceMs = deps.dispatchedCarrierGraceMs ?? 15_000; - this.wakeSlaMs = deps.wakeSlaMs ?? 60_000; + this.engine = new ManagedCommandWakeRecoveryEngine(deps); } async recordCompletion(input: CompletionInput): Promise { const evidence = persistManagedCommandCompletionEvidence(this.deps.dynamicTaskStore, input, this.now()); @@ -96,6 +80,44 @@ export class ManagedCommandWakeRecoverySweep { if (result === 'pending') managedCommandCompletionUnconsumedTotal.add(1); return result; } + async recordLost( + taskId: string, + lostReason: ManagedCommandWakeLostReason, + lostDetail?: string, + ): Promise { + const task = this.deps.dynamicTaskStore.getById(taskId); + const parsed = parseWakeTask(task); + if (!parsed) { + const retired = parseRetiredManagedCommandWakeTask(task); + if (!retired || retired.command.state !== 'command_running') return 'missing'; + const detail = lostDetail ? `(${lostDetail.slice(0, 500)})` : ''; + return this.recordRetiredCompletion({ + taskId, + wakeContent: `等待失败:命令「${retired.command.command}」执行归属已丢失${detail}。`, + result: { + exitCode: null, + timedOut: false, + durationMs: Math.max(0, this.now() - retired.command.startedAt), + ...(lostDetail ? { tailOutput: lostDetail.slice(0, 500) } : {}), + }, + }); + } + if (parsed.command.state === 'lost') return this.recoverTask(taskId); + if (parsed.command.state === 'consumed') return 'recovered'; + if (parsed.command.state !== 'command_running') return 'pending'; + if ( + !this.engine.updateCommand(parsed, { + ...parsed.command, + state: 'lost', + lostAt: this.now(), + lostReason, + ...(lostDetail ? { lostDetail: lostDetail.slice(0, 500) } : {}), + }) + ) { + return 'pending'; + } + return this.recoverTask(taskId); + } private async reconcileDurableGateJobs(tasks: DynamicTaskDef[]): Promise { let recovered = 0; let pending = 0; @@ -153,11 +175,15 @@ export class ManagedCommandWakeRecoverySweep { for (const { task, parsed, admissionFact } of undelivered) { const idempotencyKey = buildAdmissionFactIdempotencyKey(task.id); try { - const existing = await this.deps.messageStore.getByIdempotencyKey('system', parsed.threadId, idempotencyKey); + const existing = await this.deps.messageStore.getByIdempotencyKey( + parsed.userId, + parsed.threadId, + idempotencyKey, + ); if (!existing) { const stored = await this.deps.messageStore.append({ - userId: 'system', - catId: null, + from: { kind: 'system', service: 'hold-ball' }, + userId: parsed.userId, content: admissionFact, mentions: [], timestamp: this.now(), @@ -167,7 +193,15 @@ export class ManagedCommandWakeRecoverySweep { connector: 'hold-ball', label: '持球通知', icon: '🏓', - meta: { wakeWhen: true, taskId: task.id, recoverySource: 'startup_sweep' }, + meta: { + managedHold: true, + phase: 'status', + taskId: task.id, + threadId: parsed.threadId, + catId: parsed.catId, + wakeWhen: true, + recoverySource: 'startup_sweep', + }, }, }); this.deps.socketManager.broadcastToRoom(`thread:${parsed.threadId}`, 'connector_message', { @@ -181,7 +215,7 @@ export class ManagedCommandWakeRecoverySweep { }, }); } - const casOk = this.updateCommand(parsed, { ...parsed.command, admissionFactAppended: true }); + const casOk = this.engine.updateCommand(parsed, { ...parsed.command, admissionFactAppended: true }); if (casOk) recovered += 1; else pending += 1; log.info( @@ -198,13 +232,37 @@ export class ManagedCommandWakeRecoverySweep { } return { scanned: undelivered.length, recovered, pending }; } + private async recoverLostNonDurableCommands(tasks: DynamicTaskDef[]): Promise { + const isRunnerActive = this.deps.isCommandRunnerActive; + if (!isRunnerActive) return { scanned: 0, recovered: 0, pending: 0 }; + let recovered = 0; + let pending = 0; + const lostCandidates = tasks.flatMap((task) => { + const parsed = parseWakeTask(task) ?? parseRetiredManagedCommandWakeTask(task); + return parsed && + parsed.command.state === 'command_running' && + !parsed.command.durableJob && + !isRunnerActive(task.id) + ? [parsed] + : []; + }); + for (const parsed of lostCandidates) { + const result = await this.recordLost(parsed.task.id, 'runtime_restart'); + if (result === 'recovered') recovered += 1; + else pending += 1; + } + return { scanned: lostCandidates.length, recovered, pending }; + } async runOnce(): Promise { const tasks = this.deps.dynamicTaskStore.getAll(); const admission = await this.recoverAdmissionFacts(tasks); const durable = await this.reconcileDurableGateJobs(tasks); + const lost = await this.recoverLostNonDurableCommands(tasks); let { recovered, pending } = admission; recovered += durable.recovered; pending += durable.pending; + recovered += lost.recovered; + pending += lost.pending; // F261: API restart loses the in-memory ManagedRunner, not the authorized // action-plane job. Reconcile durable full-gate process/receipt truth before @@ -234,250 +292,23 @@ export class ManagedCommandWakeRecoverySweep { } return { - scanned: candidates.length + retiredTaskIds.length + admission.scanned + durable.scanned, + scanned: candidates.length + retiredTaskIds.length + admission.scanned + durable.scanned + lost.scanned, recovered, pending, }; } async retireCarrier(messageIds: readonly string[], reason: ManagedCommandWakeCarrierTerminalReason): Promise { - const getById = this.deps.messageStore.getById?.bind(this.deps.messageStore); - if (!getById) return 0; - let retired = 0; - for (const messageId of new Set(messageIds)) { - const message = await getById(messageId); - const meta = message?.source?.meta; - if ( - message?.source?.connector !== 'hold-ball' || - meta?.wakeWhen !== true || - typeof meta.taskId !== 'string' || - meta.taskId.length === 0 - ) { - continue; - } - if (this.retireTask(meta.taskId, reason, messageId)) retired += 1; - } - return retired; + return this.engine.retireCarrier(messageIds, reason); } async retireThread( threadId: string, userId: string, reason: ManagedCommandWakeCarrierTerminalReason, ): Promise<{ retired: number; messageIds: string[] }> { - let retired = 0; - const messageIds = new Set(); - const taskIds = this.deps.dynamicTaskStore.getAll().flatMap((task) => { - const parsed = parseWakeTask(task); - if (!parsed || parsed.threadId !== threadId || parsed.userId !== userId) return []; - if (parsed.command.messageId) messageIds.add(parsed.command.messageId); - return [parsed.task.id]; - }); - for (const taskId of taskIds) { - if (this.retireTask(taskId, reason)) retired += 1; - } - return { retired, messageIds: [...messageIds] }; - } - async recoverTask(taskId: string): Promise { - let parsed = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); - if (!parsed) return 'missing'; - parsed = recordManagedCommandWakeSlaBreach(this.deps, parsed, this.now, this.wakeSlaMs); - if (parsed.command.state === 'condition_met') { - const published = await this.publishCompletion(parsed); - if (!published) return 'pending'; - parsed = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); - if (!parsed) return 'missing'; - } - if (isDispatchableManagedCommandWakeState(parsed.command.state)) { - const eventCarrier = parsed.command.messageId - ? await this.deps.getEventCarrier?.({ - threadId: parsed.threadId, - userId: parsed.userId, - catId: parsed.catId, - messageId: parsed.command.messageId, - }) - : undefined; - if (eventCarrier?.state === 'handled') return this.consume(parsed, eventCarrier.invocationId); - if (eventCarrier?.state === 'terminal') { - return this.consume(parsed, undefined, eventCarrier.reason); - } - if (eventCarrier?.state === 'failed') { - return eventCarrier.errorCode === 'managed_hold_disposition_missing' - ? recoverManagedCommandMissingDisposition(this.deps, parsed, eventCarrier, this.now) - : 'pending'; - } - if (eventCarrier?.state === 'pending') return 'pending'; - // An orphaned receipt proves durable responsibility while also proving - // that its exact Queue row disappeared. Only that state may reach the - // Connector's verified custody-rebind path. - const carrier = await this.findInvocationCarrier(parsed); - if (carrier) { - const recoveryStatus = classifyInvocationRecoveryStatus(carrier.status); - if (recoveryStatus === 'completed') return this.consume(parsed, carrier.id); - if (recoveryStatus === 'in_flight' || recoveryStatus === 'terminal') return 'pending'; - } - const lastDispatchAt = parsed.command.lastDispatchAt ?? 0; - if (lastDispatchAt > 0 && this.now() - lastDispatchAt < this.dispatchedCarrierGraceMs) { - return 'pending'; - } - return this.dispatch(parsed); - } - - return parsed.command.state === 'consumed' ? 'recovered' : 'pending'; - } - - private async publishCompletion(parsed: ParsedManagedCommandWakeTask): Promise { - return publishManagedCommandWakeMessage(this.deps, parsed, this.now); + return this.engine.retireThread(threadId, userId, reason); } - private async dispatch(parsed: ParsedManagedCommandWakeTask): Promise { - const messageId = parsed.command.messageId; - const wakeContent = parsed.command.wakeContent; - if (!messageId || !wakeContent) return 'pending'; - - const attemptAt = this.now(); - const attemptCount = (parsed.command.dispatchAttemptCount ?? 0) + 1; - if (attemptCount > 1) managedCommandDispatchRetryTotal.add(1); - if ( - !this.updateCommand(parsed, { - ...parsed.command, - state: 'dispatch_pending', - dispatchAttemptCount: attemptCount, - lastDispatchAt: attemptAt, - }) - ) { - return 'pending'; - } - - const trigger = this.deps.getInvokeTrigger(); - if (!trigger) { - this.persistDispatchOutcome(parsed.task.id, 'unavailable'); - return 'pending'; - } - - let outcome: ManagedCommandWakeTriggerOutcome; - try { - const ownerAuthProvenance = normalizeOwnerAuthProvenance( - this.deps.dynamicTaskStore.getPrivateOwnerAuthProvenance?.(parsed.task.id), - ); - outcome = await trigger.trigger( - parsed.threadId, - parsed.catId, - parsed.userId, - `[定时任务] ${wakeContent}`, - messageId, - undefined, - { sourceCategory: 'scheduled', forceQueue: true, ownerAuthProvenance }, - ); - } catch (err) { - if (err instanceof ManagedCommandWakeActionLeaseAdmissionError) { - try { - const canceled = await this.deps.messageStore.markCanceled(messageId); - if (canceled?.deliveryStatus === 'canceled' && this.retireTask(parsed.task.id, 'canceled', messageId)) { - log.info({ code: err.code, taskId: parsed.task.id, messageId }, 'stale managed-command wake retired'); - return 'recovered'; - } - } catch (cancelError) { - log.warn({ err, cancelError, taskId: parsed.task.id, messageId }, 'managed-command wake retirement failed'); - } - this.persistDispatchOutcome(parsed.task.id, 'failed'); - return 'pending'; - } - log.warn( - { err, taskId: parsed.task.id, threadId: parsed.threadId, messageId }, - 'managed-command execution-plane dispatch failed', - ); - this.persistDispatchOutcome(parsed.task.id, 'failed'); - return 'pending'; - } - - const latest = parseWakeTask(this.deps.dynamicTaskStore.getById(parsed.task.id)); - if (!latest) return 'missing'; - if (outcome === 'full') { - this.updateCommand(latest, { ...latest.command, state: 'dispatch_pending', lastDispatchOutcome: 'full' }); - return 'pending'; - } - - if ( - !this.updateCommand(latest, { - ...latest.command, - state: outcome, - lastDispatchOutcome: outcome, - }) - ) { - return 'pending'; - } - const acknowledged = parseWakeTask(this.deps.dynamicTaskStore.getById(parsed.task.id)); - if (!acknowledged) return 'missing'; - const carrier = await this.findInvocationCarrier(acknowledged); - return carrier && classifyInvocationRecoveryStatus(carrier.status) === 'completed' - ? this.consume(acknowledged, carrier.id) - : 'pending'; - } - - private persistDispatchOutcome(taskId: string, outcome: 'failed' | 'unavailable'): void { - const latest = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); - if (!latest) return; - this.updateCommand(latest, { - ...latest.command, - state: 'dispatch_pending', - lastDispatchOutcome: outcome, - }); - } - - private async findInvocationCarrier(parsed: ParsedManagedCommandWakeTask): Promise { - if (!parsed.command.messageId) return null; - return this.deps.invocationRecordStore.getByIdempotencyKey( - parsed.threadId, - parsed.userId, - `connector-${parsed.command.messageId}`, - ); - } - - private retireTask( - taskId: string, - reason: ManagedCommandWakeCarrierTerminalReason, - expectedMessageId?: string, - ): boolean { - for (let attempt = 0; attempt < 8; attempt += 1) { - const parsed = parseWakeTask(this.deps.dynamicTaskStore.getById(taskId)); - if (!parsed || (expectedMessageId && parsed.command.messageId !== expectedMessageId)) return false; - if (this.consume(parsed, undefined, reason) === 'recovered') return true; - } - return false; - } - - private consume( - parsed: ParsedManagedCommandWakeTask, - invocationId?: string, - carrierTerminalReason?: ManagedCommandWakeCarrierTerminalReason, - ): ManagedCommandWakeRecoveryResult { - const consumedAt = this.now(); - const updated = this.deps.dynamicTaskStore.updateParamsIfCurrent(parsed.task.id, parsed.task.params, { - ...parsed.task.params, - holdLifecycle: { - ...parsed.lifecycle, - status: 'fired', - managedCommand: { - ...parsed.command, - state: 'consumed', - ...(invocationId ? { invocationId } : {}), - ...(carrierTerminalReason ? { carrierTerminalReason } : {}), - consumedAt, - }, - }, - }); - if (!updated) return 'missing'; - this.deps.dynamicTaskStore.setEnabled(parsed.task.id, false); - this.deps.taskRunner.unregister(parsed.task.id); - return 'recovered'; - } - - private updateCommand(parsed: ParsedManagedCommandWakeTask, command: ManagedCommandWakeProjection): boolean { - return this.deps.dynamicTaskStore.updateParamsIfCurrent(parsed.task.id, parsed.task.params, { - ...parsed.task.params, - holdLifecycle: { - ...parsed.lifecycle, - managedCommand: command, - }, - }); + async recoverTask(taskId: string): Promise { + return this.engine.recoverTask(taskId); } } diff --git a/packages/api/src/domains/ball-custody/ManagedHoldDispositionService.ts b/packages/api/src/domains/ball-custody/ManagedHoldDispositionService.ts deleted file mode 100644 index dcd271ec11..0000000000 --- a/packages/api/src/domains/ball-custody/ManagedHoldDispositionService.ts +++ /dev/null @@ -1,314 +0,0 @@ -import type { BallCustodyEvent, CatId } from '@cat-cafe/shared'; -import type { InvocationRecord } from '../cats/services/agents/invocation/InvocationRegistry.js'; -import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; -import type { IBallCustodyEventLog } from './BallCustodyEventLog.js'; -import type { IBallCustodyFencedIngest } from './BallCustodyIngest.js'; -import type { IBallCustodyProjectionStore } from './BallCustodyProjectionStore.js'; -import { - buildHoldDispositionEvent, - holdDispositionEventSourceId, - type ManagedHoldDisposition, -} from './ball-custody-events.js'; -import { ManagedHoldReceiptError, type ManagedHoldReceiptService } from './ManagedHoldReceiptService.js'; -import { - type ManagedHoldDispositionAuth, - ManagedHoldDispositionError, - type ManagedHoldGuidance, - selectManagedHoldSource, -} from './ManagedHoldSourceSelection.js'; -import type { ManagedCommandWakeDynamicTaskStore } from './managed-command-wake-lifecycle.js'; -import { parseManagedCommandWakeTask } from './managed-command-wake-lifecycle.js'; -import { classifyManagedHoldWake, findWakeTerminal } from './managed-hold-supersession.js'; -import { ManagedHoldHeartbeatConflict, recordManagedHoldDisposition } from './record-managed-hold-disposition.js'; -import { turnCustodyAdoptionRegistry } from './TurnCustodyAdoptionRegistry.js'; - -export { ManagedHoldDispositionError } from './ManagedHoldSourceSelection.js'; - -export interface ManagedHoldDispositionResult { - readonly outcome: 'applied' | 'replayed'; - readonly disposition: ManagedHoldDisposition; - readonly invocationId: string; - readonly sourceMessageId: string; - readonly taskId: string; - /** The wake reached a terminal but was no longer the subject's live wake. */ - readonly retired: boolean; -} - -interface ManagedHoldDispositionDeps { - readonly registry: { isLatest(invocationId: string): Promise }; - readonly dynamicTaskStore: Pick; - readonly messageStore: Pick; - readonly ballCustodyEventLog: Pick; - readonly ballCustodyProjectionStore: Pick; - readonly ballCustody: IBallCustodyFencedIngest; - readonly receiptService: Pick; - readonly repairProjection?: (subjectKey: string) => Promise; - readonly now?: () => number; - readonly log?: { warn(fields: Record, message: string): void }; -} - -/** Invocation-bound terminal producer for an exact managed hold wake. */ -export class ManagedHoldDispositionService { - private readonly now: () => number; - - constructor(private readonly deps: ManagedHoldDispositionDeps) { - this.now = deps.now ?? Date.now; - } - - async complete( - auth: ManagedHoldDispositionAuth, - disposition: ManagedHoldDisposition, - ): Promise { - const adopted = turnCustodyAdoptionRegistry.snapshot(auth.invocationId); - let boundSource: ManagedHoldGuidance['candidates'][number] | undefined; - const attempt = async () => { - await this.assertLatestInvocation(auth.invocationId); - const selection = await selectManagedHoldSource(this.deps, auth, adopted, this.now()); - const source = selection.state === 'single_canonical_pending' ? selection.candidates[0] : undefined; - if (!source) throw new ManagedHoldDispositionError(`managed_hold_disposition_${selection.state}`); - if ( - boundSource && - (source.sourceMessageId !== boundSource.sourceMessageId || source.taskId !== boundSource.taskId) - ) { - throw new ManagedHoldDispositionError('managed_hold_disposition_source_mismatch'); - } - boundSource = source; - return this.completeSource(auth, disposition, source); - }; - try { - return await attempt(); - } catch (error) { - // Only a proven heartbeat-only conflict earns one fresh authority check. - // A second conflict propagates; retries cannot select another obligation. - if (!(error instanceof ManagedHoldHeartbeatConflict)) throw error; - return attempt(); - } - } - - private async completeSource( - auth: ManagedHoldDispositionAuth, - disposition: ManagedHoldDisposition, - selected: ManagedHoldGuidance['candidates'][number], - ): Promise { - const { sourceMessageId, taskId } = selected; - // Identity stays fail-closed exactly as before; the command state no longer - // routes anything, so this is now a pure assertion. - this.assertCarrierIdentity(auth, sourceMessageId, taskId); - - const subjectKey = `ball:thread:${auth.threadId}`; - const events = await this.deps.ballCustodyEventLog.read(subjectKey); - // The written event keeps its invocation-scoped audit identity... - const eventSourceId = holdDispositionEventSourceId({ - invocationId: auth.invocationId, - sourceMessageId, - taskId, - }); - // ...but a wake's identity is (catId, sourceMessageId, taskId), NOT the - // invocation that happened to settle it. Sol R2 P1: production writes the - // custody event before settling the F264 receipt, so a receipt failure makes - // Queue re-expose the same wake to a successor invocation. Looking the prior - // up by the invocation-scoped sourceEventId made that successor blind to the - // existing terminal — it either could not settle the carrier at all, or wrote - // a second terminal for one wake. - const prior = findWakeTerminal(events, { catId: auth.catId, sourceMessageId, taskId }); - if (prior) { - const canonical = this.assertReplayableTerminal(prior, auth, sourceMessageId, taskId, disposition); - await this.repairProjectionIfNeeded(subjectKey, events, prior); - await this.completeReceipt(auth, sourceMessageId, taskId); - return { - outcome: 'replayed', - disposition: canonical, - invocationId: auth.invocationId, - sourceMessageId, - taskId, - retired: prior.payload.retired === true, - }; - } - - // clowder-ai#1366: a late or superseded wake still needs a deterministic - // terminal. Refusing it (the old behaviour) left no `ball.hold_dispositioned` - // for the stop gate to recognize, so the same wake was reinjected forever. - const supersession = classifyManagedHoldWake(events, { - catId: auth.catId, - sourceMessageId, - taskId, - }); - if (supersession.kind === 'wake_missing') { - throw new ManagedHoldDispositionError('managed_hold_disposition_wake_missing'); - } - // `retired` is derived ONLY from custody supersession — never from the command - // carrier. A terminal carrier explains why a callback arrived late; it does not - // mean something else took the ball. Sol REQUEST_CHANGES P1: deriving it from - // the carrier made a still-live wake write a subject-inert terminal, so the - // Queue receipt closed while `ball:thread:X` leaked in `active` forever. - const retired = supersession.kind === 'superseded'; - // Retired terminals are inert on the subject plane, so they need not own the - // ball. Any disposition that DOES advance the subject still must. - if (!retired) await this.assertCurrentHolder(subjectKey, auth.catId); - - await this.assertLatestInvocation(auth.invocationId); - await recordManagedHoldDisposition( - this.deps, - buildHoldDispositionEvent({ - threadId: auth.threadId, - catId: auth.catId, - invocationId: auth.invocationId, - sourceMessageId, - taskId, - disposition, - retired, - at: this.now(), - }), - events.length, - ); - const committed = (await this.deps.ballCustodyEventLog.read(subjectKey)).find( - (event) => event.sourceEventId === eventSourceId, - ); - this.assertMatchingDispositionEvent(committed, auth, sourceMessageId, taskId, disposition); - // Consume F264 only after the append-only custody truth is durable. If the - // receipt write fails, replay repairs it from the exact event; the inverse - // ordering could delete the only Queue carrier before any terminal event exists. - await this.completeReceipt(auth, sourceMessageId, taskId); - return { outcome: 'applied', disposition, invocationId: auth.invocationId, sourceMessageId, taskId, retired }; - } - - private async assertLatestInvocation(invocationId: string): Promise { - if (!(await this.deps.registry.isLatest(invocationId))) { - throw new ManagedHoldDispositionError('managed_hold_disposition_stale_invocation'); - } - } - - async describe(auth: ManagedHoldDispositionAuth) { - const adopted = turnCustodyAdoptionRegistry.snapshot(auth.invocationId); - await this.assertLatestInvocation(auth.invocationId); - return selectManagedHoldSource(this.deps, auth, adopted, this.now()); - } - - /** - * Identity is fail-closed exactly as before. - * - * What changed for clowder-ai#1366 is only that the command *state* no longer - * hard-rejects: a task that already left the active delivery states (or whose - * row is gone) is a late callback, not a forged one, so it may still reach a - * terminal. Whether that terminal advances the subject is decided solely by - * custody supersession — never here. - */ - private assertCarrierIdentity(auth: ManagedHoldDispositionAuth, sourceMessageId: string, taskId: string): void { - const parsed = parseManagedCommandWakeTask(this.deps.dynamicTaskStore.getById(taskId)); - // The message-side identity (connector, wakeWhen, taskId, thread, cat) was - // already verified in resolveSource, so a missing row is a late callback, - // not an unverified caller. - if (!parsed) return; - if ( - parsed.task.id !== taskId || - parsed.threadId !== auth.threadId || - parsed.catId !== auth.catId || - parsed.userId !== auth.userId || - parsed.command.messageId !== sourceMessageId - ) { - throw new ManagedHoldDispositionError('managed_hold_disposition_task_mismatch'); - } - } - - private async assertCurrentHolder(subjectKey: string, catId: string): Promise { - const projection = await this.deps.ballCustodyProjectionStore.get(subjectKey); - if ((projection?.state !== 'active' && projection?.state !== 'blocked') || projection.holder !== catId) { - throw new ManagedHoldDispositionError('managed_hold_disposition_holder_mismatch'); - } - } - - /** - * Validate an existing terminal for this exact wake and return its canonical - * disposition. - * - * Sol R4 P1: `handled` vs `completed` is the model's free choice each turn, and - * a successor invocation cannot know what its predecessor picked. Demanding the - * same value made the current F264 exposure permanently unsettleable, so the - * carrier kept recovering — the exact opposite of "already-dispositioned is - * idempotent". - * - * The recorded terminal stays authoritative: it is never rewritten and no second - * event is appended. Within ONE invocation a conflicting value is still a caller - * bug, so that case keeps failing closed and preserves concurrent linearization. - */ - private assertReplayableTerminal( - prior: BallCustodyEvent, - auth: ManagedHoldDispositionAuth, - sourceMessageId: string, - taskId: string, - requested: ManagedHoldDisposition, - ): ManagedHoldDisposition { - const recorded = prior.payload.disposition; - if ( - prior.kind !== 'ball.hold_dispositioned' || - prior.payload.catId !== auth.catId || - prior.payload.sourceMessageId !== sourceMessageId || - prior.payload.taskId !== taskId || - (recorded !== 'handled' && recorded !== 'completed') - ) { - throw new ManagedHoldDispositionError('managed_hold_disposition_replay_mismatch'); - } - if (prior.payload.invocationId === auth.invocationId && recorded !== requested) { - throw new ManagedHoldDispositionError('managed_hold_disposition_replay_mismatch'); - } - return recorded; - } - - private assertMatchingDispositionEvent( - event: BallCustodyEvent | undefined, - auth: ManagedHoldDispositionAuth, - sourceMessageId: string, - taskId: string, - disposition: ManagedHoldDisposition, - ): void { - if ( - !event || - event.kind !== 'ball.hold_dispositioned' || - event.payload.catId !== auth.catId || - event.payload.disposition !== disposition || - event.payload.sourceMessageId !== sourceMessageId || - event.payload.taskId !== taskId - ) { - throw new ManagedHoldDispositionError('managed_hold_disposition_replay_mismatch'); - } - } - - private async repairProjectionIfNeeded( - subjectKey: string, - events: readonly BallCustodyEvent[], - dispositionEvent: BallCustodyEvent, - ): Promise { - if (!this.deps.repairProjection) return; - const dispositionIndex = events.findIndex((event) => event.sourceEventId === dispositionEvent.sourceEventId); - const reopenedAfterDisposition = events - .slice(dispositionIndex + 1) - .some((event) => event.kind === 'ball.handed' || event.kind === 'ball.held'); - const projection = await this.deps.ballCustodyProjectionStore.get(subjectKey); - if (!reopenedAfterDisposition && projection?.state !== 'resolved') { - await this.deps.repairProjection(subjectKey); - } - } - - private async completeReceipt( - auth: Pick, - sourceMessageId: string, - taskId: string, - ): Promise { - try { - await this.deps.receiptService.complete({ - threadId: auth.threadId, - userId: auth.userId, - catId: auth.catId as CatId, - invocationId: auth.invocationId, - sourceMessageId, - taskId, - handledAt: this.now(), - }); - } catch (error) { - if (error instanceof ManagedHoldReceiptError) { - throw new ManagedHoldDispositionError(error.code); - } - throw error; - } - } -} diff --git a/packages/api/src/domains/ball-custody/ManagedHoldReceiptService.ts b/packages/api/src/domains/ball-custody/ManagedHoldReceiptService.ts deleted file mode 100644 index 79db22769e..0000000000 --- a/packages/api/src/domains/ball-custody/ManagedHoldReceiptService.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { CatId, QueueTargetOutcome } from '@cat-cafe/shared'; -import type { InvocationQueue, QueueEntry } from '../cats/services/agents/invocation/InvocationQueue.js'; -import type { QueuedMessageCustodyCoordinator } from '../cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; - -export interface ManagedHoldReceiptInput { - readonly threadId: string; - readonly userId: string; - readonly catId: string; - readonly invocationId: string; - readonly sourceMessageId: string; - readonly taskId: string; - readonly handledAt: number; -} - -export interface ManagedHoldReceiptResult { - readonly outcome: 'applied' | 'replayed'; - readonly entryId: string; -} - -export class ManagedHoldReceiptError extends Error { - constructor(readonly code: string) { - super(code); - this.name = 'ManagedHoldReceiptError'; - } -} - -interface ManagedHoldReceiptDeps { - readonly queue: Pick; - readonly messageStore: Pick; - readonly coordinator: Pick; - readonly now?: () => number; - readonly onSettled?: (input: ManagedHoldReceiptInput & { entryId: string }) => void | Promise; -} - -function entryMessageIds(entry: Pick): string[] { - return [entry.messageId ?? '', ...entry.mergedMessageIds].filter(Boolean); -} - -function exactOutcomeMatches( - outcome: QueueTargetOutcome | undefined, - invocationId: string, -): outcome is QueueTargetOutcome { - return ( - outcome?.invocationId === invocationId && - outcome.evidenceRef.kind === 'invocation_lineage' && - outcome.evidenceRef.invocationId === invocationId - ); -} - -/** - * F264 adapter for one managed-hold Queue carrier. - * - * It consumes only the exact source/target exposed to this invocation. No - * thread-wide "mark everything seen as handled" widening is permitted. - */ -export class ManagedHoldReceiptService { - private readonly now: () => number; - - constructor(private readonly deps: ManagedHoldReceiptDeps) { - this.now = deps.now ?? Date.now; - } - - async complete(input: ManagedHoldReceiptInput): Promise { - const message = await this.deps.messageStore.getById(input.sourceMessageId); - const custody = message?.queueCustody; - if (!message || message.threadId !== input.threadId || !custody) { - throw new ManagedHoldReceiptError('managed_hold_receipt_missing'); - } - - const sourceTaskId = message.source?.meta?.taskId; - if (message.source?.connector !== 'hold-ball' || sourceTaskId !== input.taskId) { - throw new ManagedHoldReceiptError('managed_hold_receipt_source_mismatch'); - } - - const existingOutcome = custody.targetOutcomeByCatId?.[input.catId]; - if (custody.handledByCatIds.includes(input.catId as CatId)) { - if (!exactOutcomeMatches(existingOutcome, input.invocationId)) { - throw new ManagedHoldReceiptError('managed_hold_receipt_replay_mismatch'); - } - await this.removeResidualCarrier(custody.entryId, input.userId, input); - return { outcome: 'replayed', entryId: custody.entryId }; - } - - const exposure = custody.bodyExposures?.find( - (candidate) => candidate.targetCatId === input.catId && candidate.invocationId === input.invocationId, - ); - if ( - !custody.pendingTargetCats.includes(input.catId as CatId) || - custody.seenInvocationIdByCatId[input.catId] !== input.invocationId || - !exposure - ) { - throw new ManagedHoldReceiptError('managed_hold_receipt_invocation_mismatch'); - } - - const entry = this.deps.queue.getEntrySnapshot(input.threadId, input.userId, custody.entryId); - this.assertExactCarrier(entry, input); - - const handledAt = Math.max(input.handledAt, exposure.seenAt + 1, this.now()); - const outcome: QueueTargetOutcome = { - invocationId: input.invocationId, - disposition: 'managed_hold_disposition', - evidenceRef: { kind: 'invocation_lineage', invocationId: input.invocationId }, - handledAt, - }; - const completion = await this.deps.coordinator.commitSuccessfulTargetForMessage( - custody.entryId, - input.sourceMessageId, - input.catId, - input.invocationId, - handledAt, - outcome, - () => true, - ); - if (!completion.handledTargetCats.includes(input.catId as CatId)) { - const after = await this.deps.messageStore.getById(input.sourceMessageId); - if (!exactOutcomeMatches(after?.queueCustody?.targetOutcomeByCatId?.[input.catId], input.invocationId)) { - throw new ManagedHoldReceiptError('managed_hold_receipt_commit_rejected'); - } - } - - if (!entry || !this.deps.queue.removeEntrySnapshotIfUnchanged(entry)) { - throw new ManagedHoldReceiptError('managed_hold_receipt_carrier_changed'); - } - await this.deps.onSettled?.({ ...input, entryId: custody.entryId, handledAt }); - return { outcome: 'applied', entryId: custody.entryId }; - } - - private assertExactCarrier(entry: QueueEntry | null, input: ManagedHoldReceiptInput): asserts entry is QueueEntry { - if ( - !entry || - (entry.status !== 'processing' && entry.status !== 'queued') || - entry.threadId !== input.threadId || - entry.source !== 'connector' || - entry.sourceCategory !== 'scheduled' || - !entry.targetCats.includes(input.catId as CatId) || - entry.queuedSeenInvocationIdByCatId?.[input.catId] !== input.invocationId || - entryMessageIds(entry).length !== 1 || - entryMessageIds(entry)[0] !== input.sourceMessageId - ) { - throw new ManagedHoldReceiptError('managed_hold_receipt_carrier_mismatch'); - } - } - - private async removeResidualCarrier( - entryId: string, - messageUserId: string, - input: ManagedHoldReceiptInput, - ): Promise { - const entry = this.deps.queue.getEntrySnapshot(input.threadId, messageUserId, entryId); - if (!entry) return; - this.assertExactCarrier(entry, input); - if (!this.deps.queue.removeEntrySnapshotIfUnchanged(entry)) { - throw new ManagedHoldReceiptError('managed_hold_receipt_carrier_changed'); - } - } -} diff --git a/packages/api/src/domains/ball-custody/ManagedHoldSourceSelection.ts b/packages/api/src/domains/ball-custody/ManagedHoldSourceSelection.ts deleted file mode 100644 index 116281f32b..0000000000 --- a/packages/api/src/domains/ball-custody/ManagedHoldSourceSelection.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { BallCustodyEvent } from '@cat-cafe/shared'; -import type { InvocationRecord } from '../cats/services/agents/invocation/InvocationRegistry.js'; -import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; -import { isOwnerVisibleManagedHoldConnector } from '../cats/services/stores/visibility.js'; -import type { IBallCustodyEventLog } from './BallCustodyEventLog.js'; -import { classifyManagedHoldWake, findWakeTerminal } from './managed-hold-supersession.js'; -import type { AdoptedManagedHold } from './TurnCustodyAdoptionRegistry.js'; - -export type ManagedHoldDispositionAuth = Pick< - InvocationRecord, - 'invocationId' | 'userId' | 'catId' | 'threadId' | 'originTriggerMessageId' ->; - -export class ManagedHoldDispositionError extends Error { - constructor(readonly code: string) { - super(code); - this.name = 'ManagedHoldDispositionError'; - } -} - -interface HoldSource { - readonly sourceMessageId: string; - readonly taskId: string; -} - -export interface ManagedHoldGuidance { - readonly state: 'single_canonical_pending' | 'ambiguous_multiple_pending' | 'no_obligation'; - readonly candidates: readonly HoldSource[]; - readonly instruction: string; -} - -interface Candidate extends HoldSource { - readonly primary: boolean; - readonly prior?: BallCustodyEvent; - readonly retired: boolean; - readonly withdrawn: boolean; - readonly receiptHandled: boolean; -} - -function classifyCandidate( - source: StoredMessage, - isPrimary: boolean, - auth: ManagedHoldDispositionAuth, - events: readonly BallCustodyEvent[], -): Candidate { - const sourceIdentity = identity(source, auth); - const wakeIdentity = { ...sourceIdentity, catId: auth.catId }; - const prior = findWakeTerminal(events, wakeIdentity); - const supersession = classifyManagedHoldWake(events, wakeIdentity); - if (!prior && supersession.kind === 'wake_missing') { - throw new ManagedHoldDispositionError('managed_hold_disposition_wake_missing'); - } - return { - ...sourceIdentity, - primary: isPrimary, - ...(prior ? { prior } : {}), - retired: supersession.kind === 'superseded', - withdrawn: - source.deliveryStatus === 'canceled' || - source.deletedAt !== undefined || - source._tombstone === true || - source.queueCustody?.withdrawnByCatIds?.includes(auth.catId) === true, - receiptHandled: source.queueCustody?.handledByCatIds.includes(auth.catId) === true, - }; -} - -function identity(source: StoredMessage | null, auth: ManagedHoldDispositionAuth): HoldSource { - const meta = source?.source?.meta; - if ( - !source || - source.source?.connector !== 'hold-ball' || - meta?.wakeWhen !== true || - typeof meta.taskId !== 'string' || - !meta.taskId || - source.threadId !== auth.threadId || - meta.threadId !== auth.threadId || - meta.catId !== auth.catId - ) - throw new ManagedHoldDispositionError('managed_hold_disposition_source_mismatch'); - return { sourceMessageId: source.id, taskId: meta.taskId }; -} - -function verifyAdoptedSource( - source: StoredMessage, - wake: AdoptedManagedHold, - auth: ManagedHoldDispositionAuth, - now: number, -): void { - const custody = source.queueCustody; - const exposure = custody?.bodyExposures?.find( - (item) => item.targetCatId === auth.catId && item.invocationId === auth.invocationId, - ); - // Completion retires the live seen binding; its exact durable outcome retains - // which child handled the exposure. It must not poison a later read or replay. - const completedByInvocation = - custody?.handledByCatIds.includes(auth.catId) === true && - !custody.pendingTargetCats.includes(auth.catId) && - custody.targetOutcomeByCatId?.[auth.catId]?.invocationId === auth.invocationId; - if ( - wake.taskId !== source.source?.meta?.taskId || - wake.holderCatId !== auth.catId || - wake.subjectKey !== `ball:thread:${auth.threadId}` || - !isOwnerVisibleManagedHoldConnector(source, auth.userId) || - !exposure || - !Number.isFinite(exposure.seenAt) || - exposure.seenAt > now || - (!completedByInvocation && custody?.seenInvocationIdByCatId[auth.catId] !== auth.invocationId) - ) - throw new ManagedHoldDispositionError('managed_hold_disposition_adopted_source_mismatch'); -} - -/** Select within a call-entry snapshot; neither Queue recency nor generic task state grants authority. */ -export async function selectManagedHoldSource( - deps: { - messageStore: Pick; - ballCustodyEventLog: Pick; - }, - auth: ManagedHoldDispositionAuth, - adopted: readonly AdoptedManagedHold[], - now: number, -): Promise { - const origin = auth.originTriggerMessageId ? await deps.messageStore.getById(auth.originTriggerMessageId) : null; - const primary = origin?.source?.connector === 'hold-ball' ? identity(origin, auth) : undefined; - const refs = new Map(adopted.map((wake) => [wake.sourceMessageId, wake])); - if (primary) refs.delete(primary.sourceMessageId); - const sources: Array<{ source: StoredMessage; primary: boolean }> = []; - if (origin && primary) sources.push({ source: origin, primary: true }); - for (const wake of refs.values()) { - const source = await deps.messageStore.getById(wake.sourceMessageId); - identity(source, auth); - if (!source) throw new ManagedHoldDispositionError('managed_hold_disposition_source_missing'); - verifyAdoptedSource(source, wake, auth, now); - sources.push({ source, primary: false }); - } - const events = await deps.ballCustodyEventLog.read(`ball:thread:${auth.threadId}`); - const candidates = sources.map(({ source, primary }) => classifyCandidate(source, primary, auth, events)); - const available = candidates.filter((candidate) => !candidate.withdrawn); - const repairs = available.filter((candidate) => candidate.prior && !candidate.receiptHandled); - const pending = - repairs.length > 0 - ? repairs - : available.filter((candidate) => !candidate.prior && !candidate.retired && !candidate.receiptHandled); - // Preserve exact original-trigger retirement and a single adopted source's idempotent replay. - const exactReplay = - available.find((candidate) => candidate.primary) ?? - (available.length === 1 && available[0]?.prior ? available[0] : undefined); - const selected = pending.length > 0 ? pending : exactReplay ? [exactReplay] : []; - const state = - selected.length === 1 - ? 'single_canonical_pending' - : selected.length > 1 - ? 'ambiguous_multiple_pending' - : 'no_obligation'; - return { - state, - candidates: selected.map(({ sourceMessageId, taskId }) => ({ sourceMessageId, taskId })), - instruction: - state === 'single_canonical_pending' - ? '本轮已接入这条持球通知。工作已完成时调用 cat_cafe_complete_managed_hold,仅填写 disposition=handled 或 completed;未完成则用已授权的 hold_ball、已注册的事件回调或结构化传球。命令退出、阅读或测试通过不代替通知结算。' - : state === 'ambiguous_multiple_pending' - ? '多条待结算通知无法唯一绑定;不要猜选或批量完成。按现有持球/传球或用户撤回使候选收敛,再完整读取确认;失败时保留来源并报告。' - : '当前没有待显式结算的持球通知;不要把这个结果作为其他任务完成的证明。', - }; -} diff --git a/packages/api/src/domains/ball-custody/RetiredManagedCommandTerminalRecovery.ts b/packages/api/src/domains/ball-custody/RetiredManagedCommandTerminalRecovery.ts index 40cb890e56..9a1a7f07b1 100644 --- a/packages/api/src/domains/ball-custody/RetiredManagedCommandTerminalRecovery.ts +++ b/packages/api/src/domains/ball-custody/RetiredManagedCommandTerminalRecovery.ts @@ -156,12 +156,12 @@ async function publish( if (!claimed?.command.wakeContent) return false; const idempotencyKey = `${COMPLETION_MESSAGE_KEY_PREFIX}${claimed.task.id}`; try { - const existing = await deps.messageStore.getByIdempotencyKey('scheduler', claimed.threadId, idempotencyKey); + const existing = await deps.messageStore.getByIdempotencyKey(claimed.userId, claimed.threadId, idempotencyKey); const stored = existing ?? (await deps.messageStore.append({ - userId: 'scheduler', - catId: null, + from: { kind: 'system', service: 'managed-command-terminal-recovery' }, + userId: claimed.userId, content: `[定时任务] ${claimed.command.wakeContent}`, mentions: [], timestamp: now(), @@ -191,7 +191,11 @@ async function publish( } catch (err) { let committedAfterError: StoredWakeMessage | null; try { - committedAfterError = await deps.messageStore.getByIdempotencyKey('scheduler', claimed.threadId, idempotencyKey); + committedAfterError = await deps.messageStore.getByIdempotencyKey( + claimed.userId, + claimed.threadId, + idempotencyKey, + ); } catch (lookupErr) { log.warn( { err: lookupErr, taskId: claimed.task.id, threadId: claimed.threadId }, diff --git a/packages/api/src/domains/ball-custody/TurnCustodyAdoptionRegistry.ts b/packages/api/src/domains/ball-custody/TurnCustodyAdoptionRegistry.ts deleted file mode 100644 index cbe27c1123..0000000000 --- a/packages/api/src/domains/ball-custody/TurnCustodyAdoptionRegistry.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { TurnCustodyWakeProvenance } from './TurnCustodyProjectionService.js'; - -type AdoptionCommit = () => void; -type AdoptionHandler = (wakes: readonly TurnCustodyWakeProvenance[]) => Promise; -export type AdoptedManagedHold = Extract; - -export interface TurnCustodyAdoptionReservation { - /** Publish the already-prepared route-local adoption. This boundary must be synchronous and infallible. */ - commit(): void; - /** Release the route owner without publishing an adoption when durable exposure did not commit. */ - abort(): void; -} - -interface AdoptionEntry { - readonly handler: AdoptionHandler; - accepting: boolean; - tail: Promise; - readonly wakes: Map; -} - -/** - * Process-local bridge from an invocation-authenticated tool read back to the - * route generator that owns the same child. Queue custody remains durable; the - * bridge only ensures the stop-gate baseline is opened before the tool returns - * the newly exposed body to the provider. - */ -export class TurnCustodyAdoptionRegistry { - private readonly handlers = new Map(); - - register(invocationId: string, handler: AdoptionHandler): () => Promise { - if (!invocationId || this.handlers.has(invocationId)) { - throw new Error(`turn custody adoption handler already registered for ${invocationId || ''}`); - } - const entry: AdoptionEntry = { handler, accepting: true, tail: Promise.resolve(), wakes: new Map() }; - this.handlers.set(invocationId, entry); - return async () => { - if (!entry.accepting) { - await entry.tail; - return; - } - // Refuse new reservations immediately, but keep discovery alive until every - // already-admitted transaction either commits or aborts. - entry.accepting = false; - await entry.tail; - if (this.handlers.get(invocationId) === entry) this.handlers.delete(invocationId); - }; - } - - /** - * Prepare every fallible route-local adoption before append-only Queue exposure - * is written. The returned commit holds the registry owner through route teardown, - * so a terminal race cannot turn a successful exposure into a later 409. - */ - async prepare( - invocationId: string, - wakes: readonly TurnCustodyWakeProvenance[], - ): Promise { - if (wakes.length === 0) return { commit() {}, abort() {} }; - const entry = this.handlers.get(invocationId); - if (!entry?.accepting) return null; - - const predecessor = entry.tail.catch(() => undefined); - let releaseExclusive: () => void = () => undefined; - const exclusive = new Promise((resolve) => { - releaseExclusive = resolve; - }); - entry.tail = predecessor.then(() => exclusive); - await predecessor; - - if (this.handlers.get(invocationId) !== entry) { - releaseExclusive(); - return null; - } - - let publish: void | AdoptionCommit; - try { - publish = await entry.handler(wakes); - } catch (error) { - releaseExclusive(); - throw error; - } - - let settled = false; - const settle = (): boolean => { - if (settled) return false; - settled = true; - releaseExclusive(); - return true; - }; - return { - commit: () => { - if (settled) return; - try { - // Existing handlers adopted eagerly and therefore return void (some - // JavaScript callers incidentally return Array#push's number). Only - // the new prepared form supplies a deferred publication closure. - if (typeof publish === 'function') publish(); - for (const wake of wakes) { - if (wake.kind === 'structured' && wake.protocol === 'hold') { - entry.wakes.set(wake.sourceMessageId, { ...wake }); - } - } - } finally { - settle(); - } - }, - abort: () => { - settle(); - }, - }; - } - - async adopt(invocationId: string, wakes: readonly TurnCustodyWakeProvenance[]): Promise { - const reservation = await this.prepare(invocationId, wakes); - if (!reservation) return false; - reservation.commit(); - return true; - } - - /** Discovery only: the disposition owner must recheck durable identity and exposure. */ - snapshot(invocationId: string): readonly AdoptedManagedHold[] { - const entry = this.handlers.get(invocationId); - return entry ? [...entry.wakes.values()].map((wake) => ({ ...wake })) : []; - } - - resetForTest(): void { - for (const entry of this.handlers.values()) entry.accepting = false; - this.handlers.clear(); - } -} - -export const turnCustodyAdoptionRegistry = new TurnCustodyAdoptionRegistry(); diff --git a/packages/api/src/domains/ball-custody/TurnCustodyProjectionService.ts b/packages/api/src/domains/ball-custody/TurnCustodyProjectionService.ts index c4f995c1dc..6dd6cb991d 100644 --- a/packages/api/src/domains/ball-custody/TurnCustodyProjectionService.ts +++ b/packages/api/src/domains/ball-custody/TurnCustodyProjectionService.ts @@ -1,20 +1,9 @@ -import type { BallCustodyEvent } from '@cat-cafe/shared'; import type { ActionSuccessorLeaseStore } from './ActionSuccessorLeaseStore.js'; import type { ActionSuccessorLease } from './action-successor-state-machine.js'; -import type { IBallCustodyEventLog } from './BallCustodyEventLog.js'; -import type { IBallCustodyProjectionStore } from './BallCustodyProjectionStore.js'; -import { - exactStructuredWakeIndex, - findWakeTerminal, - releasedStructuredWake, - supersededBeforeAdoption, -} from './managed-hold-supersession.js'; // Public vocabulary re-exported so the seven existing importers stay untouched. export type { ActionTransitionBaseline, - StructuredTransitionBaseline, - StructuredTransitionObservation, TurnCustodyProjection, TurnCustodyProjectionState, TurnCustodyShadowComparison, @@ -25,8 +14,6 @@ export type { // Only what this file's body actually references. import type { ActionTransitionBaseline, - StructuredTransitionBaseline, - StructuredTransitionObservation, TurnCustodyProjection, TurnCustodyShadowComparison, TurnCustodyStopDecision, @@ -35,8 +22,6 @@ import type { interface TurnCustodyProjectionDeps { readonly actionSuccessorLeaseStore?: Pick; - readonly ballCustodyProjectionStore?: Pick; - readonly ballCustodyEventLog?: Pick; } function candidateFingerprint(lease: ActionSuccessorLease, holderCatId: string): unknown { @@ -57,31 +42,6 @@ function actionTransitionFingerprint(lease: ActionSuccessorLease, holderCatId: s }); } -function dispatchTransitionObservation( - event: BallCustodyEvent, - baseline: StructuredTransitionBaseline, -): StructuredTransitionObservation | undefined { - if ( - baseline.protocol !== 'dispatch' || - event.payload.catId !== baseline.holderCatId || - event.payload.sourceMessageId !== baseline.dispatchSourceMessageId || - event.payload.fromCatId !== baseline.dispatchFromCatId - ) { - return undefined; - } - const disposition = event.payload.disposition; - return { - structuredTransitionKind: 'dispatch_dispositioned', - ...(disposition === 'handled' || disposition === 'completed' - ? { - dispatchDisposition: disposition, - dispatchDispositionEventId: event.sourceEventId, - dispatchDispositionAt: event.at, - } - : {}), - }; -} - function unknown(reason: string): TurnCustodyProjection { return { state: 'unknown_legacy', evidenceRefs: [`unknown:${reason}`] }; } @@ -90,13 +50,11 @@ function decision( projection: TurnCustodyProjection, transitionObserved: boolean, state = projection.state, - structuredTransition?: StructuredTransitionObservation, ): TurnCustodyStopDecision { return { state, shouldBlock: state === 'unknown_legacy' || (state === 'covered_active' && !transitionObserved), transitionObserved, - ...structuredTransition, evidenceRefs: [...projection.evidenceRefs], }; } @@ -114,25 +72,21 @@ export class TurnCustodyProjectionService { if (wake.kind === 'unstructured' || wake.kind === 'non_obligation') { return { state: 'covered_empty', evidenceRefs: [`wake:${wake.source}`] }; } + if (wake.kind === 'structured') { + return { state: 'covered_empty', evidenceRefs: [`lifecycle:${wake.protocol}`] }; + } if (wake.kind === 'legacy') return unknown(wake.reason); try { - return wake.kind === 'action_successor' ? await this.openAction(wake) : await this.openStructured(wake); + return await this.openAction(wake); } catch { return unknown('query_failed'); } } async close(projection: TurnCustodyProjection): Promise { - // Blocking state and transition observation are orthogonal. A managed wake - // superseded before adoption is non-blocking, but the exact adopting turn - // must still observe a later re-hold/handoff for its predecessor receipt. if (!projection.baseline) return decision(projection, false); try { - if (projection.baseline.kind === 'action_successor') { - return decision(projection, await this.actionTransitionObserved(projection.baseline)); - } - const structuredTransition = await this.structuredTransitionObserved(projection.baseline); - return decision(projection, structuredTransition !== undefined, projection.state, structuredTransition); + return decision(projection, await this.actionTransitionObserved(projection.baseline)); } catch { const evidenceRefs = [...projection.evidenceRefs, 'unknown:query_failed']; // A covered-empty projection already proved that the predecessor is not @@ -167,135 +121,9 @@ export class TurnCustodyProjectionService { }; } - private async openStructured( - wake: Extract, - ): Promise { - if (!this.deps.ballCustodyProjectionStore || !this.deps.ballCustodyEventLog) { - return unknown('structured_store_unavailable'); - } - const [projection, events] = await Promise.all([ - this.deps.ballCustodyProjectionStore.get(wake.subjectKey), - this.deps.ballCustodyEventLog.read(wake.subjectKey), - ]); - // Sol R3 P1: a non-retired terminal drives the subject to `resolved`. If the - // F264 receipt then fails and Queue re-exposes the same wake, the successor - // route would bail to unknown_legacy here and write - // `managed_hold_disposition_missing` even though the wake is already settled. - // Per-wake terminal truth is checked before subject-level state. - if (wake.protocol === 'hold') { - const settled = findWakeTerminal(events, { - catId: wake.holderCatId, - sourceMessageId: wake.sourceMessageId, - taskId: wake.taskId, - }); - if (settled) { - return { - state: 'covered_empty', - evidenceRefs: [`${wake.protocol}:${wake.subjectKey}`, `settled:${settled.sourceEventId}`], - }; - } - } - if (projection?.state !== 'active' && projection?.state !== 'blocked') { - return unknown('structured_projection_missing'); - } - const exactWakeIndex = exactStructuredWakeIndex(wake, events); - if (wake.protocol === 'dispatch' && exactWakeIndex === -1) { - return unknown('dispatch_handoff_missing'); - } - if (projection.holder !== wake.holderCatId) { - return releasedStructuredWake(wake, events, exactWakeIndex) ?? unknown('structured_holder_mismatch'); - } - // clowder-ai#1366: a wake superseded before this turn adopted it is not a - // live obligation; treating it as one blocked unrelated healthy turns. - if (wake.protocol === 'hold') { - const superseded = supersededBeforeAdoption( - events, - { catId: wake.holderCatId, sourceMessageId: wake.sourceMessageId, taskId: wake.taskId }, - wake.subjectKey, - ); - if (superseded) { - return { - ...this.coveredActiveProjection(wake, events), - state: 'covered_empty', - evidenceRefs: [...superseded], - }; - } - } - return this.coveredActiveProjection(wake, events); - } - - /** Live obligation plus the exact baseline `close()` will diff its transition against. */ - private coveredActiveProjection( - wake: Extract, - events: readonly BallCustodyEvent[], - ): TurnCustodyProjection { - return { - state: 'covered_active', - evidenceRefs: [ - `${wake.protocol}:${wake.subjectKey}`, - ...(wake.protocol === 'event_wait' - ? [ - `wait:${wake.waitContinuationCarrier.waitId}:${wake.waitContinuationCarrier.outcomeId}:g${wake.waitContinuationCarrier.ownerFence.generation}`, - ] - : []), - ...(wake.protocol === 'dispatch' ? [wake.handoff.sourceEventId] : []), - ], - baseline: { - kind: 'structured', - subjectKey: wake.subjectKey, - holderCatId: wake.holderCatId, - fromSequence: events.length, - protocol: wake.protocol, - ...(wake.protocol === 'hold' ? { sourceMessageId: wake.sourceMessageId, taskId: wake.taskId } : {}), - ...(wake.protocol === 'dispatch' - ? { - dispatchSourceMessageId: wake.handoff.messageId, - dispatchFromCatId: wake.handoff.fromCatId, - } - : {}), - }, - }; - } - private async actionTransitionObserved(baseline: ActionTransitionBaseline): Promise { const lease = await this.deps.actionSuccessorLeaseStore?.get(baseline.leaseId); if (!lease) return false; return actionTransitionFingerprint(lease, baseline.holderCatId) !== baseline.fingerprint; } - - private async structuredTransitionObserved( - baseline: StructuredTransitionBaseline, - ): Promise { - const events = await this.deps.ballCustodyEventLog?.read(baseline.subjectKey, baseline.fromSequence); - for (const event of events ?? []) { - const kind = this.structuredTransitionKind(event, baseline); - if (kind) return kind; - } - return undefined; - } - - private structuredTransitionKind( - event: BallCustodyEvent, - baseline: StructuredTransitionBaseline, - ): StructuredTransitionObservation | undefined { - const holderCatId = baseline.holderCatId; - if (event.kind === 'ball.hold_dispositioned') { - return baseline.protocol === 'hold' && - event.payload.catId === holderCatId && - event.payload.sourceMessageId === baseline.sourceMessageId && - event.payload.taskId === baseline.taskId - ? { structuredTransitionKind: 'hold_dispositioned' } - : undefined; - } - if (event.kind === 'ball.dispatch_dispositioned') { - return dispatchTransitionObservation(event, baseline); - } - if (event.kind === 'ball.handed' || event.kind === 'ball.handed_cvo') { - return event.payload.fromCatId === holderCatId ? { structuredTransitionKind: 'handed' } : undefined; - } - if (event.kind === 'ball.held') { - return event.payload.catId === holderCatId ? { structuredTransitionKind: 'held' } : undefined; - } - return undefined; - } } diff --git a/packages/api/src/domains/ball-custody/TypedWaitCustodyGuard.ts b/packages/api/src/domains/ball-custody/TypedWaitCustodyGuard.ts deleted file mode 100644 index 47d1ca93bc..0000000000 --- a/packages/api/src/domains/ball-custody/TypedWaitCustodyGuard.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ROUTING_EVENT_WAIT_REASON } from '../../infrastructure/telemetry/genai-semconv.js'; -import { - routingEventWaitFalseBypassTotal, - routingEventWaitRejectedTotal, -} from '../../infrastructure/telemetry/instruments.js'; -import type { QueuedMessageCustody, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; -import { - isLiveTypedWaitRegistration, - type TypedWaitContinuationIdentity, - type TypedWaitReference, - type TypedWaitRegistrationSnapshot, -} from './TypedWaitRegistration.js'; - -/** Internal commit dependency, never serialized into a message or public Task. */ -export interface TypedWaitCustodyGuard { - readonly identity: TypedWaitContinuationIdentity; - readonly reference: TypedWaitReference; - readonly readSnapshot: () => TypedWaitRegistrationSnapshot | null | Promise; -} - -/** Count only the final consumer rejection, never a retried Queue revision conflict or a successful replay. */ -export function rejectTypedWaitCustody( - reason: 'proof_invalid' | 'authority_stale' | 'authority_changed' | 'query_failed', - cause?: unknown, -): never { - routingEventWaitFalseBypassTotal.add(1); - routingEventWaitRejectedTotal.add(1, { [ROUTING_EVENT_WAIT_REASON]: reason }); - throw new Error(`typed wait continuation rejected: ${reason}`, { cause }); -} - -export function assertTypedWaitCustodyBindings( - message: StoredMessage, - next: QueuedMessageCustody, - guards: readonly TypedWaitCustodyGuard[] = [], -): void { - for (const [catId, outcome] of Object.entries(next.targetOutcomeByCatId ?? {})) { - const witness = outcome.consumption; - if (witness?.kind !== 'managed_hold_continued' || witness.transition !== 'event_wait') continue; - if (message.queueCustody?.handledByCatIds.some((holder) => holder === catId)) continue; - const guard = guards.find((candidate) => candidate.identity.catId === catId); - if ( - !guard || - !witness.waitRegistration || - guard.identity.invocationId !== outcome.invocationId || - guard.identity.sourceMessageId !== message.id || - witness.sourceMessageId !== message.id || - guard.identity.threadId !== message.threadId || - guard.identity.userId !== message.queueCustody?.ownerUserId || - message.source?.connector !== 'hold-ball' || - message.source.meta?.wakeWhen !== true || - message.source.meta?.threadId !== guard.identity.threadId || - message.source.meta?.catId !== catId || - guard.identity.holdTaskId !== witness.taskId || - witness.taskId !== message.source?.meta?.taskId || - witness.waitRegistration.taskId !== guard.reference.taskId || - witness.waitRegistration.generation !== guard.reference.generation - ) { - rejectTypedWaitCustody('proof_invalid'); - } - } -} - -/** Memory store calls this in the same synchronous section as its Queue mutation. */ -export function assertMemoryTypedWaitCustody(guards: readonly TypedWaitCustodyGuard[] = []): void { - for (const guard of guards) { - let snapshot: ReturnType; - try { - snapshot = guard.readSnapshot(); - } catch (error) { - rejectTypedWaitCustody('query_failed', error); - } - if ( - snapshot instanceof Promise || - !isLiveTypedWaitRegistration(snapshot, guard.identity, Date.now(), guard.reference) - ) { - rejectTypedWaitCustody('authority_stale'); - } - } -} diff --git a/packages/api/src/domains/ball-custody/WaitContinuationRetryCommitter.ts b/packages/api/src/domains/ball-custody/WaitContinuationRetryCommitter.ts deleted file mode 100644 index 044eb136ac..0000000000 --- a/packages/api/src/domains/ball-custody/WaitContinuationRetryCommitter.ts +++ /dev/null @@ -1,318 +0,0 @@ -import type { RedisClient } from '@cat-cafe/shared/utils'; -import type { IMessageStore, QueuedMessageCustody, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; -import { assertQueueCustodyTransition } from '../cats/services/stores/ports/queued-message-custody.js'; -import type { ITaskStore } from '../cats/services/stores/ports/TaskStoreContract.js'; -import { hydrateTask } from '../cats/services/stores/redis/RedisTaskCodec.js'; -import { parseConnectorSourceField } from '../cats/services/stores/redis/redis-message-parsers.js'; -import { MessageKeys } from '../cats/services/stores/redis-keys/message-keys.js'; -import { TaskKeys } from '../cats/services/stores/redis-keys/task-keys.js'; -import type { ActionSuccessorLeaseStore, ActionSuccessorOutputPreflightResult } from './ActionSuccessorLeaseStore.js'; -import { ActionSuccessorKeys } from './action-successor-keys.js'; -import { parseActionSuccessorLease } from './action-successor-redis-codecs.js'; -import type { ActionSuccessorLease } from './action-successor-state-machine.js'; -import { - evaluateWaitRetryAuthoritySnapshot, - type RetryAuthorityDecision, - type RetryAuthorityFailureReason, - resolveRetryAuthorityMessageSubject, - type WaitContinuationRetryPreflightInput, -} from './WaitContinuationRetryPreflight.js'; - -export interface RetryCustodyTransition { - readonly messageId: string; - readonly current: QueuedMessageCustody; - readonly next: QueuedMessageCustody; -} - -export type RetryAuthorityCommitResult = - | { readonly outcome: 'committed' } - | { readonly outcome: 'custody_conflict' | 'unavailable' } - | { readonly outcome: 'authority_stale'; readonly reason: RetryAuthorityFailureReason }; - -export type RetryAuthorityCommit = ( - transitions: readonly RetryCustodyTransition[], -) => Promise; - -interface WaitContinuationRetryCommitterDeps { - readonly messageStore: Pick; - readonly taskStore: Pick; - readonly actionSuccessorLeaseStore?: Pick; - readonly redis?: RedisClient; - /** Deterministic race-test seam immediately before the Redis Lua linearization point. */ - readonly beforeAtomicCommit?: () => void | Promise; -} - -export interface RetryAuthorityCommitInput { - readonly authorityMessageId: string; - readonly requestingUserId: string; - readonly targetCatId: string; - readonly transitions: readonly RetryCustodyTransition[]; -} - -type MaybePromise = T | Promise; - -function isPromiseLike(value: MaybePromise): value is Promise { - return typeof (value as { then?: unknown })?.then === 'function'; -} - -function evaluateActionSuccessorSnapshot( - lease: ActionSuccessorLease | null, - subjectTerminal: boolean, - generation: number, - catId: string, -): ActionSuccessorOutputPreflightResult | { ok: false; reason: 'lease_missing' } { - if (subjectTerminal) return { ok: false, reason: 'subject_terminal' }; - if (!lease) return { ok: false, reason: 'lease_missing' }; - if (lease.generation !== generation) return { ok: false, reason: 'stale_generation' }; - if (!lease.holderCatIds.includes(catId)) return { ok: false, reason: 'holder_not_assigned' }; - const holderOutcome = lease.holderOutcomes[catId]; - if (holderOutcome) { - return holderOutcome.outcome === 'succeeded' - ? { ok: true, reason: 'verified_success' } - : { ok: false, reason: 'holder_terminal' }; - } - if (lease.status !== 'active') return { ok: false, reason: 'lease_not_active' }; - return { ok: true, reason: 'active' }; -} - -function assertRetryCustodyTransitions(transitions: readonly RetryCustodyTransition[]): void { - if (transitions.length === 0) throw new Error('retry custody commit requires at least one transition'); - const messageIds = new Set(); - for (const transition of transitions) { - if (!transition.messageId || messageIds.has(transition.messageId)) { - throw new Error('retry custody commit requires unique non-empty message ids'); - } - messageIds.add(transition.messageId); - assertQueueCustodyTransition(transition.current, { - expectedRevision: transition.current.revision, - next: transition.next, - }); - if ( - JSON.stringify(transition.current.bodyExposures ?? []) !== JSON.stringify(transition.next.bodyExposures ?? []) - ) { - throw new Error('retry custody commit cannot change prompt exposure history'); - } - } -} - -const COMMIT_RETRY_WITH_AUTHORITY_WITNESS_LUA = ` -local function hashFieldEquals(key, field, expected) - return (redis.call('HGET', key, field) or '') == expected -end - -if not hashFieldEquals(KEYS[1], 'id', ARGV[1]) - or not hashFieldEquals(KEYS[1], 'threadId', ARGV[2]) - or not hashFieldEquals(KEYS[1], 'userId', ARGV[3]) - or not hashFieldEquals(KEYS[1], 'catId', ARGV[4]) then - return 'authority_witness_changed' -end -local sourceExists = redis.call('HEXISTS', KEYS[1], 'source') -if sourceExists ~= tonumber(ARGV[6]) - or (sourceExists == 1 and not hashFieldEquals(KEYS[1], 'source', ARGV[5])) then - return 'authority_witness_changed' -end - -if ARGV[7] == 'wait' then - if not hashFieldEquals(KEYS[2], 'id', ARGV[8]) - or not hashFieldEquals(KEYS[2], 'kind', ARGV[9]) - or not hashFieldEquals(KEYS[2], 'threadId', ARGV[10]) - or not hashFieldEquals(KEYS[2], 'userId', ARGV[11]) - or not hashFieldEquals(KEYS[2], 'ownerCatId', ARGV[12]) - or not hashFieldEquals(KEYS[2], 'automationState', ARGV[13]) then - return 'authority_witness_changed' - end - if ARGV[14] ~= '' then - if (redis.call('GET', KEYS[3]) or '') ~= ARGV[14] then return 'authority_witness_changed' end - if (redis.call('GET', KEYS[4]) or '') ~= ARGV[15] then return 'authority_witness_changed' end - end -end - -local transitionCount = tonumber(ARGV[16]) -if not transitionCount or transitionCount < 1 then return redis.error_reply('INVALID_RETRY_TRANSITION_COUNT') end -for index = 1, transitionCount do - local messageKey = KEYS[4 + index] - local expectedRevision = tonumber(ARGV[15 + index * 2]) - local nextRaw = ARGV[16 + index * 2] - local messageId = redis.call('HGET', messageKey, 'id') - local custody = redis.call('HGET', messageKey, 'queueCustody') - local currentRevision = tonumber(redis.call('HGET', messageKey, 'queueCustodyRevision') or '0') - if not messageId or not custody or currentRevision ~= expectedRevision then return 'custody_conflict' end - if redis.call('HGET', messageKey, 'deliveryStatus') ~= 'queued' then return 'custody_conflict' end - local okNext, nextCustody = pcall(cjson.decode, nextRaw) - if not okNext or type(nextCustody) ~= 'table' then return redis.error_reply('INVALID_RETRY_QUEUE_CUSTODY') end - if tonumber(nextCustody.revision) ~= expectedRevision + 1 then return redis.error_reply('INVALID_RETRY_REVISION') end -end - -for index = 1, transitionCount do - local messageKey = KEYS[4 + index] - local nextRaw = ARGV[16 + index * 2] - local nextCustody = cjson.decode(nextRaw) - redis.call('HSET', messageKey, 'queueCustody', nextRaw, 'queueCustodyRevision', tostring(nextCustody.revision)) -end -return 'committed' -`; - -/** - * Commits all custody attempt appends at the same linearization point that - * verifies the canonical Task/ActionSuccessor witness. Redis uses one Lua - * script across the authority and message keys; memory mode uses one - * synchronous critical section with no await between validation and writes. - */ -export class WaitContinuationRetryCommitter { - constructor(private readonly deps: WaitContinuationRetryCommitterDeps) {} - - async commit(input: RetryAuthorityCommitInput): Promise { - assertRetryCustodyTransitions(input.transitions); - return this.deps.redis ? this.commitRedis(input, this.deps.redis) : this.commitMemory(input); - } - - private commitMemory(input: RetryAuthorityCommitInput): RetryAuthorityCommitResult { - const messageResult = this.deps.messageStore.getById(input.authorityMessageId); - if (isPromiseLike(messageResult)) return { outcome: 'unavailable' }; - if (!messageResult) return { outcome: 'authority_stale', reason: 'message_missing' }; - const request: WaitContinuationRetryPreflightInput = { - message: messageResult, - requestingUserId: input.requestingUserId, - targetCatId: input.targetCatId, - }; - const subject = resolveRetryAuthorityMessageSubject(request); - if (!subject.ok) return { outcome: 'authority_stale', reason: subject.reason }; - - let decision: RetryAuthorityDecision; - if (subject.kind === 'user') { - decision = { ok: true, kind: 'user' }; - } else { - const taskResult = this.deps.taskStore.get(subject.carrier.waitId); - if (isPromiseLike(taskResult)) return { outcome: 'unavailable' }; - let lease: ActionSuccessorOutputPreflightResult | { ok: false; reason: 'lease_missing' } | undefined; - if (subject.carrier.ownerFence.kind === 'action_successor') { - const leaseResult = this.deps.actionSuccessorLeaseStore?.preflightOutput( - subject.carrier.ownerFence.leaseId, - subject.carrier.ownerFence.generation, - input.targetCatId, - ); - if (!leaseResult) lease = { ok: false, reason: 'lease_missing' }; - else if (isPromiseLike(leaseResult)) return { outcome: 'unavailable' }; - else lease = leaseResult; - } - decision = evaluateWaitRetryAuthoritySnapshot({ request, carrier: subject.carrier, task: taskResult, lease }); - } - if (!decision.ok) return { outcome: 'authority_stale', reason: decision.reason }; - - for (const transition of input.transitions) { - const currentResult = this.deps.messageStore.getById(transition.messageId); - if (isPromiseLike(currentResult)) return { outcome: 'unavailable' }; - if ( - !currentResult?.queueCustody || - currentResult.deliveryStatus !== 'queued' || - currentResult.queueCustody.revision !== transition.current.revision - ) { - return { outcome: 'custody_conflict' }; - } - } - for (const transition of input.transitions) { - const result = this.deps.messageStore.transitionQueueCustody(transition.messageId, { - expectedRevision: transition.current.revision, - next: transition.next, - }); - if (isPromiseLike(result)) return { outcome: 'unavailable' }; - if (result.kind !== 'updated') return { outcome: 'custody_conflict' }; - } - return { outcome: 'committed' }; - } - - private async commitRedis(input: RetryAuthorityCommitInput, redis: RedisClient): Promise { - const authorityKey = MessageKeys.detail(input.authorityMessageId); - const authorityRaw = await redis.hgetall(authorityKey); - if (!authorityRaw.id) return { outcome: 'authority_stale', reason: 'message_missing' }; - const sourceField = parseConnectorSourceField(authorityRaw.source); - if (sourceField.kind === 'invalid') { - return { outcome: 'authority_stale', reason: 'legacy_unattributed' }; - } - const source = sourceField.kind === 'valid' ? sourceField.source : undefined; - const authorityMessage = { - threadId: authorityRaw.threadId ?? '', - userId: authorityRaw.userId ?? '', - catId: (authorityRaw.catId || null) as StoredMessage['catId'], - ...(source ? { source } : {}), - }; - const request: WaitContinuationRetryPreflightInput = { - message: authorityMessage, - requestingUserId: input.requestingUserId, - targetCatId: input.targetCatId, - }; - const subject = resolveRetryAuthorityMessageSubject(request); - if (!subject.ok) return { outcome: 'authority_stale', reason: subject.reason }; - - let taskKey = authorityKey; - let taskRaw: Record = {}; - let leaseKey = authorityKey; - let leaseRaw = ''; - let terminalKey = authorityKey; - let terminalRaw = ''; - let decision: RetryAuthorityDecision; - if (subject.kind === 'user') { - decision = { ok: true, kind: 'user' }; - } else { - taskKey = TaskKeys.detail(subject.carrier.waitId); - taskRaw = await redis.hgetall(taskKey); - const task = taskRaw.id ? hydrateTask(taskRaw) : null; - let lease: ActionSuccessorOutputPreflightResult | { ok: false; reason: 'lease_missing' } | undefined; - if (subject.carrier.ownerFence.kind === 'action_successor') { - leaseKey = ActionSuccessorKeys.detail(subject.carrier.ownerFence.leaseId); - leaseRaw = (await redis.get(leaseKey)) ?? ''; - const parsedLease = parseActionSuccessorLease(leaseRaw || null); - if (parsedLease) { - terminalKey = ActionSuccessorKeys.subjectTerminal(parsedLease.subjectRef); - terminalRaw = (await redis.get(terminalKey)) ?? ''; - } - lease = evaluateActionSuccessorSnapshot( - parsedLease, - terminalRaw.length > 0, - subject.carrier.ownerFence.generation, - input.targetCatId, - ); - } - decision = evaluateWaitRetryAuthoritySnapshot({ request, carrier: subject.carrier, task, lease }); - } - if (!decision.ok) return { outcome: 'authority_stale', reason: decision.reason }; - - await this.deps.beforeAtomicCommit?.(); - const keys = [ - authorityKey, - taskKey, - leaseKey, - terminalKey, - ...input.transitions.map((transition) => MessageKeys.detail(transition.messageId)), - ]; - const args = [ - authorityRaw.id ?? '', - authorityRaw.threadId ?? '', - authorityRaw.userId ?? '', - authorityRaw.catId ?? '', - authorityRaw.source ?? '', - sourceField.kind === 'absent' ? '0' : '1', - subject.kind, - taskRaw.id ?? '', - taskRaw.kind ?? '', - taskRaw.threadId ?? '', - taskRaw.userId ?? '', - taskRaw.ownerCatId ?? '', - taskRaw.automationState ?? '', - leaseRaw, - terminalRaw, - String(input.transitions.length), - ...input.transitions.flatMap((transition) => [ - String(transition.current.revision), - JSON.stringify(transition.next), - ]), - ]; - const outcome = String(await redis.eval(COMMIT_RETRY_WITH_AUTHORITY_WITNESS_LUA, keys.length, ...keys, ...args)); - if (outcome === 'committed') return { outcome }; - if (outcome === 'custody_conflict') return { outcome }; - if (outcome === 'authority_witness_changed') { - return { outcome: 'authority_stale', reason: 'authority_witness_changed' }; - } - throw new Error(`unexpected retry authority commit outcome: ${outcome}`); - } -} diff --git a/packages/api/src/domains/ball-custody/WaitContinuationRetryPreflight.ts b/packages/api/src/domains/ball-custody/WaitContinuationRetryPreflight.ts deleted file mode 100644 index 8daa6d6ac6..0000000000 --- a/packages/api/src/domains/ball-custody/WaitContinuationRetryPreflight.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - parseWaitContinuationCarrier, - type TaskItem, - type WaitContinuationCarrierV1, - type WaitOwnerFence, -} from '@cat-cafe/shared'; -import type { StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; -import type { ITaskStore } from '../cats/services/stores/ports/TaskStoreContract.js'; -import { isSystemUserMessage } from '../cats/services/stores/visibility.js'; -import type { ActionSuccessorLeaseStore, ActionSuccessorOutputPreflightResult } from './ActionSuccessorLeaseStore.js'; - -export type RetryAuthorityFailureReason = - | 'message_missing' - | 'authority_witness_changed' - | 'legacy_unattributed' - | 'invalid_wait_carrier' - | 'task_missing' - | 'task_identity_mismatch' - | 'outcome_mismatch' - | 'owner_mismatch' - | 'lease_missing' - | 'stale_generation' - | 'lease_not_active' - | 'holder_not_assigned' - | 'holder_terminal' - | 'subject_terminal'; - -export type RetryAuthorityDecision = - | { readonly ok: true; readonly kind: 'user' | 'wait_containing_task' | 'wait_action_successor' } - | { readonly ok: false; readonly reason: RetryAuthorityFailureReason }; - -export type RetryAuthorityMessageSubject = - | { readonly ok: true; readonly kind: 'user' } - | { readonly ok: true; readonly kind: 'wait'; readonly carrier: WaitContinuationCarrierV1 } - | { readonly ok: false; readonly reason: RetryAuthorityFailureReason }; - -export interface WaitContinuationRetryPreflightDeps { - readonly taskStore: Pick; - readonly actionSuccessorLeaseStore?: Pick; -} - -export interface WaitContinuationRetryPreflightInput { - readonly message: Pick; - readonly requestingUserId: string; - readonly targetCatId: string; -} - -function ownerFencesMatch(left: WaitOwnerFence, right: WaitOwnerFence): boolean { - if (left.kind !== right.kind || left.generation !== right.generation) return false; - return left.kind === 'containing_task' || (right.kind === 'action_successor' && left.leaseId === right.leaseId); -} - -export function resolveRetryAuthorityMessageSubject( - input: WaitContinuationRetryPreflightInput, -): RetryAuthorityMessageSubject { - if ( - input.message.catId === null && - !input.message.source && - !input.message.sourceParseFailure && - !isSystemUserMessage(input.message) && - input.message.userId === input.requestingUserId - ) { - return { ok: true, kind: 'user' }; - } - if (input.message.source?.connector !== 'github-wait') { - return { ok: false, reason: 'legacy_unattributed' }; - } - const carrier = parseWaitContinuationCarrier(input.message.source.meta?.waitContinuationCarrier); - if (!carrier) return { ok: false, reason: 'invalid_wait_carrier' }; - return { ok: true, kind: 'wait', carrier }; -} - -export function evaluateWaitRetryAuthoritySnapshot(input: { - readonly request: WaitContinuationRetryPreflightInput; - readonly carrier: WaitContinuationCarrierV1; - readonly task: TaskItem | null; - readonly lease?: ActionSuccessorOutputPreflightResult | { ok: false; reason: 'lease_missing' }; -}): RetryAuthorityDecision { - const { carrier, request, task } = input; - if (!task) return { ok: false, reason: 'task_missing' }; - if ( - (task.kind !== 'pr_tracking' && task.kind !== 'issue_tracking') || - task.threadId !== request.message.threadId || - task.userId !== request.message.userId || - task.userId !== request.requestingUserId - ) { - return { ok: false, reason: 'task_identity_mismatch' }; - } - if (task.ownerCatId !== request.targetCatId) return { ok: false, reason: 'owner_mismatch' }; - - const outcome = task.automationState?.waitOutcome; - if ( - !outcome || - outcome.outcomeId !== carrier.outcomeId || - !ownerFencesMatch(outcome.ownerFence, carrier.ownerFence) || - outcome.reason !== 'matched' || - (outcome.delivery !== 'pending' && outcome.delivery !== 'delivered') - ) { - if (outcome?.reason === 'subject_terminal' || outcome?.terminalSubjectState) { - return { ok: false, reason: 'subject_terminal' }; - } - return { ok: false, reason: 'outcome_mismatch' }; - } - if (carrier.ownerFence.kind === 'containing_task') { - if (outcome.generation !== carrier.ownerFence.generation) { - return { ok: false, reason: 'outcome_mismatch' }; - } - return { ok: true, kind: 'wait_containing_task' }; - } - - const lease = input.lease; - if (!lease) return { ok: false, reason: 'lease_missing' }; - if (lease.ok) { - return lease.reason === 'active' - ? { ok: true, kind: 'wait_action_successor' } - : { ok: false, reason: 'holder_terminal' }; - } - if (lease.reason === 'predicate_mismatch') return { ok: false, reason: 'stale_generation' }; - return { ok: false, reason: lease.reason }; -} - -/** - * Read-only authority check for one manual retry. The decision is transient; - * Queue and Message custody remain unchanged until this method returns ok. - */ -export class WaitContinuationRetryPreflight { - constructor(private readonly deps: WaitContinuationRetryPreflightDeps) {} - - async preflight(input: WaitContinuationRetryPreflightInput): Promise { - const subject = resolveRetryAuthorityMessageSubject(input); - if (!subject.ok) return subject; - if (subject.kind === 'user') return { ok: true, kind: 'user' }; - return this.preflightWait(input, subject.carrier); - } - - private async preflightWait( - input: WaitContinuationRetryPreflightInput, - carrier: WaitContinuationCarrierV1, - ): Promise { - const task = await this.deps.taskStore.get(carrier.waitId); - const lease = - carrier.ownerFence.kind === 'action_successor' - ? await this.deps.actionSuccessorLeaseStore?.preflightOutput( - carrier.ownerFence.leaseId, - carrier.ownerFence.generation, - input.targetCatId, - ) - : undefined; - return evaluateWaitRetryAuthoritySnapshot({ request: input, carrier, task, lease }); - } -} diff --git a/packages/api/src/domains/ball-custody/approved-action-carrier-classification.ts b/packages/api/src/domains/ball-custody/approved-action-carrier-classification.ts index 8ca4ed8354..7e4e3d1621 100644 --- a/packages/api/src/domains/ball-custody/approved-action-carrier-classification.ts +++ b/packages/api/src/domains/ball-custody/approved-action-carrier-classification.ts @@ -1,5 +1,9 @@ import type { DispatchProposal } from '@cat-cafe/shared'; +import type { QueueLedgerEntry } from '../cats/services/agents/invocation/queue-ledger/QueueLedger.js'; +import { messageFrom } from '../cats/services/stores/message-from.js'; import type { StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; +import { actionSuccessorFencesMatch } from './ActionSuccessorAdmissionContract.js'; +import type { ActionSuccessorFence } from './ActionSuccessorAdmissionService.js'; import type { ActionSuccessorDispatchFailureReason } from './action-successor-state-machine.js'; export type ApprovedActionCarrierClassification = @@ -15,12 +19,16 @@ function sameOrderedStrings(actual: readonly string[] | undefined, expected: rea export function classifyApprovedActionCarrier( proposal: DispatchProposal, message: StoredMessage, + entries: readonly QueueLedgerEntry[], + fence: ActionSuccessorFence, ): ApprovedActionCarrierClassification { const targetCats = proposal.targetCats; + const from = messageFrom(message); const sourceMatches = message.threadId === proposal.targetThreadId && message.userId === proposal.ownerUserId && - message.catId === proposal.senderCatId && + from.kind === 'agent' && + from.catId === proposal.senderCatId && message.content === proposal.content && message.origin === 'callback' && message.replyTo === proposal.replyTo && @@ -31,32 +39,31 @@ export function classifyApprovedActionCarrier( sameOrderedStrings(message.extra.targetCats, targetCats); if (!sourceMatches) return { outcome: 'conflict', reason: 'carrier_source_conflict' }; - const custody = message.queueCustody; - if (!custody) { + if (entries.length === 0) { return message.deliveryStatus === undefined || message.deliveryStatus === 'queued' ? { outcome: 'repairable' } : { outcome: 'conflict', reason: 'carrier_receipt_conflict' }; } - const carrierByTargetCatId = custody.carrierByTargetCatId; - if (!carrierByTargetCatId) return { outcome: 'conflict', reason: 'carrier_receipt_conflict' }; - const custodyMatches = - (message.deliveryStatus === 'queued' || message.deliveryStatus === 'delivered') && - custody.entryId === `cross-thread:${message.id}` && - custody.intent === 'execute' && - custody.ownerUserId === proposal.ownerUserId && - custody.receiptScope === 'cross_thread_delivery' && - sameOrderedStrings(custody.allTargetCats, targetCats) && - sameOrderedStrings(Object.keys(carrierByTargetCatId), targetCats) && - targetCats.every((catId) => { - const binding = carrierByTargetCatId[catId]; - return ( - binding?.source === 'agent' && - binding.sourceCategory === 'a2a' && - binding.callerCatId === proposal.senderCatId && - binding.a2aTriggerMessageId === message.id && - binding.autoExecute === true && - binding.entryId.length > 0 - ); - }); - return custodyMatches ? { outcome: 'admitted' } : { outcome: 'conflict', reason: 'carrier_receipt_conflict' }; + const [entry] = entries; + const ledgerMatches = + message.deliveryStatus !== 'canceled' && + entries.length === 1 && + Boolean( + entry && + sameOrderedStrings(entry.targets, targetCats) && + entry.owner.kind === 'user' && + entry.owner.userId === proposal.ownerUserId && + entry.from.kind === 'agent' && + entry.from.catId === proposal.senderCatId && + entry.kind === 'message_wake' && + entry.payload.messageId === message.id && + entry.payload.sourceRecordId === message.id && + entry.payload.content === proposal.content && + entry.execution.intent === 'execute' && + entry.execution.autoExecute === true && + entry.sourceCategory === 'a2a' && + entry.execution.actionSuccessorFence && + actionSuccessorFencesMatch(entry.execution.actionSuccessorFence, fence), + ); + return ledgerMatches ? { outcome: 'admitted' } : { outcome: 'conflict', reason: 'carrier_receipt_conflict' }; } diff --git a/packages/api/src/domains/ball-custody/managed-command-wake-carrier-adapter.ts b/packages/api/src/domains/ball-custody/managed-command-wake-carrier-adapter.ts new file mode 100644 index 0000000000..28ec0de6b0 --- /dev/null +++ b/packages/api/src/domains/ball-custody/managed-command-wake-carrier-adapter.ts @@ -0,0 +1,55 @@ +import type { InvocationQueue } from '../cats/services/agents/invocation/InvocationQueue.js'; +import type { IInvocationRecordStore } from '../cats/services/stores/ports/InvocationRecordStore.js'; +import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; +import { + type ManagedCommandWakeRecoveryDeps, + resolveManagedCommandWakeEventCarrier, +} from './managed-command-wake-lifecycle.js'; + +interface ManagedCommandWakeCarrierAdapterDeps { + readonly messageStore: Pick; + readonly invocationRecordStore: Pick; + readonly invocationQueue: Pick; +} + +/** + * Resolve managed-command delivery from its canonical owners: + * Queue only proves an exact target is still pending; once delivered, the + * source dispatchRef and response bubble in History own every later state. + */ +export function createManagedCommandWakeCarrierAdapter( + deps: ManagedCommandWakeCarrierAdapterDeps, +): Pick { + return { + getEventCarrier: async ({ threadId, userId, catId, messageId }) => { + const message = await deps.messageStore.getById(messageId); + const refs = + message?.lifecycle && 'dispatchRefs' in message.lifecycle ? (message.lifecycle.dispatchRefs ?? []) : []; + const matchingRef = refs.find((ref) => ref.targetId === catId); + const response = matchingRef ? await deps.messageStore.getById(matchingRef.statusMessageId) : null; + let pendingTarget = false; + if (!matchingRef) { + const entries = (await deps.invocationQueue.getDurableEntriesForMessages(threadId, [messageId])).get(messageId); + pendingTarget = + entries?.some( + (entry) => + entry.owner.kind === 'user' && + entry.owner.userId === userId && + entry.threadId === threadId && + entry.targets.includes(catId), + ) ?? false; + } + const carrier = resolveManagedCommandWakeEventCarrier(message, response, pendingTarget, { + threadId, + userId, + catId, + }); + if (carrier.state !== 'failed' || !carrier.invocationId) return carrier; + const invocation = await deps.invocationRecordStore.get(carrier.invocationId); + return { + ...carrier, + ...(invocation?.status === 'failed' && invocation.error ? { errorCode: invocation.error } : {}), + }; + }, + }; +} diff --git a/packages/api/src/domains/ball-custody/managed-command-wake-lifecycle.ts b/packages/api/src/domains/ball-custody/managed-command-wake-lifecycle.ts index 91cb72cc52..91c7b956ad 100644 --- a/packages/api/src/domains/ball-custody/managed-command-wake-lifecycle.ts +++ b/packages/api/src/domains/ball-custody/managed-command-wake-lifecycle.ts @@ -1,6 +1,4 @@ -import type { CatId } from '@cat-cafe/shared'; import type { DynamicTaskDef } from '../../infrastructure/scheduler/DynamicTaskStore.js'; -import type { OwnerAuthProvenance } from '../cats/services/owner-auth-provenance.js'; import type { InvocationRecord } from '../cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; import { @@ -40,7 +38,7 @@ export interface ManagedCommandWakeRecoveryStats { readonly pending: number; } -export type ManagedCommandWakeTriggerOutcome = 'dispatched' | 'enqueued' | 'full'; +export type ManagedCommandWakeTriggerOutcome = 'enqueued' | 'full'; export type ManagedCommandWakeEventCarrier = | { state: 'missing' | 'pending' | 'orphaned' } @@ -56,43 +54,62 @@ export type ManagedCommandWakeEventCarrier = export function resolveManagedCommandWakeEventCarrier( message: StoredMessage | null | undefined, - expected: { threadId: string; catId: string; activeQueueEntryId?: string | null }, + response: StoredMessage | null | undefined, + pendingTarget: boolean, + expected: { threadId: string; userId: string; catId: string }, ): ManagedCommandWakeEventCarrier { - if (!message || message.threadId !== expected.threadId) { + if (!message || message.threadId !== expected.threadId || message.userId !== expected.userId) { return { state: 'missing' }; } if (message.deliveryStatus === 'canceled') return { state: 'terminal', reason: 'canceled' }; - const custody = message.queueCustody; - if (!custody) return { state: 'missing' }; - const outcome = custody.targetOutcomeByCatId?.[expected.catId]; - if (custody.handledByCatIds.includes(expected.catId as CatId) && outcome) { - return { state: 'handled', invocationId: outcome.invocationId }; + const refs = message.lifecycle && 'dispatchRefs' in message.lifecycle ? (message.lifecycle.dispatchRefs ?? []) : []; + const matchingRefs = refs.filter((ref) => ref.targetId === expected.catId); + if (matchingRefs.length === 0) { + return message.deliveryStatus === 'queued' && pendingTarget ? { state: 'pending' } : { state: 'orphaned' }; } - if (custody.withdrawnByCatIds?.includes(expected.catId as CatId)) { - return { state: 'terminal', reason: 'withdrawn' }; + if (matchingRefs.length !== 1 || message.deliveryStatus !== 'delivered') return { state: 'orphaned' }; + const ref = matchingRefs[0]!; + if ( + !response || + response.id !== ref.statusMessageId || + response.threadId !== expected.threadId || + response.userId !== expected.userId + ) { + return { state: 'orphaned' }; } - if (custody.status === 'terminal') return { state: 'terminal', reason: 'terminal' }; - if (custody.pendingTargetCats.includes(expected.catId as CatId)) { - if ('activeQueueEntryId' in expected && expected.activeQueueEntryId !== custody.entryId) { + const lifecycle = response.lifecycle; + if (lifecycle?.kind === 'delivery_failure') { + if (lifecycle.inputMessageId !== message.id || !lifecycle.requestedTargets.includes(expected.catId)) { return { state: 'orphaned' }; } - if (custody.failedByCatIds.includes(expected.catId as CatId)) { - const failedAttempt = (custody.targetAttempts ?? []) - .filter((attempt) => attempt.targetCatId === expected.catId && attempt.state === 'failed') - .sort((left, right) => left.sequence - right.sequence) - .at(-1); - if (failedAttempt) { - return { - state: 'failed', - attemptId: failedAttempt.id, - attemptSequence: failedAttempt.sequence, - ...(failedAttempt.invocationId ? { invocationId: failedAttempt.invocationId } : {}), - }; - } - } - return { state: 'pending' }; + return { + state: 'failed', + attemptId: `${message.id}:${expected.catId}:${response.id}`, + attemptSequence: 1, + errorCode: lifecycle.reason, + }; + } + if ( + lifecycle?.kind !== 'response' || + lifecycle.targetId !== expected.catId || + !lifecycle.inputMessageIds.includes(message.id) + ) { + return { state: 'orphaned' }; + } + if (lifecycle.status === 'processing') return { state: 'pending' }; + if (lifecycle.status === 'completed') return { state: 'handled', invocationId: lifecycle.invocationId }; + if (lifecycle.status === 'failed') { + return { + state: 'failed', + attemptId: `${message.id}:${expected.catId}:${lifecycle.invocationId}`, + attemptSequence: 1, + invocationId: lifecycle.invocationId, + }; } - return { state: 'missing' }; + return { + state: 'terminal', + reason: lifecycle.status === 'canceled' ? 'canceled' : 'terminal', + }; } export interface ManagedCommandWakeTrigger { @@ -103,15 +120,13 @@ export interface ManagedCommandWakeTrigger { message: string, messageId: string, contentBlocks?: undefined, - policy?: { sourceCategory?: string; forceQueue?: boolean; ownerAuthProvenance?: OwnerAuthProvenance }, + policy?: { sourceCategory?: string; priority?: 'urgent' | 'normal' }, ): Promise; } export interface ManagedCommandWakeDynamicTaskStore { getAll(): DynamicTaskDef[]; getById(id: string): DynamicTaskDef | null; - /** Production stores must expose the private carrier; runtime legacy shapes still normalize to unknown. */ - getPrivateOwnerAuthProvenance(id: string): OwnerAuthProvenance; updateParamsIfCurrent(id: string, current: Record, next: Record): boolean; setEnabled(id: string, enabled: boolean): boolean; } @@ -130,25 +145,18 @@ export interface ManagedCommandWakeRecoveryDeps { ): InvocationRecord | null | Promise; }; readonly getInvokeTrigger: () => ManagedCommandWakeTrigger | undefined; - /** F167×F254: current Queue/F264 carrier truth for force-queued event wakes. */ + /** F167×F254: current Queue/F264 carrier truth for event wakes. */ readonly getEventCarrier?: (input: { threadId: string; userId: string; catId: string; messageId: string; }) => ManagedCommandWakeEventCarrier | Promise; - /** Retry one exact failed Queue target; the implementation must append a durable attempt fence before execution. */ - readonly retryEventCarrier?: (input: { - taskId: string; - threadId: string; - userId: string; - catId: string; - messageId: string; - attemptId: string; - }) => 'retried' | 'not_retryable' | 'unavailable' | Promise<'retried' | 'not_retryable' | 'unavailable'>; readonly now?: () => number; readonly dispatchedCarrierGraceMs?: number; readonly wakeSlaMs?: number; + /** Process-local liveness fence: present in production, omitted by isolated consumers. */ + readonly isCommandRunnerActive?: (taskId: string) => boolean; } export interface RecordManagedCommandCompletionInput { diff --git a/packages/api/src/domains/ball-custody/managed-command-wake-message-fence.ts b/packages/api/src/domains/ball-custody/managed-command-wake-message-fence.ts index 9233405d49..e942a7d812 100644 --- a/packages/api/src/domains/ball-custody/managed-command-wake-message-fence.ts +++ b/packages/api/src/domains/ball-custody/managed-command-wake-message-fence.ts @@ -119,7 +119,7 @@ function releaseManagedCommandWakeMessageContentClaim( } export async function publishManagedCommandWakeMessage( - deps: Pick, + deps: Pick, parsed: ParsedManagedCommandWakeTask, now: () => number, ): Promise { @@ -131,12 +131,12 @@ export async function publishManagedCommandWakeMessage( const actionLeaseRef = readManagedCommandWakeActionLeaseRef(claimed); try { - const existing = await deps.messageStore.getByIdempotencyKey('scheduler', claimed.threadId, idempotencyKey); + const existing = await deps.messageStore.getByIdempotencyKey(claimed.userId, claimed.threadId, idempotencyKey); const stored = existing ?? (await deps.messageStore.append({ - userId: 'scheduler', - catId: null, + from: { kind: 'system', service: 'managed-command-wake' }, + userId: claimed.userId, content: triggerContent, mentions: [], timestamp: now(), @@ -148,6 +148,8 @@ export async function publishManagedCommandWakeMessage( label: '持球通知', icon: '🏓', meta: { + managedHold: true, + phase: 'wake', taskId: claimed.task.id, threadId: claimed.threadId, catId: claimed.catId, @@ -157,7 +159,6 @@ export async function publishManagedCommandWakeMessage( }, })); - if (!existing) broadcastStoredMessage(deps.socketManager, claimed.threadId, stored); return commitManagedCommandWakeMessageVisibility( deps.dynamicTaskStore, claimed.task.id, @@ -168,7 +169,11 @@ export async function publishManagedCommandWakeMessage( } catch (err) { let committedAfterError: StoredWakeMessage | null; try { - committedAfterError = await deps.messageStore.getByIdempotencyKey('scheduler', claimed.threadId, idempotencyKey); + committedAfterError = await deps.messageStore.getByIdempotencyKey( + claimed.userId, + claimed.threadId, + idempotencyKey, + ); } catch (lookupErr) { log.warn( { err: lookupErr, taskId: claimed.task.id, threadId: claimed.threadId }, @@ -177,7 +182,6 @@ export async function publishManagedCommandWakeMessage( return false; } if (committedAfterError) { - broadcastStoredMessage(deps.socketManager, claimed.threadId, committedAfterError); return commitManagedCommandWakeMessageVisibility( deps.dynamicTaskStore, claimed.task.id, @@ -198,20 +202,3 @@ export async function publishManagedCommandWakeMessage( return false; } } - -function broadcastStoredMessage( - socketManager: ManagedCommandWakeRecoveryDeps['socketManager'], - threadId: string, - stored: StoredWakeMessage, -): void { - socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: stored.id, - type: 'connector', - content: stored.content, - source: stored.source, - timestamp: stored.timestamp, - }, - }); -} diff --git a/packages/api/src/domains/ball-custody/managed-command-wake-queue-adapter.ts b/packages/api/src/domains/ball-custody/managed-command-wake-queue-adapter.ts deleted file mode 100644 index 3d88bc59dc..0000000000 --- a/packages/api/src/domains/ball-custody/managed-command-wake-queue-adapter.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { DynamicTaskStore } from '../../infrastructure/scheduler/DynamicTaskStore.js'; -import type { InvocationQueue } from '../cats/services/agents/invocation/InvocationQueue.js'; -import type { QueueProcessor } from '../cats/services/agents/invocation/QueueProcessor.js'; -import type { IInvocationRecordStore } from '../cats/services/stores/ports/InvocationRecordStore.js'; -import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; -import { - type ManagedCommandWakeRecoveryDeps, - parseManagedCommandWakeTask, - resolveManagedCommandWakeEventCarrier, -} from './managed-command-wake-lifecycle.js'; - -interface ManagedCommandWakeQueueAdapterDeps { - readonly dynamicTaskStore: Pick; - readonly messageStore: Pick; - readonly invocationRecordStore: Pick; - readonly invocationQueue: Pick; - readonly queueProcessor: Pick; -} - -export function createManagedCommandWakeQueueAdapter( - deps: ManagedCommandWakeQueueAdapterDeps, -): Pick { - return { - getEventCarrier: async ({ threadId, userId, catId, messageId }) => { - const message = await deps.messageStore.getById(messageId); - const custodyEntryId = message?.queueCustody?.entryId; - const activeQueueEntryId = custodyEntryId - ? (deps.invocationQueue.getEntrySnapshot(threadId, userId, custodyEntryId)?.id ?? null) - : null; - const carrier = resolveManagedCommandWakeEventCarrier(message, { threadId, catId, activeQueueEntryId }); - if (carrier.state !== 'failed' || !carrier.invocationId) return carrier; - const invocation = await deps.invocationRecordStore.get(carrier.invocationId); - return { - ...carrier, - ...(invocation?.status === 'failed' && invocation.error ? { errorCode: invocation.error } : {}), - }; - }, - - retryEventCarrier: async ({ taskId, threadId, userId, catId, messageId, attemptId }) => { - const task = parseManagedCommandWakeTask(deps.dynamicTaskStore.getById(taskId)); - const message = await deps.messageStore.getById(messageId); - if ( - !task || - task.threadId !== threadId || - task.userId !== userId || - task.catId !== catId || - task.command.messageId !== messageId || - message?.threadId !== threadId || - message.source?.connector !== 'hold-ball' || - message.source.meta?.taskId !== taskId || - message.source.meta?.wakeWhen !== true || - !message.queueCustody || - (message.queueCustody.ownerUserId !== undefined && message.queueCustody.ownerUserId !== userId) - ) { - return 'not_retryable'; - } - const result = await deps.queueProcessor.retryFailedTarget( - threadId, - userId, - message.queueCustody.entryId, - catId, - attemptId, - async (transitions) => { - if (transitions.length !== 1 || transitions[0]?.messageId !== messageId) { - return { outcome: 'unavailable' }; - } - const currentTask = parseManagedCommandWakeTask(deps.dynamicTaskStore.getById(taskId)); - if (!currentTask || currentTask.command.messageId !== messageId) { - return { outcome: 'authority_stale', reason: 'outcome_mismatch' }; - } - const transition = transitions[0]; - const committed = await deps.messageStore.transitionQueueCustody(transition.messageId, { - expectedRevision: transition.current.revision, - next: transition.next, - }); - return committed.kind === 'updated' ? { outcome: 'committed' } : { outcome: 'custody_conflict' }; - }, - ); - if (result.outcome === 'retried') return 'retried'; - return result.outcome === 'unavailable' ? 'unavailable' : 'not_retryable'; - }, - }; -} diff --git a/packages/api/src/domains/ball-custody/managed-command-wake-recovery-policy.ts b/packages/api/src/domains/ball-custody/managed-command-wake-recovery-policy.ts index 8023921a43..3401ae381e 100644 --- a/packages/api/src/domains/ball-custody/managed-command-wake-recovery-policy.ts +++ b/packages/api/src/domains/ball-custody/managed-command-wake-recovery-policy.ts @@ -1,10 +1,6 @@ import { createModuleLogger } from '../../infrastructure/logger.js'; import { managedCommandWakeSlaBreachTotal } from '../../infrastructure/telemetry/instruments.js'; -import type { - ManagedCommandWakeEventCarrier, - ManagedCommandWakeRecoveryDeps, - ManagedCommandWakeRecoveryResult, -} from './managed-command-wake-lifecycle.js'; +import type { ManagedCommandWakeRecoveryDeps } from './managed-command-wake-lifecycle.js'; import { type ManagedCommandWakeProjection, type ParsedManagedCommandWakeTask, @@ -12,83 +8,11 @@ import { } from './managed-command-wake-task-projection.js'; const log = createModuleLogger('ball-custody/managed-command-wake-recovery-policy'); -const MAX_MISSING_DISPOSITION_RETRIES = 1; export function isDispatchableManagedCommandWakeState(state: ManagedCommandWakeProjection['state']): boolean { return state === 'message_written' || state === 'dispatch_pending' || state === 'dispatched' || state === 'enqueued'; } -export async function recoverManagedCommandMissingDisposition( - deps: ManagedCommandWakeRecoveryDeps, - parsed: ParsedManagedCommandWakeTask, - carrier: Extract, - now: () => number, -): Promise { - if (parsed.command.lastDispositionFailedAttemptId === carrier.attemptId) return 'pending'; - const retryCount = parsed.command.dispositionRetryCount ?? 0; - // Queue custody is the retry truth across the task/Queue persistence seam. - // Sequence > 1 proves a durable successor was already appended even when a - // process exited before the task-side audit counters could be updated. - if (retryCount >= MAX_MISSING_DISPOSITION_RETRIES || carrier.attemptSequence > 1) { - const escalatedAt = now(); - const updated = deps.dynamicTaskStore.updateParamsIfCurrent(parsed.task.id, parsed.task.params, { - ...parsed.task.params, - holdLifecycle: { - ...parsed.lifecycle, - status: 'escalated', - managedCommand: { - ...parsed.command, - state: 'escalated', - dispositionEscalationReason: 'managed_hold_disposition_missing', - dispositionEscalatedAttemptId: carrier.attemptId, - dispositionEscalatedAt: escalatedAt, - }, - }, - }); - if (!updated) return 'pending'; - deps.dynamicTaskStore.setEnabled(parsed.task.id, false); - deps.taskRunner.unregister(parsed.task.id); - log.error( - { - taskId: parsed.task.id, - threadId: parsed.threadId, - messageId: parsed.command.messageId, - attemptId: carrier.attemptId, - retryCount, - }, - 'managed-command wake escalated after bounded missing-disposition recovery', - ); - return 'recovered'; - } - - const messageId = parsed.command.messageId; - const retry = deps.retryEventCarrier; - if (!messageId || !retry) return 'pending'; - const outcome = await retry({ - taskId: parsed.task.id, - threadId: parsed.threadId, - userId: parsed.userId, - catId: parsed.catId, - messageId, - attemptId: carrier.attemptId, - }); - if (outcome !== 'retried') return 'pending'; - const latest = parseManagedCommandWakeTask(deps.dynamicTaskStore.getById(parsed.task.id)); - if (!latest) return 'missing'; - deps.dynamicTaskStore.updateParamsIfCurrent(latest.task.id, latest.task.params, { - ...latest.task.params, - holdLifecycle: { - ...latest.lifecycle, - managedCommand: { - ...latest.command, - dispositionRetryCount: retryCount + 1, - lastDispositionFailedAttemptId: carrier.attemptId, - }, - }, - }); - return 'pending'; -} - export function recordManagedCommandWakeSlaBreach( deps: ManagedCommandWakeRecoveryDeps, parsed: ParsedManagedCommandWakeTask, diff --git a/packages/api/src/domains/ball-custody/managed-command-wake-task-projection.ts b/packages/api/src/domains/ball-custody/managed-command-wake-task-projection.ts index a57327cc1c..df40556834 100644 --- a/packages/api/src/domains/ball-custody/managed-command-wake-task-projection.ts +++ b/packages/api/src/domains/ball-custody/managed-command-wake-task-projection.ts @@ -19,6 +19,7 @@ import type { DurableManagedGateJob } from './durable-managed-gate-job.js'; export type ManagedCommandWakeState = | 'command_running' + | 'lost' | 'condition_met' | 'message_written' | 'dispatch_pending' @@ -28,7 +29,8 @@ export type ManagedCommandWakeState = | 'escalated' | 'consumed'; -export type ManagedCommandWakeCarrierTerminalReason = 'withdrawn' | 'canceled' | 'terminal' | 'force_reset'; +export type ManagedCommandWakeCarrierTerminalReason = 'withdrawn' | 'canceled' | 'failed' | 'terminal' | 'force_reset'; +export type ManagedCommandWakeLostReason = 'runtime_restart' | 'spawn_failed' | 'runner_failed'; export interface ManagedCommandTerminalResult { readonly exitCode: number | null; @@ -51,6 +53,9 @@ export interface ManagedCommandWakeProjection { /** F167 Phase P: true once the admission-fact visibility message was durably appended. */ readonly admissionFactAppended?: boolean; readonly conditionMetAt?: number; + readonly lostAt?: number; + readonly lostReason?: ManagedCommandWakeLostReason; + readonly lostDetail?: string; readonly wakeContent?: string; readonly wakeSource?: 'command_completion' | 'fallback_timer'; readonly result?: ManagedCommandTerminalResult; @@ -66,13 +71,6 @@ export interface ManagedCommandWakeProjection { readonly carrierTerminalReason?: ManagedCommandWakeCarrierTerminalReason; readonly consumedAt?: number; readonly slaBreachObservedAt?: number; - /** Number of exact failed Queue attempts redelivered for a missing invocation-bound disposition. */ - readonly dispositionRetryCount?: number; - /** Idempotency fence for the failed attempt that authorized the latest redelivery. */ - readonly lastDispositionFailedAttemptId?: string; - readonly dispositionEscalationReason?: 'managed_hold_disposition_missing'; - readonly dispositionEscalatedAttemptId?: string; - readonly dispositionEscalatedAt?: number; } export interface ParsedManagedCommandWakeTask { @@ -91,6 +89,7 @@ export function isPlainRecord(value: unknown): value is Record function isManagedCommandWakeState(value: unknown): value is ManagedCommandWakeState { return ( value === 'command_running' || + value === 'lost' || value === 'condition_met' || value === 'message_written' || value === 'dispatch_pending' || diff --git a/packages/api/src/domains/ball-custody/managed-hold-supersession.ts b/packages/api/src/domains/ball-custody/managed-hold-supersession.ts deleted file mode 100644 index 0c80b7259e..0000000000 --- a/packages/api/src/domains/ball-custody/managed-hold-supersession.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { BallCustodyEvent } from '@cat-cafe/shared'; -import type { TurnCustodyProjection, TurnCustodyWakeProvenance } from './turn-custody-projection-types.js'; - -type StructuredWakeLocator = Extract; - -import { handedEventSourceId } from './ball-custody-events.js'; - -/** - * Single source of truth for "was this exact managed-hold wake superseded?". - * - * F167 × clowder-ai#1366: this predicate previously existed twice — once in - * ManagedHoldDispositionService.assertWakeNotReplaced (write side) and once - * implicitly in TurnCustodyProjectionService.openStructured (read side). The two - * diverged, so a wake could be judged "replaced, refuse to terminate" by the - * writer while the reader still treated it as a live obligation. That divergence - * is the liveness loop: undischargeable on one side, still blocking on the other. - */ -export interface ManagedHoldWakeIdentity { - readonly catId: string; - readonly sourceMessageId: string; - readonly taskId: string; -} - -export type ManagedHoldSupersession = - | { readonly kind: 'wake_missing' } - | { readonly kind: 'live' } - | { readonly kind: 'superseded'; readonly bySourceEventId: string }; - -/** - * Locate the exact `ball.wake_condition_met` for this wake, then report whether a - * later custody event took the ball away from it. - * - * `boundarySequence` bounds the scan to events that existed at a given moment. - * The read side passes its adoption baseline so that a *post*-adoption rehold or - * handoff is NOT counted as supersession — that is a legitimate continuation - * witness which the stop gate must still observe through its normal close path. - */ -export function classifyManagedHoldWake( - events: readonly BallCustodyEvent[], - wake: ManagedHoldWakeIdentity, - boundarySequence: number = events.length, -): ManagedHoldSupersession { - const exactWakeIndex = events.findIndex( - (event) => - event.kind === 'ball.wake_condition_met' && - event.payload.taskId === wake.taskId && - event.payload.catId === wake.catId, - ); - if (exactWakeIndex === -1) return { kind: 'wake_missing' }; - - // The wake's own receiver-boundary handoff is what delivered it; it is never - // its own replacement. - const ownReceiverHandoffSourceId = handedEventSourceId(wake.sourceMessageId, wake.catId); - const superseding = events - .slice(exactWakeIndex + 1, Math.max(exactWakeIndex + 1, boundarySequence)) - .find( - (event) => - (event.kind === 'ball.held' && event.payload.catId === wake.catId) || - (event.kind === 'ball.handed' && - event.sourceEventId !== ownReceiverHandoffSourceId && - (event.payload.fromCatId === wake.catId || event.payload.toCatId === wake.catId)) || - (event.kind === 'ball.handed_cvo' && event.payload.fromCatId === wake.catId), - ); - - return superseding ? { kind: 'superseded', bySourceEventId: superseding.sourceEventId } : { kind: 'live' }; -} - -/** - * Read-side projection for an adopted hold wake. - * - * Returns evidence refs when the wake was already superseded BEFORE this turn - * adopted it (so it is not a live obligation and must not block the turn), or - * `undefined` when it is still live. The boundary is the caller's adoption - * snapshot, so a rehold/handoff that happens AFTER adoption remains a - * continuation witness rather than a supersession. - */ -export function supersededBeforeAdoption( - events: readonly BallCustodyEvent[], - wake: ManagedHoldWakeIdentity, - subjectKey: string, -): readonly string[] | undefined { - const supersession = classifyManagedHoldWake(events, wake, events.length); - return supersession.kind === 'superseded' - ? [`hold:${subjectKey}`, `superseded:${supersession.bySourceEventId}`] - : undefined; -} - -/** - * Any terminal already recorded for this exact wake, regardless of which - * invocation wrote it. - * - * Shared deliberately: the producer uses it for idempotent replay, and the F167 - * stop-gate consumer uses it to recognize a wake that is already settled. Those - * two answers diverging is the same class of bug this module exists to prevent. - */ -export function findWakeTerminal( - events: readonly BallCustodyEvent[], - wake: ManagedHoldWakeIdentity, -): BallCustodyEvent | undefined { - return events.find( - (event) => - event.kind === 'ball.hold_dispositioned' && - event.payload.catId === wake.catId && - event.payload.sourceMessageId === wake.sourceMessageId && - event.payload.taskId === wake.taskId, - ); -} - -/** Index of the exact delivery handoff for this structured wake, or -1. */ -export function exactStructuredWakeIndex(wake: StructuredWakeLocator, events: readonly BallCustodyEvent[]): number { - if (wake.protocol === 'dispatch') { - return events.findIndex( - (event) => - event.kind === 'ball.handed' && - event.sourceEventId === wake.handoff.sourceEventId && - event.payload.fromCatId === wake.handoff.fromCatId && - event.payload.toCatId === wake.holderCatId, - ); - } - if (wake.protocol !== 'hold') return -1; - const wakeConditionIndex = events.findIndex( - (event) => - event.kind === 'ball.wake_condition_met' && - event.payload.taskId === wake.taskId && - event.payload.catId === wake.holderCatId, - ); - if (wakeConditionIndex === -1) return -1; - return events.findIndex( - (event, index) => - index > wakeConditionIndex && - event.kind === 'ball.handed' && - event.sourceEventId === handedEventSourceId(wake.sourceMessageId, wake.holderCatId) && - event.payload.toCatId === wake.holderCatId, - ); -} - -function findRelease( - events: readonly BallCustodyEvent[], - exactWakeIndex: number, - holderCatId: string, -): BallCustodyEvent | undefined { - return events.slice(exactWakeIndex + 1).find((event) => { - if (event.kind === 'ball.handed') return event.payload.fromCatId === holderCatId; - return ( - event.kind === 'ball.handed_cvo' && - event.payload.fromCatId === holderCatId && - (event.payload.intent === 'handoff' || event.payload.intent === 'done_notify') - ); - }); -} - -/** The holder already released this exact wake, so it is no longer an obligation. */ -export function releasedStructuredWake( - wake: StructuredWakeLocator, - events: readonly BallCustodyEvent[], - exactWakeIndex: number, -): TurnCustodyProjection | undefined { - if (exactWakeIndex === -1) return undefined; - const release = findRelease(events, exactWakeIndex, wake.holderCatId); - if (!release) return undefined; - const exactWakeEvidence = - wake.protocol === 'dispatch' - ? wake.handoff.sourceEventId - : wake.protocol === 'hold' - ? handedEventSourceId(wake.sourceMessageId, wake.holderCatId) - : undefined; - return { - state: 'covered_empty', - evidenceRefs: [ - `${wake.protocol}:${wake.subjectKey}`, - ...(exactWakeEvidence ? [exactWakeEvidence] : []), - `released:${release.sourceEventId}`, - ], - }; -} diff --git a/packages/api/src/domains/ball-custody/record-managed-hold-disposition.ts b/packages/api/src/domains/ball-custody/record-managed-hold-disposition.ts deleted file mode 100644 index d86eb3c8a6..0000000000 --- a/packages/api/src/domains/ball-custody/record-managed-hold-disposition.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { BallCustodyEvent } from '@cat-cafe/shared'; -import type { IBallCustodyEventLog } from './BallCustodyEventLog.js'; -import type { IBallCustodyFencedIngest } from './BallCustodyIngest.js'; -import { ManagedHoldDispositionError } from './ManagedHoldSourceSelection.js'; - -/** Internal retry evidence; the public rejection code remains unchanged. */ -export class ManagedHoldHeartbeatConflict extends ManagedHoldDispositionError { - constructor() { - super('managed_hold_disposition_fence_conflict'); - } -} - -interface RecordDeps { - readonly ballCustody: IBallCustodyFencedIngest; - readonly ballCustodyEventLog: Pick; - readonly repairProjection?: (subjectKey: string) => Promise; - readonly log?: { warn(fields: Record, message: string): void }; -} - -/** Preserve append-before-receipt ordering and identify bounded retry evidence. */ -export async function recordManagedHoldDisposition( - deps: RecordDeps, - event: BallCustodyEvent, - expectedSequence: number, -): Promise { - let conflictSequence: number | undefined; - try { - const result = await deps.ballCustody.recordFenced(event, expectedSequence); - if (result.outcome === 'conflict') { - conflictSequence = result.actualSequence; - throw new ManagedHoldDispositionError('managed_hold_disposition_fence_conflict'); - } - } catch (error) { - const events = await deps.ballCustodyEventLog.read(event.subjectKey); - if (conflictSequence !== undefined) { - const intervening = events.slice(expectedSequence, conflictSequence); - const heartbeatOnly = - conflictSequence > expectedSequence && - intervening.length === conflictSequence - expectedSequence && - intervening.every((candidate) => candidate.kind === 'invocation.heartbeat'); - deps.log?.warn( - { - subjectKey: event.subjectKey, - invocationId: event.payload.invocationId, - sourceMessageId: event.payload.sourceMessageId, - taskId: event.payload.taskId, - expectedSequence, - actualSequence: conflictSequence, - heartbeatOnly, - interveningEvents: intervening - .slice(0, 8) - .map(({ sourceEventId, kind, at }) => ({ sourceEventId, kind, at })), - omittedEventCount: Math.max(0, conflictSequence - expectedSequence - 8), - }, - '[F167] Managed hold disposition CAS conflict', - ); - if (heartbeatOnly) error = new ManagedHoldHeartbeatConflict(); - } - const appended = events.find((candidate) => candidate.sourceEventId === event.sourceEventId); - if (!appended || !deps.repairProjection) throw error; - await deps.repairProjection(event.subjectKey); - } -} diff --git a/packages/api/src/domains/ball-custody/turn-custody-projection-types.ts b/packages/api/src/domains/ball-custody/turn-custody-projection-types.ts index 9f2866bf31..c0de416852 100644 --- a/packages/api/src/domains/ball-custody/turn-custody-projection-types.ts +++ b/packages/api/src/domains/ball-custody/turn-custody-projection-types.ts @@ -11,7 +11,7 @@ import type { WaitContinuationCarrierV1 } from '@cat-cafe/shared'; export type TurnCustodyWakeProvenance = | { readonly kind: 'unstructured'; - readonly source: 'user_chat' | 'roam' | 'cron' | 'brainstorm' | 'protocol_decline'; + readonly source: 'user_chat' | 'roam' | 'cron' | 'brainstorm' | 'protocol_decline' | 'queue_delivery'; } | { readonly kind: 'non_obligation'; @@ -71,38 +71,17 @@ export interface ActionTransitionBaseline { readonly fingerprint: string; } -export interface StructuredTransitionBaseline { - readonly kind: 'structured'; - readonly subjectKey: string; - readonly holderCatId: string; - readonly fromSequence: number; - readonly protocol: Extract['protocol']; - readonly sourceMessageId?: string; - readonly taskId?: string; - readonly dispatchSourceMessageId?: string; - readonly dispatchFromCatId?: string; -} - export interface TurnCustodyProjection { readonly state: TurnCustodyProjectionState; readonly evidenceRefs: readonly string[]; - readonly baseline?: ActionTransitionBaseline | StructuredTransitionBaseline; + readonly baseline?: ActionTransitionBaseline; } export interface TurnCustodyStopDecision { readonly state: TurnCustodyProjectionState; readonly shouldBlock: boolean; readonly transitionObserved: boolean; - readonly structuredTransitionKind?: 'hold_dispositioned' | 'dispatch_dispositioned' | 'held' | 'handed'; - readonly dispatchDisposition?: 'handled' | 'completed'; - readonly dispatchDispositionEventId?: string; - readonly dispatchDispositionAt?: number; readonly evidenceRefs: string[]; } -export type StructuredTransitionObservation = Pick< - TurnCustodyStopDecision, - 'structuredTransitionKind' | 'dispatchDisposition' | 'dispatchDispositionEventId' | 'dispatchDispositionAt' ->; - export type TurnCustodyShadowComparison = 'agree_allow' | 'agree_block' | 'old_only_block' | 'new_only_block'; diff --git a/packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts b/packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts index 735d7721dc..8a6bf19ffd 100644 --- a/packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts +++ b/packages/api/src/domains/ball-custody/turn-custody-wake-provenance.ts @@ -1,5 +1,5 @@ -import type { WaitContinuationCarrierV1 } from '@cat-cafe/shared'; -import type { QueueEntry } from '../cats/services/agents/invocation/InvocationQueue.js'; +import { isManagedHoldConnectorSource, type WaitContinuationCarrierV1 } from '@cat-cafe/shared'; +import { type QueueEntry, queueEntryCallerCatId } from '../cats/services/agents/invocation/InvocationQueue.js'; import { hydrateCrossThreadReplyHint, type IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; import { handedEventSourceId } from './ball-custody-events.js'; import type { TurnCustodyWakeProvenance } from './TurnCustodyProjectionService.js'; @@ -8,18 +8,11 @@ import { waitContinuationCarriersMatch, } from './wait-continuation-carrier.js'; -type WakeQueueEntry = Pick< - QueueEntry, - | 'actionSuccessorFence' - | 'a2aTriggerMessageId' - | 'callerCatId' - | 'messageId' - | 'source' - | 'sourceCategory' - | 'targetCats' - | 'threadId' - | 'waitContinuationCarrier' ->; +type WakeQueueEntry = Pick; + +function exactTargetCatId(entry: WakeQueueEntry): string | undefined { + return entry.targets.length === 1 ? entry.targets[0] : undefined; +} export function buildCrossThreadNoObligationWake(input: unknown): TurnCustodyWakeProvenance | undefined { if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined; @@ -89,27 +82,22 @@ async function resolveScheduledWake( messageStore: IMessageStore, ): Promise { try { - const sourceMessage = entry.messageId ? await messageStore.getById(entry.messageId) : null; - if (sourceMessage?.source?.connector === 'hold-ball') { + const messageId = entry.payload.messageId; + const sourceMessage = messageId ? await messageStore.getById(messageId) : null; + if (isManagedHoldConnectorSource(sourceMessage?.source) && sourceMessage?.source?.meta?.phase === 'wake') { const meta = sourceMessage.source.meta; const taskId = typeof meta?.taskId === 'string' ? meta.taskId : undefined; const sourceThreadId = typeof meta?.threadId === 'string' ? meta.threadId : undefined; const sourceCatId = typeof meta?.catId === 'string' ? meta.catId : undefined; - if ( - !entry.messageId || - !taskId || - meta?.wakeWhen !== true || - sourceThreadId !== entry.threadId || - sourceCatId !== entry.targetCats[0] - ) { + if (!messageId || !taskId || sourceThreadId !== entry.threadId || sourceCatId !== exactTargetCatId(entry)) { return { kind: 'legacy', reason: 'carrier_missing', sourceCategory: 'scheduled' }; } return { kind: 'structured', protocol: 'hold', subjectKey: `ball:thread:${entry.threadId}`, - holderCatId: entry.targetCats[0] ?? 'unknown', - sourceMessageId: entry.messageId, + holderCatId: exactTargetCatId(entry) ?? 'unknown', + sourceMessageId: messageId, taskId, }; } @@ -120,29 +108,21 @@ async function resolveScheduledWake( } async function resolveA2AWake(entry: WakeQueueEntry, messageStore: IMessageStore): Promise { - const messageId = entry.a2aTriggerMessageId ?? entry.messageId; - let fromCatId = entry.callerCatId; + const messageId = entry.execution.a2aTriggerMessageId ?? entry.payload.messageId; + const fromCatId = queueEntryCallerCatId(entry); if (messageId) { try { const replyHint = await hydrateCrossThreadReplyHint(messageStore, messageId); const noObligationWake = buildCrossThreadNoObligationWake(replyHint); if (noObligationWake) return noObligationWake; - fromCatId ??= replyHint?.senderCatId; } catch { // Optional lifecycle classification failed. The exact dispatch carrier // below remains fail-closed and must not be weakened by that lookup. } } - if (!fromCatId && messageId) { - try { - fromCatId = (await messageStore.getById(messageId))?.catId ?? undefined; - } catch { - return { kind: 'legacy', reason: 'query_failed', sourceCategory: 'a2a' }; - } - } return buildA2ADispatchTurnCustodyWake({ threadId: entry.threadId, - targetCatId: entry.targetCats[0], + targetCatId: exactTargetCatId(entry), messageId, fromCatId, }); @@ -160,15 +140,16 @@ async function resolveWaitContinuationWake( entry: WakeQueueEntry, messageStore: IMessageStore, ): Promise { - const queueCarrier = entry.waitContinuationCarrier; + const queueCarrier = entry.execution.waitContinuationCarrier; if (!queueCarrier) return null; - if (entry.source !== 'connector' || !entry.messageId || entry.actionSuccessorFence) { + const messageId = entry.payload.messageId; + if (!messageId || entry.execution.actionSuccessorFence) { return missingQueueCarrier(entry); } let storedCarrier: WaitContinuationCarrierV1 | undefined; try { - storedCarrier = waitContinuationCarrierFromStoredMessage(await messageStore.getById(entry.messageId)); + storedCarrier = waitContinuationCarrierFromStoredMessage(await messageStore.getById(messageId)); } catch { return missingQueueCarrier(entry); } @@ -178,7 +159,7 @@ async function resolveWaitContinuationWake( kind: 'structured', protocol: 'event_wait', subjectKey: `ball:thread:${entry.threadId}`, - holderCatId: entry.targetCats[0] ?? 'unknown', + holderCatId: exactTargetCatId(entry) ?? 'unknown', waitContinuationCarrier: queueCarrier, }; } @@ -190,21 +171,27 @@ export async function resolveQueueTurnCustodyWake( ): Promise { const waitWake = await resolveWaitContinuationWake(entry, messageStore); if (waitWake) return waitWake; - if (entry.actionSuccessorFence) { + if (entry.execution.actionSuccessorFence) { return { kind: 'action_successor', - leaseId: entry.actionSuccessorFence.leaseId, - generation: entry.actionSuccessorFence.generation, - holderCatId: entry.targetCats[0] ?? 'unknown', + leaseId: entry.execution.actionSuccessorFence.leaseId, + generation: entry.execution.actionSuccessorFence.generation, + holderCatId: exactTargetCatId(entry) ?? 'unknown', }; } - if (entry.source === 'user') return { kind: 'unstructured', source: 'user_chat' }; + if (entry.from.kind === 'user') return { kind: 'unstructured', source: 'user_chat' }; if (entry.sourceCategory === 'scheduled') return resolveScheduledWake(entry, messageStore); if (entry.sourceCategory === 'freshness') return { kind: 'unstructured', source: 'protocol_decline' }; if (entry.sourceCategory === 'a2a') return resolveA2AWake(entry, messageStore); + if (entry.sourceCategory) { + // ADR-043 D6: ordinary Queue delivery owns its own lifecycle. CI, + // review, conflict, issue, continuation, and A2A-failure messages are + // not legacy custody obligations and must never enter the old stop gate. + return { kind: 'unstructured', source: 'queue_delivery' }; + } return { kind: 'legacy', - reason: entry.messageId ? 'carrier_missing' : 'source_missing', + reason: entry.payload.messageId ? 'carrier_missing' : 'source_missing', ...(entry.sourceCategory ? { sourceCategory: entry.sourceCategory } : {}), }; } @@ -228,5 +215,6 @@ export function turnCustodyWakeSourceCategory(wake: TurnCustodyWakeProvenance): if (wake.source === 'user_chat') return 'user'; if (wake.source === 'cron') return 'scheduled'; if (wake.source === 'protocol_decline') return 'freshness'; + if (wake.source === 'queue_delivery') return 'queue'; return 'unknown'; } diff --git a/packages/api/src/domains/ball-custody/wait-continuation-carrier.ts b/packages/api/src/domains/ball-custody/wait-continuation-carrier.ts index 7f2573463c..99658924a2 100644 --- a/packages/api/src/domains/ball-custody/wait-continuation-carrier.ts +++ b/packages/api/src/domains/ball-custody/wait-continuation-carrier.ts @@ -1,5 +1,10 @@ -import { parseWaitContinuationCarrier, type WaitContinuationCarrierV1 } from '@cat-cafe/shared'; +import { + createWaitContinuationCarrier, + parseWaitContinuationCarrier, + type WaitContinuationCarrierV1, +} from '@cat-cafe/shared'; import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; +import type { ITaskStore } from '../cats/services/stores/ports/TaskStore.js'; export class WaitContinuationCarrierError extends Error { readonly code = 'INVALID_WAIT_CONTINUATION_CARRIER'; @@ -35,3 +40,35 @@ export function waitContinuationCarriersMatch( (right.ownerFence.kind === 'action_successor' && left.ownerFence.leaseId === right.ownerFence.leaseId) ); } + +/** + * Fail closed unless a frozen github-wait carrier still names the exact + * canonical task outcome that is pending delivery to this owner. + * + * The connector Message is immutable transport evidence; the Task remains the + * authority for whether that wait generation is current and deliverable. + */ +export async function assertCurrentWaitContinuationCarrier( + taskStore: Pick | undefined, + carrier: WaitContinuationCarrierV1, + scope: { threadId: string; userId: string; catId: string }, +): Promise { + if (!taskStore) { + throw new WaitContinuationCarrierError('github-wait admission requires the canonical task store'); + } + const task = await taskStore.get(carrier.waitId); + const outcome = task?.automationState?.waitOutcome; + const currentCarrier = outcome ? createWaitContinuationCarrier(carrier.waitId, outcome) : undefined; + if ( + !task || + (task.kind !== 'pr_tracking' && task.kind !== 'issue_tracking') || + task.threadId !== scope.threadId || + task.userId !== scope.userId || + task.ownerCatId !== scope.catId || + outcome?.delivery !== 'pending' || + outcome.generation !== carrier.ownerFence.generation || + !waitContinuationCarriersMatch(carrier, currentCarrier) + ) { + throw new WaitContinuationCarrierError('github-wait continuation is stale or outside its owner scope'); + } +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationIndex.ts b/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationIndex.ts new file mode 100644 index 0000000000..ac7340c512 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationIndex.ts @@ -0,0 +1,85 @@ +import type { + CallerDispatchObservationInclusion, + CallerDispatchObservationPointer, +} from './caller-dispatch-observation-model.js'; + +export interface CallerDispatchObservationEntry extends CallerDispatchObservationPointer { + presentedFingerprint?: string; + observedFingerprint?: string; + actualFingerprint?: string; + touchedAt: number; +} + +const MAX_ENTRIES_PER_SLOT = 128; +const MAX_OBSERVATION_SLOTS = 512; +const OBSERVATION_IDLE_TTL_MS = 24 * 60 * 60 * 1_000; + +/** Process-local bounded index; canonical dispatch truth remains in History. */ +export class CallerDispatchObservationIndex { + private readonly bySlot = new Map>(); + + private prune(now = Date.now()): void { + const cutoff = now - OBSERVATION_IDLE_TTL_MS; + for (const [slotKey, entries] of this.bySlot) { + for (const [key, entry] of entries) { + if (entry.touchedAt < cutoff) entries.delete(key); + } + if (entries.size === 0) this.bySlot.delete(slotKey); + } + } + + private ensureSlot(slotKey: string, now: number): Map { + const existing = this.bySlot.get(slotKey); + if (existing) return existing; + this.prune(now); + if (this.bySlot.size >= MAX_OBSERVATION_SLOTS) { + const oldest = [...this.bySlot].sort( + ([, left], [, right]) => + Math.min(...[...left.values()].map((entry) => entry.touchedAt)) - + Math.min(...[...right.values()].map((entry) => entry.touchedAt)), + )[0]; + if (oldest) this.bySlot.delete(oldest[0]); + } + const entries = new Map(); + this.bySlot.set(slotKey, entries); + return entries; + } + + get(slotKey: string, key: string): CallerDispatchObservationEntry | undefined { + return this.bySlot.get(slotKey)?.get(key); + } + + set(slotKey: string, key: string, entry: CallerDispatchObservationEntry): void { + const entries = this.ensureSlot(slotKey, entry.touchedAt); + entries.set(key, entry); + if (entries.size <= MAX_ENTRIES_PER_SLOT) return; + const oldest = [...entries].sort(([, left], [, right]) => left.touchedAt - right.touchedAt)[0]; + if (oldest) entries.delete(oldest[0]); + } + + list(slotKey: string): readonly CallerDispatchObservationPointer[] { + this.prune(); + return [...(this.bySlot.get(slotKey)?.values() ?? [])]; + } + + acknowledge(included: readonly CallerDispatchObservationInclusion[]): void { + for (const observation of included) { + for (const [slotKey, entries] of this.bySlot) { + const current = entries.get(observation.key); + if (!current || current.revision !== observation.includedRevision) continue; + if (observation.terminal) { + entries.delete(observation.key); + if (entries.size === 0) this.bySlot.delete(slotKey); + break; + } + entries.set(observation.key, { + ...current, + presentedRevision: observation.includedRevision, + presentedFingerprint: observation.fingerprint, + touchedAt: Date.now(), + }); + break; + } + } + } +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.ts b/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.ts new file mode 100644 index 0000000000..5d80be0346 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.ts @@ -0,0 +1,289 @@ +import { messageFrom } from '../../stores/message-from.js'; +import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; +import { + type CallerDispatchObservationEntry, + CallerDispatchObservationIndex, +} from './CallerDispatchObservationIndex.js'; +import { CallerDispatchProcessStartRegistry } from './CallerDispatchProcessStartRegistry.js'; +import { + type CallerDispatchObservationInclusion, + type CallerDispatchObservationPointer, + type CallerDispatchObservationProjection, + type CallerDispatchObservationScope, + type CallerDispatchProcessStartProjection, + callerDispatchObservationKey, + callerDispatchObservationSlotKey, +} from './caller-dispatch-observation-model.js'; +import { + type ProjectedObservation, + projectCallerDispatchObservations, +} from './caller-dispatch-observation-projector.js'; +import { readCallerDispatchObservationLine } from './caller-dispatch-observation-reader.js'; + +export { + type CallerDispatchObservationInclusion, + type CallerDispatchObservationPointer, + type CallerDispatchObservationProjection, + type CallerDispatchProcessStartProjection, + callerDispatchObservationKey, +} from './caller-dispatch-observation-model.js'; + +const DEFAULT_MAX_PROMPT_CHARS = 6_000; + +/** + * Process-local index only. Canonical phase/outcome always comes from History. + * Re-registering the same exact source×target is intentionally idempotent. + */ +export class CallerDispatchObservationRegistry { + private readonly observations = new CallerDispatchObservationIndex(); + private readonly processStart = new CallerDispatchProcessStartRegistry(); + private nextRevision = 0; + + private allocateRevision(): number { + this.nextRevision += 1; + return this.nextRevision; + } + + private registerSelection( + pointer: Omit< + CallerDispatchObservationPointer, + 'revision' | 'presentedRevision' | 'firstAddedBy' | 'selectionChange' | 'selectionChangedBy' + >, + change: 'added' | 'removed', + changedBy: 'initial' | 'steer' | 'queue_withdrawal' | 'unknown', + ): string { + const sk = callerDispatchObservationSlotKey(pointer); + const now = Date.now(); + const key = callerDispatchObservationKey(pointer); + const current = this.observations.get(sk, key); + if (current?.selectionChange === change && current.selectionChangedBy === changedBy) return key; + const next: CallerDispatchObservationEntry = { + ...pointer, + revision: this.allocateRevision(), + presentedRevision: current?.presentedRevision ?? 0, + firstAddedBy: + current?.firstAddedBy ?? + (change === 'added' && (changedBy === 'initial' || changedBy === 'steer') ? changedBy : 'unknown'), + selectionChange: change, + selectionChangedBy: changedBy, + touchedAt: now, + ...(current?.presentedFingerprint ? { presentedFingerprint: current.presentedFingerprint } : {}), + ...(current?.observedFingerprint ? { observedFingerprint: current.observedFingerprint } : {}), + ...(current?.actualFingerprint ? { actualFingerprint: current.actualFingerprint } : {}), + }; + this.observations.set(sk, key, next); + return key; + } + + private registerActualRef( + pointer: Omit, + actualFingerprint: string, + ): string { + const sk = callerDispatchObservationSlotKey(pointer); + const now = Date.now(); + const key = callerDispatchObservationKey(pointer); + const current = this.observations.get(sk, key); + if (current?.actualFingerprint === actualFingerprint) return key; + + const next: CallerDispatchObservationEntry = { + ...pointer, + revision: this.allocateRevision(), + presentedRevision: current?.presentedRevision ?? 0, + firstAddedBy: current?.firstAddedBy ?? pointer.firstAddedBy, + selectionChange: current?.selectionChange ?? pointer.selectionChange, + selectionChangedBy: current?.selectionChangedBy ?? pointer.selectionChangedBy, + actualFingerprint, + touchedAt: now, + ...(current?.presentedFingerprint ? { presentedFingerprint: current.presentedFingerprint } : {}), + ...(current?.observedFingerprint ? { observedFingerprint: current.observedFingerprint } : {}), + }; + this.observations.set(sk, key, next); + return key; + } + + registerInitialSource(source: StoredMessage, targetIds: readonly string[]): string[] { + const from = messageFrom(source); + if (from.kind !== 'agent') return []; + return [...new Set(targetIds)].map((targetId) => + this.registerSelection( + { + ownerId: source.userId, + threadId: source.threadId, + callerCatId: from.catId, + sourceMessageId: source.id, + targetId, + }, + 'added', + 'initial', + ), + ); + } + + registerSteerChanges( + source: StoredMessage, + changes: { readonly addedTargetIds: readonly string[]; readonly removedTargetIds: readonly string[] }, + ): string[] { + const from = messageFrom(source); + if (from.kind !== 'agent') return []; + return this.registerSteerChangesByIdentity( + { + ownerId: source.userId, + threadId: source.threadId, + callerCatId: from.catId, + sourceMessageId: source.id, + }, + changes, + ); + } + + registerSteerChangesByIdentity( + source: { + readonly ownerId: string; + readonly threadId: string; + readonly callerCatId: string; + readonly sourceMessageId: string; + }, + changes: { readonly addedTargetIds: readonly string[]; readonly removedTargetIds: readonly string[] }, + ): string[] { + const registered: string[] = []; + for (const targetId of new Set(changes.addedTargetIds)) { + registered.push( + this.registerSelection( + { + ownerId: source.ownerId, + threadId: source.threadId, + callerCatId: source.callerCatId, + sourceMessageId: source.sourceMessageId, + targetId, + }, + 'added', + 'steer', + ), + ); + } + for (const targetId of new Set(changes.removedTargetIds)) { + registered.push( + this.registerSelection( + { + ownerId: source.ownerId, + threadId: source.threadId, + callerCatId: source.callerCatId, + sourceMessageId: source.sourceMessageId, + targetId, + }, + 'removed', + 'steer', + ), + ); + } + return registered; + } + + registerQueueWithdrawalByIdentity(source: { + readonly ownerId: string; + readonly threadId: string; + readonly callerCatId: string; + readonly sourceMessageId: string; + readonly targetIds: readonly string[]; + }): string[] { + return [...new Set(source.targetIds)].map((targetId) => + this.registerSelection( + { + ownerId: source.ownerId, + threadId: source.threadId, + callerCatId: source.callerCatId, + sourceMessageId: source.sourceMessageId, + targetId, + }, + 'removed', + 'queue_withdrawal', + ), + ); + } + + registerPersistedSource(source: StoredMessage, targetIds?: readonly string[]): string[] { + const from = messageFrom(source); + if (from.kind !== 'agent') return []; + if (source.lifecycle?.kind !== 'input' && source.lifecycle?.kind !== 'response') return []; + const allowedTargets = targetIds ? new Set(targetIds) : undefined; + return (source.lifecycle.dispatchRefs ?? []) + .filter((ref) => !allowedTargets || allowedTargets.has(ref.targetId)) + .map((ref) => + this.registerActualRef( + { + ownerId: source.userId, + threadId: source.threadId, + callerCatId: from.catId, + sourceMessageId: source.id, + targetId: ref.targetId, + firstAddedBy: 'unknown', + selectionChange: 'added', + selectionChangedBy: 'unknown', + }, + `${ref.phase}:${ref.statusMessageId}`, + ), + ); + } + + list(scope: CallerDispatchObservationScope): readonly CallerDispatchObservationPointer[] { + return this.observations.list(callerDispatchObservationSlotKey(scope)); + } + + acknowledge(included: readonly CallerDispatchObservationInclusion[]): void { + this.observations.acknowledge(included); + } + + projectProcessStartNotice( + scope: CallerDispatchObservationScope, + processGenerationId: string, + ): CallerDispatchProcessStartProjection { + return this.processStart.project(scope, processGenerationId); + } + + acknowledgeProcessStartNotice(scope: CallerDispatchObservationScope, processGenerationId: string): void { + this.processStart.acknowledge(scope, processGenerationId); + } + + private async refreshPointer( + messageStore: Pick, + pointer: CallerDispatchObservationPointer, + ): Promise { + const key = callerDispatchObservationKey(pointer); + const includedRevision = pointer.revision; + const observation = await readCallerDispatchObservationLine(messageStore, pointer); + const slotKey = callerDispatchObservationSlotKey(pointer); + let current = this.observations.get(slotKey, key); + // A committed Steer/update won the race while History was being read. + // Do not relabel the stale line with the newer revision; retain it for the next turn. + if (!current || current.revision !== includedRevision) return null; + + if (current.observedFingerprint !== observation.fingerprint) { + const projectedRevision = + current.presentedRevision >= current.revision ? this.allocateRevision() : current.revision; + current = { + ...current, + revision: projectedRevision, + observedFingerprint: observation.fingerprint, + touchedAt: Date.now(), + }; + this.observations.set(slotKey, key, current); + } + const alreadyPresented = + current.presentedRevision === current.revision && current.presentedFingerprint === observation.fingerprint; + if (alreadyPresented) return null; + return { ...observation, key, includedRevision: current.revision }; + } + + async project( + messageStore: Pick, + scope: CallerDispatchObservationScope, + maxPromptChars = DEFAULT_MAX_PROMPT_CHARS, + ): Promise { + const allPointers = this.list(scope); + return projectCallerDispatchObservations({ + allPointers, + maxPromptChars, + refresh: (pointer) => this.refreshPointer(messageStore, pointer), + }); + } +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchProcessStartRegistry.ts b/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchProcessStartRegistry.ts new file mode 100644 index 0000000000..81b6c075dc --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/CallerDispatchProcessStartRegistry.ts @@ -0,0 +1,60 @@ +import type { + CallerDispatchObservationScope, + CallerDispatchProcessStartProjection, +} from './caller-dispatch-observation-model.js'; +import { callerDispatchObservationSlotKey } from './caller-dispatch-observation-model.js'; + +interface CallerDispatchProcessStartState { + processGenerationId: string; + noticeAcknowledged: boolean; + touchedAt: number; +} + +const MAX_PROCESS_START_SLOTS = 512; +const PROCESS_START_IDLE_TTL_MS = 24 * 60 * 60 * 1_000; + +export class CallerDispatchProcessStartRegistry { + private readonly bySlot = new Map(); + + private prune(now = Date.now()): void { + const cutoff = now - PROCESS_START_IDLE_TTL_MS; + for (const [slotKey, state] of this.bySlot) { + if (state.touchedAt < cutoff) this.bySlot.delete(slotKey); + } + } + + project(scope: CallerDispatchObservationScope, processGenerationId: string): CallerDispatchProcessStartProjection { + const slotKey = callerDispatchObservationSlotKey(scope); + let state = this.bySlot.get(slotKey); + if (!state || state.processGenerationId !== processGenerationId) { + this.prune(); + if (!this.bySlot.has(slotKey) && this.bySlot.size >= MAX_PROCESS_START_SLOTS) { + const oldest = [...this.bySlot].sort(([, left], [, right]) => left.touchedAt - right.touchedAt)[0]; + if (oldest) this.bySlot.delete(oldest[0]); + } + state = { processGenerationId, noticeAcknowledged: false, touchedAt: Date.now() }; + this.bySlot.set(slotKey, state); + } + state.touchedAt = Date.now(); + if (state.noticeAcknowledged) return { prompt: '' }; + + return { + prompt: [ + '[A2A Observation Scope]', + `process_start processGeneration=${processGenerationId}`, + '当前进程内的 outbound dispatch observation 仅覆盖本 API 进程登记的 dispatch。', + '此前进程的 dispatch 结果仍保留在 canonical History;需要时按需读取 History,不能据此推断旧结果。', + '本说明不会创建任务、唤醒成员或清理当前进程内的 observation。', + '[/A2A Observation Scope]', + ].join('\n'), + }; + } + + acknowledge(scope: CallerDispatchObservationScope, processGenerationId: string): void { + const state = this.bySlot.get(callerDispatchObservationSlotKey(scope)); + if (state?.processGenerationId === processGenerationId) { + state.noticeAcknowledged = true; + state.touchedAt = Date.now(); + } + } +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationOwnerReaper.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationOwnerReaper.ts index a2d9778bf4..c8e35ce41c 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationOwnerReaper.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationOwnerReaper.ts @@ -37,7 +37,7 @@ export interface InvocationOwnerReaperOptions { /** Queue reservations whose tracker owner was installed but later disappeared. */ listStaleProcessingLeases?: (now?: number) => StaleProcessingOwnerLease[]; /** Pre-provider reservations are safe to recover without probing provider lifecycle. */ - reapStalePrestartReservations?: (now?: number) => number; + reapStalePrestartReservations?: (now?: number) => number | Promise; ownerLeaseTtlMs?: number; now?: () => number; log: InvocationOwnerReaperLog; @@ -82,7 +82,7 @@ export class InvocationOwnerReaper { async runOnce(): Promise { const now = this.now(); const result = emptyResult(); - result.prestartReaped = this.options.reapStalePrestartReservations?.(now) ?? 0; + result.prestartReaped = (await this.options.reapStalePrestartReservations?.(now)) ?? 0; const candidates = await this.collectCandidates(now, result); result.scanned = candidates.length; diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts index d9850ad7cf..8a43d994a9 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -1,17 +1,26 @@ /** * InvocationQueue - * Per-thread, per-user FIFO 队列,用于猫猫在跑时排队用户/connector 消息。 + * Per-thread durable work ledger for user/connector/agent/system dispatch. * * 与 InvocationTracker(互斥锁,跟踪活跃调用)互补: * - InvocationTracker: "谁在跑" * - InvocationQueue: "谁在等" * - * scopeKey = `${threadId}:${userId}` — 存储层天然用户隔离。 - * 系统级出队(invocation 完成后)通过 *AcrossUsers 方法跨用户 FIFO。 + * The in-memory maps below are a process-local projection only. QueueLedgerStore + * owns canonical persistence and recovery; every mutation crosses that boundary + * before the cache is updated. */ -import { randomUUID } from 'node:crypto'; -import type { QueueAuthorIntent, QueueTargetAttemptTerminalReason, WaitContinuationCarrierV1 } from '@cat-cafe/shared'; +import { createHash, randomUUID } from 'node:crypto'; +import type { + CatRoutingError, + MessageFrom, + QueueAuthorIntent, + QueueAuthorIntentFallbackReason, + QueueTargetAttemptTerminalReason, + WaitContinuationCarrierV1, +} from '@cat-cafe/shared'; +import { isMessageFrom } from '@cat-cafe/shared'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import type { CallerTraceContext } from '../../../../../infrastructure/telemetry/genai-semconv.js'; import { @@ -19,133 +28,79 @@ import { actionSuccessorFencesMatch, } from '../../../../ball-custody/ActionSuccessorAdmissionContract.js'; import type { CloudDispatchProvenance } from '../../cloud-bridge/types.js'; -import type { QueueBodyExposure, QueuePrestartRetirementIntent } from '../../stores/ports/queued-message-custody.js'; +import type { + AppendMessageInput, + IMessageStore, + LifecycleResponseTerminalPatch, + StoredMessage, +} from '../../stores/ports/MessageStore.js'; import type { ToolExecutionPolicy } from '../../types.js'; +import { compareLifecycleQueueEntries } from './message-lifecycle-queue-order.js'; import type { OwnerAuthProvenance } from './owner-auth-provenance.js'; +import { InMemoryQueueLedgerStore } from './queue-ledger/InMemoryQueueLedgerStore.js'; +import { + type QueueLedgerEntry, + type QueueLedgerStore, + type QueueOwner, + queueOwner, +} from './queue-ledger/QueueLedger.js'; +import { createQueueLedgerAdmission } from './queue-ledger/QueueLedgerAdmission.js'; + +export type QueueEntry = QueueLedgerEntry; + +type QueueAttemptSettlementOutcome = 'handled' | 'failed' | 'interrupted' | 'cancelled'; + +interface AdmittedAttemptEvidence { + awakenedInvocationId?: string; + awakenedAt?: number; + seenInvocationId?: string; + seenAt?: number; +} -export interface QueueEntry { - id: string; +type LifecycleTargetRetirementResult = + | { outcome: 'retired'; rowStatus: 'claimed' | 'queued' | 'absent' } + | { outcome: 'state_changed' }; + +export interface QueueEnqueueInput { threadId: string; userId: string; - /** Internal-only owner authentication provenance; presentation projections must redact it. */ + owner?: QueueOwner; + sourceId?: string; + kind: QueueLedgerEntry['kind']; ownerAuthProvenance: OwnerAuthProvenance; - executionScope?: 'collective-participation' | 'collective-work'; - /** Optional request-level idempotency key for API replay dedup. */ idempotencyKey?: string; content: string; - messageId: string | null; - mergedMessageIds: string[]; - source: 'user' | 'connector' | 'agent'; + messageId?: string | null; + from: MessageFrom; targetCats: string[]; - /** Stable target identity for glass-box projection after individual targets are handled. */ - allTargetCats?: string[]; - /** F264: per-target human author intent. Missing user records use legacy next_work compatibility. */ + routingWarnings?: CatRoutingError[]; authorIntentByCatId?: Record; - /** Notice reached a safe boundary, but the queued body has not been read yet. */ - queuedNotifiedByCatIds?: string[]; - /** Exact child invocation created for this target before prompt-body exposure. */ - queuedAwakenedInvocationIdByCatId?: Record; - /** Immutable child-creation time paired with queuedAwakenedInvocationIdByCatId. */ - queuedAwakenedAtByCatId?: Record; - /** F254 D1.2a: per-cat read marker for queued body exposure. Seen suppresses freshness nag, not work. */ - queuedSeenByCatIds?: string[]; - /** - * F254 D1.2b: per-cat invocation that read the queued body. - * Used only as handled evidence; queuedSeenByCatIds remains the nag-suppression marker. - */ - queuedSeenInvocationIdByCatId?: Record; - /** Exact, append-only child invocation body exposures for durable receipt projection. */ - queuedBodyExposures?: QueueBodyExposure[]; - /** The reading invocation failed/canceled; responsibility remains queued. */ - queuedFailedByCatIds?: string[]; - /** Exact terminal fact for the currently active target attempt. */ - queuedFailureAtByCatId?: Record; - queuedFailureReasonByCatId?: Record; - /** Gate 5: restart-stable identity of the latest durable retry attempt per target. */ - queuedAttemptIdByCatId?: Record; - /** Historical target closure retained while sibling targets remain queued. */ - queuedHandledByCatIds?: string[]; - /** F264: accepted Steer awaiting replacement invocation identity. */ - steerRequestedByCatIds?: string[]; - /** F264: exact replacement invocation after provider-start read binding. */ - steeredInvocationIdByCatId?: Record; - /** - * #1291 Gate 6: process-local exact Batch Steer reservation. - * - * This is deliberately not a second durable ledger. Durable per-message Queue - * custody remains authoritative; this marker only fences dequeue so F175 cannot - * absorb an unselected adjacent entry between reservation and replacement start. - */ - exactSteerBatch?: ExactSteerBatchReservation; - /** Restart-stable exact group fence while pre-start supersession terminalizes durable carriers. */ - prestartRetirement?: QueuePrestartRetirementIntent; intent: string; - status: 'queued' | 'processing'; - createdAt: number; - /** Set when entry transitions to 'processing'. Used for stale-processing TTL. */ - processingStartedAt?: number; - /** F122B: auto-execute without waiting for steer/manual trigger */ - autoExecute: boolean; - /** - * Process-local fence while one persisted A2A trigger is binding its complete - * fan-out custody. Ordinary selectors must not publish any carrier from this - * admission before the canonical MessageStore CAS commits the whole group. - * - * The fence itself is process-local. MessageStore persists the corresponding - * QueueCustodyAdmissionIntent after policy decisions but before these carriers - * are staged, so startup reconstructs only the accepted subset and converges - * rejected targets directly into full-custody failure state. - */ - queueCustodyAdmissionId?: string; - /** F122B: which cat initiated this entry (for A2A/multi_mention display) */ - callerCatId?: string; - /** Source invocation lineage. Parallel invocations of one cat must not coalesce with each other. */ - a2aParentInvocationId?: string; - /** Exact cloud child provenance; never reconstructed from Queue display content. */ - cloudDispatchProvenance?: CloudDispatchProvenance; - /** Multi-mention child must fail visibly if its exact cloud provenance is unavailable. */ - requiresExactCloudDispatchProvenance?: boolean; - /** F134: sender identity for connector group chat messages (used for UI display) */ - senderMeta?: { id: string; name?: string }; - /** F175: queue-internal priority — urgent entries sort before normal in dequeue */ - priority: 'urgent' | 'normal'; - /** F175: origin category for visual grouping */ - sourceCategory?: 'ci' | 'review' | 'conflict' | 'scheduled' | 'a2a' | 'continuation' | 'issue' | 'freshness'; - /** Queue-internal dedup key for agent control-flow work. */ + autoExecute?: boolean; + priority?: QueueLedgerEntry['priority']; + sourceCategory?: QueueLedgerEntry['sourceCategory']; continuationKey?: string; - /** F254 Phase E: typed custody carrier for one persistent catch closure. */ + a2aParentInvocationId?: string; freshnessClosureId?: string; - freshnessRequiredFrontierMessageId?: string; - /** ADR-042 distinct supplement carrier; never aliases freshnessClosureId. */ freshnessSupplementId?: string; freshnessSupplementLineageId?: string; freshnessSupplementSeq?: 1 | 2; readOnlyToolPolicy?: ToolExecutionPolicy; - /** F167 Phase S: persistent action lease generation fence across queue/start/commit. */ actionSuccessorFence?: ActionSuccessorFence; - /** #1291 Gate 4: immutable projection of the canonical wait owner fence. */ waitContinuationCarrier?: WaitContinuationCarrierV1; - /** F175: user drag-reorder position — explicit values override priority in dequeue */ position?: number; - /** F175: skill hint for connector triggers — flows through as promptTags on execution */ suggestedSkill?: string; callerTraceContext?: CallerTraceContext; - /** Explicit A2A trigger message for stream reply threading. */ a2aTriggerMessageId?: string; + cloudDispatchProvenance?: CloudDispatchProvenance; + requiresExactCloudDispatchProvenance?: boolean; + dedupeProcessing?: boolean; } -/** - * The replacement fence must cover the original A2A trigger and every still - * durable body in the carrier. After settlement, the primary body may differ - * from the original trigger, so neither field is a safe stand-in for the - * other. - */ -export function exactA2ASourceMessageIds( - entry: Pick, -): string[] { +export function exactA2ASourceMessageIds(entry: Pick): string[] { return [ ...new Set( - [entry.a2aTriggerMessageId, entry.messageId, ...entry.mergedMessageIds].filter( + [entry.execution.a2aTriggerMessageId, entry.payload.messageId].filter( (messageId): messageId is string => typeof messageId === 'string' && messageId.length > 0, ), ), @@ -160,42 +115,27 @@ export interface EnqueueResult { deduped?: boolean; } -export interface ExactSteerBatchReservation { - reservationId: string; - primaryEntryId: string; - entryIds: string[]; - targetCatId: string; -} +export type EnqueueMessageResult = + | { outcome: 'full' } + | { + outcome: 'enqueued'; + message: StoredMessage; + entry?: QueueEntry; + entries: QueueEntry[]; + queuePosition?: number; + deduped: boolean; + }; -export type ExactUserBatchReservationResult = - | ({ outcome: 'reserved' } & ExactSteerBatchReservation) +export type DurableSteerClaimResult = + | { outcome: 'claimed'; entries: QueueEntry[]; targetCatId: string } | { outcome: 'rejected'; - reason: 'invalid_entry_ids' | 'entry_not_found' | 'entry_ineligible' | 'entries_incompatible'; + reason: 'entry_not_found' | 'entry_processing' | 'entry_ineligible'; }; -export type ExactUserEntryReservationResult = - | ({ outcome: 'reserved' } & ExactSteerBatchReservation) - | { outcome: 'rejected'; reason: 'state_changed' }; - -export interface ActivatedExactSteerReservation { - reservationId: string; - entry: QueueEntry; -} - -export interface QueuedHandledResult { - entryId: string; - threadId: string; - userId: string; - catId: string; - messageIds: string[]; - remainingTargetCats: string[]; - fullyConsumed: boolean; - /** F254 D1.2b: rollback payload used when delivery persistence fails after tentative consume. */ - entrySnapshot?: QueueEntry; - queueIndex?: number; - originalContent?: string; -} +export type DurableMessageClaimResult = + | { outcome: 'claimed'; entries: QueueEntry[] } + | { outcome: 'not_found' | 'processing' }; export interface ActionSuccessorQueueRetirement { entryId: string; @@ -209,16 +149,44 @@ const MAX_QUEUE_DEPTH = 5; /** * Stable InvocationRecord identity for an ActionSuccessor carrier. * - * Queue entry ids are process-local UUIDs. An ActionSuccessor retry must instead - * bind durable admission to the lease/generation idempotency key that survives a - * restart, otherwise a lost in-memory queue can execute the same carrier twice. + * Queue row ids are derived from a durable producer identity. ActionSuccessor + * supplies the lease/generation identity so retries converge on the same row. */ export function actionSuccessorInvocationIdempotencyKey(queueIdempotencyKey: string): string { return `action-successor:${queueIdempotencyKey}`; } -export function isSystemPinnedQueueEntry(entry: Pick): boolean { - return entry.source === 'agent' && entry.sourceCategory === 'continuation'; +export function queueEntrySource(entry: Pick): 'user' | 'connector' | 'agent' | 'system' { + if (entry.from.kind === 'user') return 'user'; + if (entry.from.kind === 'agent') return 'agent'; + if (entry.from.kind === 'system') return 'system'; + return 'connector'; +} + +export function queueEntryCallerCatId(entry: Pick): string | undefined { + return entry.from.kind === 'agent' ? entry.from.catId : undefined; +} + +export function queueEntrySenderMeta( + entry: Pick, +): { readonly id: string; readonly name?: string } | undefined { + return entry.from.kind === 'external' ? entry.from.sender : undefined; +} + +export function queueEntryTargetCats(entry: Pick): string[] { + return [...entry.targets]; +} + +export function queueEntryOwnerId(entry: Pick): string { + return entry.owner.kind === 'user' ? entry.owner.userId : `system:${entry.owner.service}`; +} + +export function queueEntryMessageIds(entry: Pick): string[] { + return entry.payload.messageId ? [entry.payload.messageId] : []; +} + +export function isSystemPinnedQueueEntry(entry: Pick): boolean { + return entry.from.kind === 'agent' && entry.sourceCategory === 'continuation'; } /** @@ -226,37 +194,35 @@ export function isSystemPinnedQueueEntry(entry: Pick, - catId: string, -): boolean { - return !entry.queueCustodyAdmissionId && isQueueTargetPending(entry, catId); +export function isOrdinaryQueueTargetEligible(entry: Pick, catId: string): boolean { + return ( + (entry.status === 'queued' || entry.status === 'claimed') && + (entry.targets.length === 0 || entry.targets.includes(catId)) + ); } -/** Pending ownership includes a carrier fenced behind an in-flight custody CAS. */ -function isQueueTargetPending(entry: Pick, catId: string): boolean { - return entry.targetCats.includes(catId) && !entry.queuedFailedByCatIds?.includes(catId); +function isQueueTargetPending(entry: Pick, catId: string): boolean { + return entry.status !== 'terminal' && entry.targets.includes(catId); } export class InvocationQueue { + static readonly STALE_PROCESSING_THRESHOLD_MS = 600_000; + private readonly log = createModuleLogger('invocation-queue'); private queues = new Map(); + /** + * Process-local handoff snapshots after Queue has removed the dispatched + * target. These are not Queue truth and are never hydrated or persisted; + * ActiveRun + History own execution and terminal lifecycle. + */ + private readonly admittedEntries = new Map(); + /** Runtime-only prompt evidence keyed by admitted snapshots. It is never serialized into Queue rows. */ + private readonly admittedAttemptEvidence = new WeakMap>(); + private lastEnqueuedAt = 0; + /** Claimed rows remain reversible until tracker admission owns execution. */ + private readonly ledgerClaimIds = new Map(); - /** Original content per entryId at enqueue time, for rollbackEnqueue */ - private originalContents = new Map(); - - /** Rollback snapshots for the short reservation→preempt→dequeue window. */ - private exactSteerReservations = new Map< - string, - { - threadId: string; - userId: string; - primaryEntryId: string; - targetCatId: string; - entries: Map; - phase: 'reserved' | 'preempting' | 'activated'; - } - >(); + constructor(private readonly ledgerStore: QueueLedgerStore = new InMemoryQueueLedgerStore()) {} private scopeKey(threadId: string, userId: string): string { return `${threadId}:${userId}`; @@ -275,89 +241,55 @@ export class InvocationQueue { return q; } - private static readonly PRIORITY_RANK: Record = { urgent: 0, normal: 1 }; - - private static normalizedPriority(input: { - source: QueueEntry['source']; - sourceCategory?: QueueEntry['sourceCategory']; - priority?: QueueEntry['priority']; - }): QueueEntry['priority'] { - return input.source === 'agent' && input.sourceCategory !== 'continuation' - ? 'normal' - : (input.priority ?? 'normal'); - } - - /** F175: multi-dimensional entry comparator for dequeue ordering. - * Position is scoped to same-user entries to prevent cross-user queue-jumping in shared threads. */ - private static compareEntries(a: QueueEntry, b: QueueEntry): number { - const aPinned = isSystemPinnedQueueEntry(a); - const bPinned = isSystemPinnedQueueEntry(b); - if (aPinned && !bPinned) return -1; - if (!aPinned && bPinned) return 1; - - if (a.userId === b.userId) { - const aHasPos = a.position !== undefined; - const bHasPos = b.position !== undefined; - if (aHasPos && !bHasPos) return -1; - if (!aHasPos && bHasPos) return 1; - if (aHasPos && bHasPos) return a.position! - b.position!; - } - const pDiff = (InvocationQueue.PRIORITY_RANK[a.priority] ?? 1) - (InvocationQueue.PRIORITY_RANK[b.priority] ?? 1); - if (pDiff !== 0) return pDiff; - return a.createdAt - b.createdAt; - } - - /** F175: set explicit dequeue position for drag-reorder. */ - setPosition(threadId: string, userId: string, entryId: string, position: number): boolean { - const e = this.findEntry(threadId, userId, entryId); - if (!e || e.status !== 'queued') return false; - if (e.exactSteerBatch) return false; - if (isSystemPinnedQueueEntry(e)) return false; - e.position = position; - return true; - } - - /** - * 预留队列位。容量检查在此完成。 - * 同源同目标的连续消息自动合并。 - */ - enqueue( - input: Omit< - QueueEntry, - | 'id' - | 'status' - | 'createdAt' - | 'mergedMessageIds' - | 'messageId' - | 'autoExecute' - | 'callerCatId' - | 'priority' - | 'position' - | 'suggestedSkill' - | 'ownerAuthProvenance' - | 'exactSteerBatch' - > & { - ownerAuthProvenance: OwnerAuthProvenance; - autoExecute?: boolean; - callerCatId?: string; - priority?: 'urgent' | 'normal'; - suggestedSkill?: string; - messageId?: string | null; - /** Defaults true for request replay dedupe; connector coalescing can opt out for in-flight entries. */ - dedupeProcessing?: boolean; - }, - ): EnqueueResult { - const ownerAuthProvenance: unknown = input.ownerAuthProvenance; + private static persistentSourceId(input: QueueEnqueueInput): string { + const sourceId = + input.sourceId ?? + input.messageId ?? + input.idempotencyKey ?? + input.continuationKey ?? + input.freshnessSupplementId ?? + input.freshnessClosureId; + if (!sourceId) throw new Error('durable Queue admission requires a persistent producer identity'); + return sourceId; + } + + /** Enforce the canonical Queue admission contract on both live and restart paths. */ + private static requireAdmissionContract(input: { + kind: unknown; + from: unknown; + userId?: unknown; + targetCats: unknown; + messageId?: unknown; + a2aTriggerMessageId?: unknown; + ownerAuthProvenance: unknown; + }): { kind: QueueEntry['kind']; ownerAuthProvenance: OwnerAuthProvenance } { + const kind = input.kind; + if (kind !== 'conversation_input' && kind !== 'message_wake' && kind !== 'private_input') { + throw new Error('kind must be explicit on every Queue producer'); + } if ( - input.executionScope && - (input.source !== 'connector' || - input.targetCats.length !== 1 || - (input.executionScope === 'collective-participation' - ? ownerAuthProvenance !== 'unknown' - : input.executionScope !== 'collective-work' || ownerAuthProvenance !== 'strict')) + !Array.isArray(input.targetCats) || + input.targetCats.some((catId) => typeof catId !== 'string' || catId.length === 0) || + new Set(input.targetCats).size !== input.targetCats.length ) { - throw new Error('Collective execution scope requires one exact cat and its matching owner provenance'); + throw new Error('targetCats must contain unique non-empty target ids'); + } + if (kind !== 'conversation_input' && input.targetCats.length === 0) { + throw new Error(`${kind} must have an exact target`); + } + if (!isMessageFrom(input.from)) { + throw new Error('from must be explicit on every Queue producer'); + } + if (input.from.kind === 'user' && input.from.userId !== input.userId) { + throw new Error('Queue user sender must match the owner userId'); + } + if (kind === 'private_input' && input.messageId != null) { + throw new Error('private_input cannot reference a public History message'); + } + if (kind === 'message_wake' && !input.messageId && !input.a2aTriggerMessageId) { + throw new Error('message_wake must reference an existing History message'); } + const ownerAuthProvenance = input.ownerAuthProvenance; if ( ownerAuthProvenance !== 'strict' && ownerAuthProvenance !== 'compatibility_fallback' && @@ -365,90 +297,48 @@ export class InvocationQueue { ) { throw new Error('ownerAuthProvenance must be explicit on every Queue producer'); } - const key = this.scopeKey(input.threadId, input.userId); - const q = this.getOrCreate(key); - const priority = InvocationQueue.normalizedPriority(input); - const dedupeProcessing = input.dedupeProcessing ?? true; + return { kind, ownerAuthProvenance }; + } - // Request replay dedupe: if an active entry already exists for this key in this scope, - // return it instead of creating a second queue row. - if (input.idempotencyKey) { - const existing = q.find( - (entry) => - entry.idempotencyKey === input.idempotencyKey && - (entry.status === 'queued' || (dedupeProcessing && entry.status === 'processing')), - ); - if (existing) { - if ( - (input.executionScope || existing.executionScope) && - (input.executionScope !== existing.executionScope || - input.ownerAuthProvenance !== existing.ownerAuthProvenance || - input.content !== existing.content || - input.targetCats[0] !== existing.targetCats[0]) - ) - throw new Error('Queue execution scope conflicts with existing request'); - if (existing.status === 'queued') { - const upgradedPriority = - (InvocationQueue.PRIORITY_RANK[priority] ?? 1) < (InvocationQueue.PRIORITY_RANK[existing.priority] ?? 1); - if (upgradedPriority) { - existing.priority = priority; - } - if (input.suggestedSkill && (upgradedPriority || !existing.suggestedSkill)) { - existing.suggestedSkill = input.suggestedSkill; - } - if (input.sourceCategory && !existing.sourceCategory) { - existing.sourceCategory = input.sourceCategory; - } - } - const position = q.findIndex((entry) => entry.id === existing.id); - return { - outcome: 'enqueued', - entry: { ...existing }, - queuePosition: position >= 0 ? position + 1 : undefined, - deduped: true, - }; - } - } + private nextEnqueuedAt(): number { + const now = Date.now(); + this.lastEnqueuedAt = Math.max(now, this.lastEnqueuedAt + 1); + return this.lastEnqueuedAt; + } - // F175: capacity check — only user messages are depth-limited - if (input.source === 'user') { - const userQueuedCount = q.filter((e) => e.status === 'queued' && e.source === 'user').length; - if (userQueuedCount >= MAX_QUEUE_DEPTH) { - return { outcome: 'full' }; - } - } + /** RFC #1356's only Queue comparator: position → priority → FIFO → stable id. */ + private static compareEntries(a: QueueEntry, b: QueueEntry): number { + return compareLifecycleQueueEntries( + { id: a.id, priority: a.priority, enqueuedAt: a.enqueuedAt, position: a.position }, + { id: b.id, priority: b.priority, enqueuedAt: b.enqueuedAt, position: b.position }, + ); + } - const entry: QueueEntry = { - id: randomUUID(), + private createLedgerRows( + input: QueueEnqueueInput, + sourceId: string, + enqueuedAt: number, + messageId?: string, + ): QueueLedgerEntry[] { + InvocationQueue.requireAdmissionContract({ ...input, messageId: messageId ?? input.messageId }); + return createQueueLedgerAdmission({ + sourceId, threadId: input.threadId, - userId: input.userId, - ownerAuthProvenance, - ...(input.executionScope ? { executionScope: input.executionScope } : {}), - idempotencyKey: input.idempotencyKey, + owner: queueOwner(input), + kind: input.kind, + from: input.from, + targetCatIds: input.targetCats, content: input.content, - messageId: input.messageId ?? null, - mergedMessageIds: [], - source: input.source, - targetCats: [...input.targetCats], - allTargetCats: [...input.targetCats], - ...(input.authorIntentByCatId ? { authorIntentByCatId: structuredClone(input.authorIntentByCatId) } : {}), + ...(messageId ? { messageId } : {}), + ...(input.routingWarnings ? { routingWarnings: input.routingWarnings } : {}), + ...(input.authorIntentByCatId ? { authorIntentByCatId: input.authorIntentByCatId } : {}), intent: input.intent, - status: 'queued', - createdAt: Date.now(), - autoExecute: input.autoExecute ?? false, - queueCustodyAdmissionId: input.queueCustodyAdmissionId, - callerCatId: input.callerCatId, - a2aParentInvocationId: input.a2aParentInvocationId, - cloudDispatchProvenance: input.cloudDispatchProvenance - ? structuredClone(input.cloudDispatchProvenance) - : undefined, - requiresExactCloudDispatchProvenance: input.requiresExactCloudDispatchProvenance, - senderMeta: input.senderMeta, - priority, + ownerAuthProvenance: input.ownerAuthProvenance, + autoExecute: input.autoExecute, + priority: input.priority, sourceCategory: input.sourceCategory, - continuationKey: input.continuationKey, + a2aParentInvocationId: input.a2aParentInvocationId, freshnessClosureId: input.freshnessClosureId, - freshnessRequiredFrontierMessageId: input.freshnessRequiredFrontierMessageId, freshnessSupplementId: input.freshnessSupplementId, freshnessSupplementLineageId: input.freshnessSupplementLineageId, freshnessSupplementSeq: input.freshnessSupplementSeq, @@ -457,1388 +347,1372 @@ export class InvocationQueue { waitContinuationCarrier: input.waitContinuationCarrier, suggestedSkill: input.suggestedSkill, callerTraceContext: input.callerTraceContext, - a2aTriggerMessageId: input.a2aTriggerMessageId, - position: undefined, - }; - q.push(entry); - this.originalContents.set(entry.id, input.content); - return { outcome: 'enqueued', entry: { ...entry }, queuePosition: q.length }; + a2aTriggerMessageId: input.a2aTriggerMessageId ?? (input.kind === 'message_wake' ? messageId : undefined), + cloudDispatchProvenance: input.cloudDispatchProvenance, + requiresExactCloudDispatchProvenance: input.requiresExactCloudDispatchProvenance, + enqueuedAt, + }); } - /** Check if any entry in the thread already carries this messageId (connector retry dedup). */ - hasEntryWithMessageId(threadId: string, messageId: string): boolean { - return this.findEntryWithMessageId(threadId, messageId) !== null; + private static projectLedgerEntry(entry: QueueLedgerEntry): QueueEntry { + return structuredClone(entry); } - /** Return the exact Queue carrier for a persisted message across user scopes. */ - findEntryWithMessageId(threadId: string, messageId: string): QueueEntry | null { - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - const entry = q.find((e) => e.messageId === messageId || e.mergedMessageIds?.includes(messageId)); - if (entry) return { ...entry }; + private cacheLedgerEntries(entries: readonly QueueLedgerEntry[]): QueueEntry[] { + const projected: QueueEntry[] = []; + for (const row of entries) { + if (row.status === 'terminal') { + this.removeCachedEntry(row.threadId, row.id); + continue; + } + const entry = InvocationQueue.projectLedgerEntry(row); + const queue = this.getOrCreate(this.scopeKey(entry.threadId, queueEntryOwnerId(entry))); + const index = queue.findIndex((candidate) => candidate.id === entry.id); + if (index >= 0) queue[index] = entry; + else queue.push(entry); + projected.push(structuredClone(entry)); } - return null; + return projected; } - /** Backfill messageId on a new entry (null → value). */ - backfillMessageId(threadId: string, userId: string, entryId: string, messageId: string): void { - const e = this.findEntry(threadId, userId, entryId); - if (!e) return; - if (!e.messageId) { - e.messageId = messageId; - return; - } - if (e.messageId !== messageId && !e.mergedMessageIds.includes(messageId)) { - e.mergedMessageIds.push(messageId); + async hydrateFromLedger(messageStore?: Pick): Promise { + this.queues.clear(); + this.admittedEntries.clear(); + this.ledgerClaimIds.clear(); + let count = 0; + for (const threadId of await this.ledgerStore.listThreadIds()) { + const rows: QueueLedgerEntry[] = []; + for (const row of await this.ledgerStore.list(threadId)) { + if (row.status !== 'claimed' || !row.claimId) { + rows.push(row); + continue; + } + rows.push(...(await this.reconcileClaimedLedgerEntry(row, messageStore))); + } + count += this.cacheLedgerEntries(rows).length; } + return count; } - /** Rollback an enqueued entry — remove entirely. */ - rollbackEnqueue(threadId: string, userId: string, entryId: string): void { - this.remove(threadId, userId, entryId); - this.originalContents.delete(entryId); - } - - /** - * Publish every carrier in one custody admission after its full durable CAS. - * Validation is complete before the first mutation, so ordinary selectors see - * either the fenced group or the committed group, never a partially released set. - * A duplicate recovery may observe the whole group already committed; that exact - * all-unfenced state is an idempotent success, while mixed/foreign fences fail. - */ - commitQueueCustodyAdmission( + private async reconcileClaimedLedgerEntry( + row: QueueLedgerEntry, + messageStore?: Pick, + ): Promise { + if (row.status !== 'claimed' || !row.claimId) return [row]; + const source = row.payload.messageId ? await messageStore?.getById(row.payload.messageId) : null; + if (source?.recall || source?.deliveryStatus === 'canceled') { + const terminal = await this.ledgerStore.commit(row.threadId, row.id, row.claimId, 'withdrawn', Date.now()); + return terminal.outcome === 'updated' ? [terminal.entry] : []; + } + const dispatchedTargets = new Set(source?.lifecycle?.dispatchRefs?.map((ref) => ref.targetId) ?? []); + const claimedTargets = row.claimedTargetIds ?? []; + if (claimedTargets.length > 0 && claimedTargets.every((targetId) => dispatchedTargets.has(targetId))) { + // A response lifecycle is the durable receiver. A crash after that + // History commit may leave the short Queue claim behind; retire only + // the targets proven dispatched and preserve every pending sibling. + const committed = await this.ledgerStore.commit(row.threadId, row.id, row.claimId, 'processing', Date.now()); + if (committed.outcome !== 'updated') return []; + const remaining = await this.ledgerStore.get(row.threadId, row.id); + return remaining ? [remaining] : []; + } + const restored = await this.ledgerStore.restore(row.threadId, row.id, row.claimId); + if (restored.outcome !== 'updated') return []; + const staleDispatchedTargets = restored.entry.targets.filter((targetId) => dispatchedTargets.has(targetId)); + if (staleDispatchedTargets.length === 0) return [restored.entry]; + const reconciled = await this.ledgerStore.reconcileTargets(row.threadId, row.id, [], staleDispatchedTargets, {}); + return 'entry' in reconciled && reconciled.entry ? [reconciled.entry] : []; + } + + /** Reconcile short claims after History became durable but Queue retirement failed. */ + async reconcileClaimedLifecycleTargets( threadId: string, - userId: string, - admissionId: string, entryIds: readonly string[], - ): boolean { - const distinctEntryIds = [...new Set(entryIds)]; - if (!admissionId || distinctEntryIds.length !== entryIds.length) return false; - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return false; - const byId = new Map(q.map((entry) => [entry.id, entry])); - const entries = distinctEntryIds.map((entryId) => byId.get(entryId)); - if (entries.some((entry) => !entry || entry.status !== 'queued')) { - return false; - } - const alreadyCommitted = entries.every((entry) => entry?.queueCustodyAdmissionId === undefined); - const ownsAdmission = entries.every((entry) => entry?.queueCustodyAdmissionId === admissionId); - if (!alreadyCommitted && !ownsAdmission) return false; - for (const entry of entries as QueueEntry[]) delete entry.queueCustodyAdmissionId; - return true; + messageStore: Pick, + ): Promise { + let converged = true; + for (const entryId of entryIds) { + const row = await this.ledgerStore.get(threadId, entryId); + if (!row) { + this.ledgerClaimIds.delete(entryId); + this.removeCachedEntry(threadId, entryId); + this.forgetAdmittedEntries(threadId, entryId); + continue; + } + if (row.status !== 'claimed' || !row.claimId) { + this.ledgerClaimIds.delete(entryId); + this.removeCachedEntry(threadId, entryId); + this.forgetAdmittedEntries(threadId, entryId); + if (row.status === 'queued') this.cacheLedgerEntries([row]); + else converged = false; + continue; + } + const remaining = await this.reconcileClaimedLedgerEntry(row, messageStore); + this.ledgerClaimIds.delete(entryId); + this.removeCachedEntry(threadId, entryId); + this.forgetAdmittedEntries(threadId, entryId); + this.cacheLedgerEntries(remaining); + const durable = await this.ledgerStore.get(threadId, entryId); + if (durable?.status === 'claimed') converged = false; + } + return converged; + } + + async enqueueDurable(input: QueueEnqueueInput): Promise { + const sourceId = InvocationQueue.persistentSourceId(input); + const enqueuedAt = this.nextEnqueuedAt(); + const rows = this.createLedgerRows(input, sourceId, enqueuedAt, input.messageId ?? undefined); + const result = await this.ledgerStore.enqueue(rows, input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined); + if (result.outcome === 'full') return { outcome: 'full' }; + if (result.outcome === 'conflict') throw new Error(`Queue admission identity conflict: ${sourceId}`); + const entries = this.cacheLedgerEntries(result.entries); + const primary = entries[0]; + return { + outcome: 'enqueued', + ...(primary ? { entry: primary } : {}), + entries, + queuePosition: primary + ? this.list(primary.threadId, queueEntryOwnerId(primary)).findIndex((entry) => entry.id === primary.id) + 1 + : undefined, + deduped: result.outcome === 'replayed', + }; } - /** Remove and return the first entry (FIFO). */ - dequeue(threadId: string, userId: string): QueueEntry | null { - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q || q.length === 0) return null; - return q.shift()!; + /** Synchronous durable admission for the in-memory store used by local/test hosts. */ + enqueueDurableNow(input: QueueEnqueueInput): EnqueueResult & { entries?: QueueEntry[] } { + if (!(this.ledgerStore instanceof InMemoryQueueLedgerStore)) { + throw new Error('synchronous durable Queue admission requires the in-memory ledger'); + } + const sourceId = InvocationQueue.persistentSourceId(input); + const rows = this.createLedgerRows(input, sourceId, this.nextEnqueuedAt(), input.messageId ?? undefined); + const result = this.ledgerStore.enqueueNow(rows, input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined); + if (result.outcome === 'full') return { outcome: 'full' }; + if (result.outcome === 'conflict') throw new Error(`Queue admission identity conflict: ${sourceId}`); + const entries = this.cacheLedgerEntries(result.entries); + const primary = entries[0]; + return { + outcome: 'enqueued', + ...(primary ? { entry: primary } : {}), + entries, + queuePosition: primary + ? this.list(primary.threadId, queueEntryOwnerId(primary)).findIndex((entry) => entry.id === primary.id) + 1 + : undefined, + deduped: result.outcome === 'replayed', + }; } - /** Look at the first entry without removing. */ - peek(threadId: string, userId: string): QueueEntry | null { - const q = this.queues.get(this.scopeKey(threadId, userId)); - return q?.[0] ?? null; + /** One storage transaction for a queued Message and its complete Queue fan-out. */ + async appendAndEnqueueDurable( + messageStore: IMessageStore, + message: AppendMessageInput, + input: QueueEnqueueInput, + ): Promise { + if ( + (message.threadId ?? 'default') !== input.threadId || + message.userId !== input.userId || + message.content !== input.content || + JSON.stringify(message.from) !== JSON.stringify(input.from) || + (message.deliveryStatus !== 'queued' && !(message.from.kind === 'agent' && message.deliveryStatus === undefined)) + ) { + throw new Error('atomic Queue admission message does not match its execution work item'); + } + const enqueuedAt = this.nextEnqueuedAt(); + const result = await messageStore.appendWithQueueLedgerAdmission( + message, + (messageId) => this.createLedgerRows(input, messageId, enqueuedAt, messageId), + this.ledgerStore, + input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined, + ); + if (result.outcome === 'full') return { outcome: 'full' }; + + const projected = this.cacheLedgerEntries(result.entries); + const expected = this.createLedgerRows(input, result.message.id, enqueuedAt, result.message.id); + const primary = + projected[0] ?? + (() => { + const first = expected[0]; + if (!first) return undefined; + const owner = queueOwner(input); + const ownerUserId = owner.kind === 'user' ? owner.userId : `system:${owner.service}`; + return this.findEntry(input.threadId, ownerUserId, first.id); + })(); + return { + outcome: 'enqueued', + message: result.message, + ...(primary ? { entry: { ...primary } } : {}), + entries: projected, + queuePosition: primary + ? this.list(primary.threadId, queueEntryOwnerId(primary)).findIndex((entry) => entry.id === primary.id) + 1 + : undefined, + deduped: result.deduped, + }; } - /** Remove a specific entry by id. Returns null if not found. */ - remove(threadId: string, userId: string, entryId: string): QueueEntry | null { - let q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return null; - let idx = q.findIndex((e) => e.id === entryId); - if (idx === -1) return null; - const batch = q[idx]?.exactSteerBatch; - if (batch) { - this.releaseExactUserBatch(threadId, userId, batch.reservationId); - q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return null; - idx = q.findIndex((entry) => entry.id === entryId); - if (idx === -1) return null; + /** One storage transaction for a terminal response bubble and its outbound A2A fan-out. */ + async terminalizeResponseAndEnqueueDurable( + messageStore: IMessageStore, + responseMessageId: string, + terminalPatch: LifecycleResponseTerminalPatch, + input: QueueEnqueueInput, + ): Promise { + const source = await messageStore.getById(responseMessageId); + if ( + !source || + source.threadId !== input.threadId || + source.userId !== input.userId || + source.content === undefined || + JSON.stringify(source.from) !== JSON.stringify(input.from) + ) { + throw new Error('lifecycle Queue source does not match its execution work item'); + } + const canonicalInput: QueueEnqueueInput = { + ...input, + sourceId: responseMessageId, + messageId: responseMessageId, + }; + const rows = this.createLedgerRows(canonicalInput, responseMessageId, source.timestamp, responseMessageId); + const result = await messageStore.commitLifecycleResponseTerminalWithQueueLedgerAdmission( + responseMessageId, + terminalPatch, + rows, + this.ledgerStore, + input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined, + ); + if (result.kind === 'full') return { outcome: 'full' }; + if (result.kind !== 'applied' && result.kind !== 'replayed') { + throw new Error( + `lifecycle response terminal Queue admission conflict: ${result.kind}:${'reason' in result ? result.reason : 'missing'}`, + ); } - this.originalContents.delete(entryId); + const projected = this.cacheLedgerEntries(result.entries); + const primary = projected[0]; + return { + outcome: 'enqueued', + message: result.message, + ...(primary ? { entry: primary } : {}), + entries: projected, + queuePosition: primary + ? this.list(primary.threadId, queueEntryOwnerId(primary)).findIndex((entry) => entry.id === primary.id) + 1 + : undefined, + deduped: result.ledgerReplayed, + }; + } - return q.splice(idx, 1)[0] ?? null; + /** Atomically adopt one already-persisted connector message into the ledger. */ + async enqueueExistingMessageDurable( + messageStore: IMessageStore, + messageId: string, + input: QueueEnqueueInput, + ): Promise { + const source = await messageStore.getById(messageId); + if ( + !source || + source.threadId !== input.threadId || + source.userId !== input.userId || + source.content !== input.content || + JSON.stringify(source.from) !== JSON.stringify(input.from) + ) { + throw new Error('existing Queue source does not match its execution work item'); + } + const enqueuedAt = this.nextEnqueuedAt(); + const rows = this.createLedgerRows(input, messageId, enqueuedAt, messageId); + const result = await messageStore.enqueueExistingMessageWithQueueLedgerAdmission( + messageId, + rows, + this.ledgerStore, + input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined, + ); + if (result.outcome === 'full') return { outcome: 'full' }; + const projected = this.cacheLedgerEntries(result.entries); + const primary = projected[0]; + return { + outcome: 'enqueued', + message: result.message, + ...(primary ? { entry: primary } : {}), + entries: projected, + queuePosition: primary + ? this.list(primary.threadId, queueEntryOwnerId(primary)).findIndex((entry) => entry.id === primary.id) + 1 + : undefined, + deduped: result.deduped, + }; } - /** Retire every process-local carrier for one persisted action fence. */ - retireActionSuccessorFence(fence: ActionSuccessorFence): ActionSuccessorQueueRetirement[] { - const retired: ActionSuccessorQueueRetirement[] = []; - for (const [scope, queue] of this.queues) { - for (let index = queue.length - 1; index >= 0; index -= 1) { - const entry = queue[index]; - if (!entry || !actionSuccessorFencesMatch(entry.actionSuccessorFence, fence)) continue; - queue.splice(index, 1); - this.originalContents.delete(entry.id); - retired.push({ - entryId: entry.id, - threadId: entry.threadId, - userId: entry.userId, - messageIds: exactA2ASourceMessageIds(entry), - }); - } - if (queue.length === 0) this.queues.delete(scope); + /** + * Atomically verify the selected source entry, bind an optional + * targetless anchor, and add missing siblings. A terminal race therefore + * rejects the whole Steer mapping instead of leaving a partial fan-out. + */ + async mapQueuedMessageTargetsDurable( + messageStore: Pick, + messageId: string, + entryId: string, + bindTargetCatId: string, + expectedQueuedEntryIds: readonly string[], + input: QueueEnqueueInput, + ) { + const source = await messageStore.getById(messageId); + if ( + !source || + source.threadId !== input.threadId || + source.userId !== input.userId || + source.content !== input.content || + JSON.stringify(source.from) !== JSON.stringify(input.from) || + (source.deliveryStatus !== 'queued' && source.deliveryStatus !== 'delivered') + ) { + throw new Error('queued Message does not match its target fan-out expansion'); + } + const anchor = await this.ledgerStore.get(input.threadId, entryId); + if (!anchor) return { outcome: 'not_found' as const, entries: [] }; + const siblingInput: QueueEnqueueInput = { ...input, sourceId: messageId, messageId }; + const siblingRows = + siblingInput.targetCats.length > 0 + ? this.createLedgerRows(siblingInput, messageId, anchor.enqueuedAt, messageId).map((row) => ({ + ...row, + ...(anchor.position !== undefined ? { position: anchor.position } : {}), + })) + : []; + const result = await this.ledgerStore.expandTargets( + input.threadId, + entryId, + bindTargetCatId, + expectedQueuedEntryIds, + siblingRows, + ); + if (result.outcome === 'expanded' || result.outcome === 'replayed') { + return { ...result, entries: this.cacheLedgerEntries(result.entries) }; } - return retired; + return result; } - /** Read the exact process carriers before durable custody retirement. */ - listActionSuccessorFence(fence: ActionSuccessorFence): ActionSuccessorQueueRetirement[] { - const matches: ActionSuccessorQueueRetirement[] = []; + /** + * Apply the user's explicit Steer delta to the current pending target set. + * Targets delivered while the modal was open are absent from Queue already; + * this method therefore never reconstructs them from a stale browser list. + */ + async reconcileQueuedMessageTargetsDurable( + threadId: string, + userId: string, + entryId: string, + addTargetIds: readonly string[], + removeTargetIds: readonly string[], + authorIntentByTarget: Readonly>, + ) { + const current = await this.ledgerStore.get(threadId, entryId); + if (current && queueEntryOwnerId(current) !== userId) { + throw new Error('Queue target reconciliation owner mismatch'); + } + const result = await this.ledgerStore.reconcileTargets( + threadId, + entryId, + addTargetIds, + removeTargetIds, + authorIntentByTarget, + ); + if (result.outcome === 'updated' || result.outcome === 'replayed') { + this.removeCachedEntry(threadId, entryId); + if (result.entry) this.cacheLedgerEntries([result.entry]); + } + return result; + } + + private findEntryAcrossUsers(threadId: string, entryId: string): QueueEntry | undefined { for (const queue of this.queues.values()) { - for (const entry of queue) { - if (!actionSuccessorFencesMatch(entry.actionSuccessorFence, fence)) continue; - matches.push({ - entryId: entry.id, - threadId: entry.threadId, - userId: entry.userId, - messageIds: exactA2ASourceMessageIds(entry), - }); - } + if (!this.queueMatchesThread(queue, threadId)) continue; + const entry = queue.find((candidate) => candidate.id === entryId); + if (entry) return entry; } - return matches; + return undefined; } - /** Shallow copy of all entries sorted by dequeue priority (comparator order). */ - list(threadId: string, userId: string): QueueEntry[] { - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return []; - return [...q].sort(InvocationQueue.compareEntries); + private async claimLedgerEntry(entry: QueueEntry, selectedTargetCatId?: string): Promise { + const claimId = randomUUID(); + const claimedAt = Date.now(); + // The selected target has two jobs in the store: bind a targetless row on + // first dispatch, or claim only that target from an existing target set. + // Omitting it for targeted rows accidentally claimed every sibling. + const claimed = await this.ledgerStore.claim(entry.threadId, entry.id, claimId, claimedAt, selectedTargetCatId); + if (claimed.outcome !== 'claimed') return null; + const [projected] = this.cacheLedgerEntries(claimed.entries); + if (!projected) return null; + this.ledgerClaimIds.set(projected.id, claimId); + return structuredClone(projected); } - /** Stable deep snapshot for persistence/rollback boundaries. */ - getEntrySnapshot(threadId: string, userId: string, entryId: string): QueueEntry | null { - const entry = this.findEntry(threadId, userId, entryId); - return entry ? InvocationQueue.cloneEntry(entry) : null; + private rememberAdmittedEntry(entry: QueueEntry): void { + const entries = this.admittedEntries.get(entry.id) ?? []; + const admitted = structuredClone(entry); + entries.push(admitted); + this.admittedAttemptEvidence.set(admitted, new Map()); + this.admittedEntries.set(entry.id, entries); } - /** Resolve a durable carrier by its globally unique entry id without trusting a source-thread projection. */ - getEntrySnapshotForUserById(userId: string, entryId: string): QueueEntry | null { - for (const q of this.queues.values()) { - const entry = q.find((candidate) => candidate.id === entryId && candidate.userId === userId); - if (entry) return InvocationQueue.cloneEntry(entry); - } - return null; + private findAdmittedEntry( + threadId: string, + entryId: string, + targetCatId?: string, + userId?: string, + ): QueueEntry | undefined { + return this.admittedEntries + .get(entryId) + ?.find( + (entry) => + entry.threadId === threadId && + (userId === undefined || queueEntryOwnerId(entry) === userId) && + (targetCatId === undefined || entry.targets.includes(targetCatId)), + ); } /** - * Restore a pre-mutation snapshot only while the exact post-mutation state is - * still current. This CAS boundary prevents a failed durable side effect from - * rolling back a later concurrent Queue update. + * Read process-local provider attempts carrying any of the exact source + * messages. Admitted attempts are deliberately absent from the durable + * pending Queue, so execution evidence must never rediscover them through + * the ledger. */ - restoreEntrySnapshotIfUnchanged(expectedCurrent: QueueEntry, replacement: QueueEntry): boolean { - if ( - expectedCurrent.id !== replacement.id || - expectedCurrent.threadId !== replacement.threadId || - expectedCurrent.userId !== replacement.userId - ) { - throw new Error('Queue snapshot restore identity mismatch'); - } - const q = this.queues.get(this.scopeKey(expectedCurrent.threadId, expectedCurrent.userId)); - const index = q?.findIndex((entry) => entry.id === expectedCurrent.id) ?? -1; - if (!q || index < 0) return false; - const current = q[index]; - if (JSON.stringify(current) !== JSON.stringify(expectedCurrent)) return false; - q[index] = InvocationQueue.cloneEntry(replacement); - return true; - } - - /** Remove one exact Queue snapshot while it is still current. */ - removeEntrySnapshotIfUnchanged(expectedCurrent: QueueEntry): boolean { - const q = this.queues.get(this.scopeKey(expectedCurrent.threadId, expectedCurrent.userId)); - const index = q?.findIndex((entry) => entry.id === expectedCurrent.id) ?? -1; - if (!q || index < 0) return false; - const current = q[index]; - if (JSON.stringify(current) !== JSON.stringify(expectedCurrent)) return false; - q.splice(index, 1); - this.originalContents.delete(expectedCurrent.id); - return true; + findAdmittedEntriesForMessages( + threadId: string, + messageIds: readonly string[], + userId?: string, + targetCatId?: string, + ): QueueEntry[] { + const wanted = new Set(messageIds); + if (wanted.size === 0) return []; + return [...this.admittedEntries.values()] + .flat() + .filter( + (entry) => + entry.threadId === threadId && + (userId === undefined || queueEntryOwnerId(entry) === userId) && + (targetCatId === undefined || entry.targets.includes(targetCatId)) && + queueEntryMessageIds(entry).some((messageId) => wanted.has(messageId)), + ) + .map((entry) => structuredClone(entry)); } - /** Restore one exact TTL-0 Queue owner after process restart or failed persistence. Idempotent by entryId. */ - restoreDurableEntry(entry: QueueEntry, options?: { beforeEntryId?: string }): 'restored' | 'existing' { - for (const q of this.queues.values()) { - const existing = q.find((candidate) => candidate.id === entry.id); - if (!existing) continue; - if (existing.threadId !== entry.threadId || existing.userId !== entry.userId) { - throw new Error(`durable Queue entry identity collision: ${entry.id}`); + /** Process-local prompt transport evidence for one admitted attempt; never persisted on Queue state. */ + getAdmittedAttemptEvidence( + threadId: string, + entryId: string, + targetCatId: string, + userId?: string, + ): Readonly | null { + const admitted = this.findAdmittedEntry(threadId, entryId, targetCatId, userId); + if (!admitted) return null; + const evidence = this.admittedAttemptEvidence.get(admitted)?.get(targetCatId); + return evidence ? { ...evidence } : null; + } + + private forgetAdmittedEntries(threadId: string, entryId: string): QueueEntry[] { + const entries = this.admittedEntries.get(entryId); + if (!entries) return []; + const removed = entries.filter((entry) => entry.threadId === threadId); + const remaining = entries.filter((entry) => entry.threadId !== threadId); + if (remaining.length === 0) this.admittedEntries.delete(entryId); + else this.admittedEntries.set(entryId, remaining); + return removed.map((entry) => structuredClone(entry)); + } + + private cacheLedgerClaim(entries: readonly QueueLedgerEntry[], claimId: string): QueueEntry[] { + const projected = this.cacheLedgerEntries(entries); + for (const entry of projected) { + const cached = this.findEntry(entry.threadId, queueEntryOwnerId(entry), entry.id); + if (!cached) continue; + if (projected.length > 1) { + cached.retiringGroupId = claimId; + entry.retiringGroupId = claimId; } - return 'existing'; + this.ledgerClaimIds.set(cached.id, claimId); } - const restored = InvocationQueue.cloneEntry(entry); - const queue = this.getOrCreate(this.scopeKey(entry.threadId, entry.userId)); - const beforeIndex = options?.beforeEntryId - ? queue.findIndex((candidate) => candidate.id === options.beforeEntryId) - : -1; - queue.splice(beforeIndex >= 0 ? beforeIndex : queue.length, 0, restored); - this.originalContents.set(entry.id, entry.content); - return 'restored'; + return projected.map((entry) => this.getEntrySnapshot(entry.threadId, queueEntryOwnerId(entry), entry.id) ?? entry); } - /** F254 D1.1: queued freshness input scoped to the cat that would process it. */ - getQueuedFreshnessMessagesForCat( + async claimExactSteerEntryDurable( threadId: string, userId: string, - catId: string, - opts?: { excludeEntryId?: string; parentInvocationId?: string }, - ): Array<{ - entryId: string; - source: string; - content: string; - callerCatId?: string; - messageId?: string | null; - mergedMessageIds?: string[]; - sourceCategory?: QueueEntry['sourceCategory']; - }> { - return this.list(threadId, userId) - .filter((entry) => entry.id !== opts?.excludeEntryId) - .filter((entry) => entry.status === 'queued' && entry.targetCats.includes(catId)) - .filter((entry) => InvocationQueue.canExposeToCurrentParent(entry, catId, opts?.parentInvocationId)) - .filter((entry) => !entry.queuedSeenByCatIds?.includes(catId)) - .map((entry) => ({ - entryId: entry.id, - source: entry.source, - content: entry.content, - ...(entry.callerCatId ? { callerCatId: entry.callerCatId } : {}), - ...(entry.messageId !== undefined ? { messageId: entry.messageId } : {}), - ...(entry.mergedMessageIds.length > 0 ? { mergedMessageIds: [...entry.mergedMessageIds] } : {}), - ...(entry.sourceCategory ? { sourceCategory: entry.sourceCategory } : {}), - })); + entryId: string, + targetCatId: string, + claimedAt = Date.now(), + ): Promise { + const entry = this.findEntry(threadId, userId, entryId); + if (!entry) return { outcome: 'rejected', reason: 'entry_not_found' }; + if (entry.status !== 'queued') return { outcome: 'rejected', reason: 'entry_processing' }; + const assignsTargetlessConversation = entry.kind === 'conversation_input' && entry.targets.length === 0; + if ( + isSystemPinnedQueueEntry(entry) || + (!assignsTargetlessConversation && !isOrdinaryQueueTargetEligible(entry, targetCatId)) + ) { + return { outcome: 'rejected', reason: 'entry_ineligible' }; + } + const claimId = randomUUID(); + const claimed = await this.ledgerStore.claim(threadId, entryId, claimId, claimedAt, targetCatId, claimedAt); + if (claimed.outcome !== 'claimed') { + return { + outcome: 'rejected', + reason: claimed.outcome === 'not_found' ? 'entry_not_found' : 'entry_processing', + }; + } + return { outcome: 'claimed', entries: this.cacheLedgerClaim(claimed.entries, claimId), targetCatId }; } - /** F254 D1.2a: queued bodies readable by a target cat. Seen suppresses nags, not body access. */ - getQueuedBodyMessagesForCat( + /** + * Bind one public human message to the selected member and persist the + * author's non-interrupting delivery intent. This is the Queue-panel + * counterpart of composer admission: the same row either appends to the + * exact current run or remains ordinary next work without canceling it. + */ + async bindContinueCurrentIntentDurable( threadId: string, userId: string, - catId: string, - parentInvocationId?: string, - ): Array<{ - entryId: string; - source: string; - content: string; - callerCatId?: string; - messageId?: string | null; - mergedMessageIds?: string[]; - }> { - return this.list(threadId, userId) - .filter((entry) => entry.status === 'queued' && entry.targetCats.includes(catId)) - .filter((entry) => InvocationQueue.canExposeToCurrentParent(entry, catId, parentInvocationId)) - .map((entry) => ({ - entryId: entry.id, - source: entry.source, - content: entry.content, - ...(entry.callerCatId ? { callerCatId: entry.callerCatId } : {}), - ...(entry.messageId !== undefined ? { messageId: entry.messageId } : {}), - ...(entry.mergedMessageIds.length > 0 ? { mergedMessageIds: [...entry.mergedMessageIds] } : {}), - })); - } + entryId: string, + targetCatId: string, + authorIntent: QueueAuthorIntent, + at = Date.now(), + ): Promise { + const entry = this.findEntry(threadId, userId, entryId); + if ( + !entry || + entry.status !== 'queued' || + entry.kind !== 'conversation_input' || + entry.from.kind !== 'user' || + isSystemPinnedQueueEntry(entry) + ) { + return null; + } + const assignsTargetlessConversation = entry.targets.length === 0; + if (!assignsTargetlessConversation && !isOrdinaryQueueTargetEligible(entry, targetCatId)) return null; - private static canExposeToCurrentParent( - entry: Pick, - catId: string, - parentInvocationId: string | undefined, - ): boolean { - // Author disposition belongs only to human-authored work. Agent and connector - // carriers retain their typed custody/continuation path and may be read at a - // current safe boundary without manufacturing a human queue preference. - if (entry.source !== 'user') return true; - const authorIntent = entry.authorIntentByCatId?.[catId]; - return Boolean( - parentInvocationId && - authorIntent?.requested === 'continue_current' && - authorIntent.fallbackAt === undefined && - authorIntent.boundParentInvocationId === parentInvocationId, + const claimId = randomUUID(); + const claimed = await this.ledgerStore.claim( + threadId, + entryId, + claimId, + at, + assignsTargetlessConversation ? targetCatId : undefined, ); + if (claimed.outcome !== 'claimed' || !claimed.entries[0]) return null; + const replacement = structuredClone(claimed.entries[0]); + replacement.delivery.authorIntentByTarget = { + ...(replacement.delivery.authorIntentByTarget ?? {}), + [targetCatId]: structuredClone(authorIntent), + }; + const committed = await this.ledgerStore.commit(threadId, entryId, claimId, 'queued', at, replacement); + if (committed.outcome !== 'updated') { + await this.ledgerStore.restore(threadId, entryId, claimId, assignsTargetlessConversation); + return null; + } + const [bound] = this.cacheLedgerEntries([committed.entry]); + return bound ? structuredClone(bound) : null; } - /** - * Close every still-pending exposure window bound to one terminal parent. - * The immutable request/fence remain; only an append-only fallback fact is added. - */ - fallbackAuthorIntentsForParentAcrossUsers( + /** Record the truthful next-work fallback after an exact Append race. */ + async fallbackQueuedAuthorIntentDurable( threadId: string, - catId: string, - parentInvocationId: string, - fallbackAt = Date.now(), - ): Array<{ entryId: string; userId: string }> { - const changed: Array<{ entryId: string; userId: string }> = []; - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - for (const entry of q) { - if (entry.status !== 'queued' || !entry.targetCats.includes(catId)) continue; - const authorIntent = entry.authorIntentByCatId?.[catId]; - if ( - authorIntent?.requested !== 'continue_current' || - authorIntent.boundParentInvocationId !== parentInvocationId || - authorIntent.fallbackAt !== undefined - ) { - continue; - } - const hadExposure = (entry.queuedBodyExposures ?? []).some((exposure) => exposure.targetCatId === catId); - entry.authorIntentByCatId = { - ...(entry.authorIntentByCatId ?? {}), - [catId]: { - ...authorIntent, - fallbackAt, - fallbackReason: hadExposure ? 'parent_non_success_after_exposure' : 'parent_terminal_before_exposure', - }, - }; - changed.push({ entryId: entry.id, userId: entry.userId }); + userId: string, + entryId: string, + targetCatId: string, + reason: QueueAuthorIntentFallbackReason, + at = Date.now(), + ): Promise { + const result = await this.mutateQueuedLedgerEntry(threadId, userId, entryId, (row) => { + const intent = row.delivery.authorIntentByTarget?.[targetCatId]; + if (!intent || intent.requested !== 'continue_current' || intent.fallbackAt !== undefined) return false; + row.delivery.authorIntentByTarget = { + ...(row.delivery.authorIntentByTarget ?? {}), + [targetCatId]: { ...intent, fallbackAt: at, fallbackReason: reason }, + }; + return true; + }); + return result?.changed ?? false; + } + + async restoreClaimedEntries(threadId: string, entryIds: readonly string[]): Promise { + let restoredAll = true; + for (const entryId of entryIds) { + const claimId = this.ledgerClaimIds.get(entryId); + if (!claimId) { + restoredAll = false; + continue; } + // The durable claim records whether this action bound a formerly + // targetless entry. Never infer that from the v2 source-derived id: every + // v2 row has that identity, including already-targeted rows. + const restored = await this.ledgerStore.restore(threadId, entryId, claimId); + if (restored.outcome !== 'updated') { + restoredAll = false; + continue; + } + this.ledgerClaimIds.delete(entryId); + this.cacheLedgerEntries([restored.entry]); } - return changed; + return restoredAll; } - /** Record that a best-effort freshness notice reached this target's current invocation. */ - markQueuedNotified(threadId: string, userId: string, entryId: string, catId: string): boolean { + /** Claim one queued row before an external withdrawal side effect. */ + async claimQueuedEntryForWithdrawal(threadId: string, userId: string, entryId: string): Promise { const entry = this.findEntry(threadId, userId, entryId); - if (!entry || entry.status !== 'queued' || !isOrdinaryQueueTargetEligible(entry, catId)) return false; - if (entry.queuedSeenByCatIds?.includes(catId)) return false; - const notified = new Set(entry.queuedNotifiedByCatIds ?? []); - const alreadyNotified = notified.has(catId); - notified.add(catId); - entry.queuedNotifiedByCatIds = [...notified]; - return !alreadyNotified; + if (!entry || entry.status !== 'queued') return null; + return this.claimLedgerEntry(entry); } - markSteering(threadId: string, userId: string, entryId: string, catId: string): boolean { - const entry = this.findEntry(threadId, userId, entryId); - if (!entry || entry.status !== 'queued' || !isOrdinaryQueueTargetEligible(entry, catId)) return false; - const requested = new Set(entry.steerRequestedByCatIds ?? []); - const changed = !requested.has(catId); - requested.add(catId); - entry.steerRequestedByCatIds = [...requested]; - return changed; + /** Freeze the source entry for one message before its withdrawal side effect. */ + async claimMessageEntriesForWithdrawal( + threadId: string, + userId: string, + messageId: string, + claimedAt = Date.now(), + ): Promise { + const matches = [...this.queues.values()] + .flat() + .filter((entry) => entry.threadId === threadId && entry.payload.messageId === messageId); + if (matches.length === 0 || matches.some((entry) => queueEntryOwnerId(entry) !== userId)) { + return { outcome: 'not_found' }; + } + if (matches.some((entry) => entry.status !== 'queued')) return { outcome: 'processing' }; + const claimId = randomUUID(); + const claimed = await this.ledgerStore.claimPrefix( + threadId, + matches.map((entry) => entry.id), + claimId, + claimedAt, + ); + if (claimed.outcome !== 'claimed') { + return { outcome: claimed.outcome === 'not_found' ? 'not_found' : 'processing' }; + } + return { outcome: 'claimed', entries: this.cacheLedgerClaim(claimed.entries, claimId) }; } - clearSteering(threadId: string, userId: string, entryId: string, catId: string): boolean { - const entry = this.findEntry(threadId, userId, entryId); - if (!entry) return false; - const hadRequested = entry.steerRequestedByCatIds?.includes(catId) ?? false; - const hadInvocation = !!entry.steeredInvocationIdByCatId?.[catId]; - entry.steerRequestedByCatIds = entry.steerRequestedByCatIds?.filter((candidate) => candidate !== catId); - if (entry.steerRequestedByCatIds?.length === 0) entry.steerRequestedByCatIds = undefined; - if (entry.steeredInvocationIdByCatId) { - delete entry.steeredInvocationIdByCatId[catId]; - if (Object.keys(entry.steeredInvocationIdByCatId).length === 0) { - entry.steeredInvocationIdByCatId = undefined; - } + /** Withdraw every source entry frozen by claimMessageEntriesForWithdrawal. */ + async commitClaimedMessageWithdrawal(threadId: string, entryIds: readonly string[]): Promise { + let committedAll = true; + for (const entryId of entryIds) { + if (!(await this.commitClaimedWithdrawal(threadId, entryId))) committedAll = false; } - return hadRequested || hadInvocation; + return committedAll; } - /** - * Bind the exact provider child as soon as it exists, before the queued body - * is exposed. This preserves "woke" independently from "read". - */ - markQueuedAwakened( - threadId: string, - userId: string, - entryId: string, - catId: string, - invocationId: string, - awakenedAt = Date.now(), - ): boolean { + /** Remove a withdrawal claim after its source-message terminal write succeeds. */ + async commitClaimedWithdrawal(threadId: string, entryId: string): Promise { + const claimId = this.ledgerClaimIds.get(entryId); + if (!claimId) return null; + const committed = await this.ledgerStore.commit(threadId, entryId, claimId, 'withdrawn', Date.now()); + if (committed.outcome !== 'updated') return null; + this.ledgerClaimIds.delete(entryId); + return this.removeCachedEntry(threadId, entryId); + } + + /** Persist explicit ordering through the same claim/commit CAS as dequeue. */ + async setPositionDurable(threadId: string, userId: string, entryId: string, position: number): Promise { const entry = this.findEntry(threadId, userId, entryId); - if (!entry || !isOrdinaryQueueTargetEligible(entry, catId)) return false; - const existingInvocationId = entry.queuedAwakenedInvocationIdByCatId?.[catId]; - const existingAwakenedAt = entry.queuedAwakenedAtByCatId?.[catId]; - if (existingInvocationId === invocationId) { - if (existingAwakenedAt !== undefined && existingAwakenedAt !== awakenedAt) { - throw new Error('queued awakened timestamp is immutable'); - } + if ( + !entry || + entry.status !== 'queued' || + entry.kind === 'private_input' || + isSystemPinnedQueueEntry(entry) || + !Number.isInteger(position) || + position < 0 + ) { return false; } - entry.queuedAwakenedInvocationIdByCatId = { - ...(entry.queuedAwakenedInvocationIdByCatId ?? {}), - [catId]: invocationId, - }; - entry.queuedAwakenedAtByCatId = { - ...(entry.queuedAwakenedAtByCatId ?? {}), - [catId]: awakenedAt, - }; - entry.queuedNotifiedByCatIds = entry.queuedNotifiedByCatIds?.filter((candidate) => candidate !== catId); - if (entry.queuedNotifiedByCatIds?.length === 0) entry.queuedNotifiedByCatIds = undefined; + const claimId = randomUUID(); + const claimedAt = Date.now(); + const claimed = await this.ledgerStore.claim(threadId, entryId, claimId, claimedAt); + if (claimed.outcome !== 'claimed' || !claimed.entries[0]) return false; + const replacement = structuredClone(claimed.entries[0]); + replacement.position = position; + const committed = await this.ledgerStore.commit(threadId, entryId, claimId, 'queued', claimedAt, replacement); + if (committed.outcome !== 'updated') { + await this.ledgerStore.restore(threadId, entryId, claimId); + return false; + } + this.cacheLedgerEntries([committed.entry]); return true; } /** - * F254 D1.2a: mark a queued entry as seen by one target cat. - * Does not consume or deliver the entry. - * - * Returns whether this call created the first queued_seen state transition for - * this cat. Invocation evidence is still refreshed on repeat reads so retry - * attempts can close handled evidence without double-counting queued_seen. + * Apply a queued-row metadata mutation through the existing ledger claim/commit + * CAS. This deliberately reuses the five ADR-043 primitives instead of adding + * a side-channel persistence API for receipts. */ - markQueuedSeen( + private async mutateQueuedLedgerEntry( threadId: string, userId: string, entryId: string, - catId: string, - invocationId?: string, - seenAt = Date.now(), - ): boolean { - const entry = this.findEntry(threadId, userId, entryId); - if (!entry || entry.status !== 'queued' || !isOrdinaryQueueTargetEligible(entry, catId)) return false; - const seen = new Set(entry.queuedSeenByCatIds ?? []); - const alreadySeen = seen.has(catId); - seen.add(catId); - entry.queuedSeenByCatIds = [...seen]; - entry.queuedNotifiedByCatIds = entry.queuedNotifiedByCatIds?.filter((candidate) => candidate !== catId); - if (entry.queuedNotifiedByCatIds?.length === 0) entry.queuedNotifiedByCatIds = undefined; - if (invocationId) { - entry.queuedSeenInvocationIdByCatId = { ...(entry.queuedSeenInvocationIdByCatId ?? {}), [catId]: invocationId }; - if ( - !(entry.queuedBodyExposures ?? []).some( - (exposure) => exposure.targetCatId === catId && exposure.invocationId === invocationId, - ) - ) { - entry.queuedBodyExposures = [ - ...(entry.queuedBodyExposures ?? []), - { targetCatId: catId, invocationId, seenAt }, - ]; - } + mutate: (entry: QueueLedgerEntry) => boolean, + ): Promise<{ changed: boolean; entry: QueueEntry } | null> { + const cached = this.findEntry(threadId, userId, entryId); + if (!cached || cached.status !== 'queued') return null; + const claimId = randomUUID(); + const claimedAt = Date.now(); + const claimed = await this.ledgerStore.claim(threadId, entryId, claimId, claimedAt); + if (claimed.outcome !== 'claimed' || !claimed.entries[0]) return null; + const replacement = structuredClone(claimed.entries[0]); + let changed: boolean; + try { + changed = mutate(replacement); + } catch (error) { + await this.ledgerStore.restore(threadId, entryId, claimId); + throw error; + } + const committed = await this.ledgerStore.commit(threadId, entryId, claimId, 'queued', claimedAt, replacement); + if (committed.outcome !== 'updated') { + await this.ledgerStore.restore(threadId, entryId, claimId); + return null; } - return !alreadySeen; + const [entry] = this.cacheLedgerEntries([committed.entry]); + return entry ? { changed, entry } : null; } - /** - * Record the exact Queue bodies supplied as the current invocation prompt. - * Processing entries are no longer discoverable through get_thread_context, - * so their read evidence must be bound at the provider launch boundary. - */ - markProcessingSeen( + async markProcessingAwakened( threadId: string, userId: string, entryId: string, - catIds: readonly string[], + targetCatId: string, invocationId: string, - seenAt = Date.now(), - ): string[] { - const entry = this.findEntry(threadId, userId, entryId); - if (!entry || entry.status !== 'processing') return []; - const seen = new Set(entry.queuedSeenByCatIds ?? []); - const notified = new Set(entry.queuedNotifiedByCatIds ?? []); - const failed = new Set(entry.queuedFailedByCatIds ?? []); - const marked: string[] = []; - for (const catId of catIds) { - if (!isOrdinaryQueueTargetEligible(entry, catId)) continue; - seen.add(catId); - notified.delete(catId); - entry.queuedSeenInvocationIdByCatId = { - ...(entry.queuedSeenInvocationIdByCatId ?? {}), - [catId]: invocationId, - }; - if ( - !(entry.queuedBodyExposures ?? []).some( - (exposure) => exposure.targetCatId === catId && exposure.invocationId === invocationId, - ) - ) { - entry.queuedBodyExposures = [ - ...(entry.queuedBodyExposures ?? []), - { targetCatId: catId, invocationId, seenAt }, - ]; - } - if (entry.steerRequestedByCatIds?.includes(catId)) { - entry.steerRequestedByCatIds = entry.steerRequestedByCatIds.filter((candidate) => candidate !== catId); - if (entry.steerRequestedByCatIds.length === 0) entry.steerRequestedByCatIds = undefined; - entry.steeredInvocationIdByCatId = { - ...(entry.steeredInvocationIdByCatId ?? {}), - [catId]: invocationId, - }; - } - marked.push(catId); - } - if (marked.length === 0) return []; - entry.queuedSeenByCatIds = [...seen]; - entry.queuedNotifiedByCatIds = notified.size > 0 ? [...notified] : undefined; - entry.queuedFailedByCatIds = failed.size > 0 ? [...failed] : undefined; - return marked; + awakenedAt = Date.now(), + ): Promise { + const admitted = this.findAdmittedEntry(threadId, entryId, targetCatId, userId); + if (!admitted) return false; + const evidenceByTarget = this.admittedAttemptEvidence.get(admitted) ?? new Map(); + const evidence = evidenceByTarget.get(targetCatId) ?? {}; + if (evidence.awakenedInvocationId && evidence.awakenedInvocationId !== invocationId) return false; + evidence.awakenedInvocationId ??= invocationId; + evidence.awakenedAt ??= awakenedAt; + evidenceByTarget.set(targetCatId, evidence); + this.admittedAttemptEvidence.set(admitted, evidenceByTarget); + return true; } - /** Mark exact read evidence as failed and return the responsibility to queued state. */ - markQueuedFailedForCatAcrossUsers( + async markProcessingSeen( threadId: string, - catId: string, + userId: string, + entryId: string, + targetCatId: string, invocationId: string, - attemptedEntryIds: ReadonlySet = new Set(), - terminalReason: QueueTargetAttemptTerminalReason = 'invocation_failed', - failedAt = Date.now(), - ): Array<{ entryId: string; userId: string }> { - const failed: Array<{ entryId: string; userId: string }> = []; - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - for (const entry of q) { - if ( - entry.status !== 'queued' || - !entry.targetCats.includes(catId) || - (entry.queuedSeenInvocationIdByCatId?.[catId] !== invocationId && !attemptedEntryIds.has(entry.id)) - ) { - continue; - } - const failedCats = new Set(entry.queuedFailedByCatIds ?? []); - failedCats.add(catId); - entry.queuedFailedByCatIds = [...failedCats]; - entry.queuedFailureAtByCatId = { ...(entry.queuedFailureAtByCatId ?? {}), [catId]: failedAt }; - entry.queuedFailureReasonByCatId = { - ...(entry.queuedFailureReasonByCatId ?? {}), - [catId]: terminalReason, - }; - if (entry.queuedSeenInvocationIdByCatId?.[catId] === invocationId) { - delete entry.queuedSeenInvocationIdByCatId[catId]; - if (Object.keys(entry.queuedSeenInvocationIdByCatId).length === 0) { - entry.queuedSeenInvocationIdByCatId = undefined; - } - } - this.clearSteering(entry.threadId, entry.userId, entry.id, catId); - failed.push({ entryId: entry.id, userId: entry.userId }); - } - } - return failed; - } - - /** - * Re-open exactly one failed target of an existing Queue entry. The message, - * entry id, and other target state remain unchanged; custody records the new - * attempt before any execution is started. - */ - retryFailedTarget( + seenAt = Date.now(), + ): Promise<{ changed: boolean; newlySeen: boolean }> { + const admitted = this.findAdmittedEntry(threadId, entryId, targetCatId, userId); + if (!admitted) return { changed: false, newlySeen: false }; + const evidenceByTarget = this.admittedAttemptEvidence.get(admitted) ?? new Map(); + const evidence = evidenceByTarget.get(targetCatId) ?? {}; + const newlySeen = evidence.seenAt === undefined; + const changed = newlySeen || evidence.seenInvocationId !== invocationId; + evidence.seenAt ??= seenAt; + evidence.seenInvocationId = invocationId; + evidenceByTarget.set(targetCatId, evidence); + this.admittedAttemptEvidence.set(admitted, evidenceByTarget); + return { changed, newlySeen }; + } + + /** Claim one exact pending target on its source entry before binding a full-body read to the active child. */ + async claimExactExposureDurable( threadId: string, userId: string, entryId: string, - catId: string, - attemptId?: string, - ): { before: QueueEntry; after: QueueEntry } | null { + targetCatId: string, + messageId: string, + ): Promise { const entry = this.findEntry(threadId, userId, entryId); if ( !entry || entry.status !== 'queued' || - !entry.targetCats.includes(catId) || - !entry.queuedFailedByCatIds?.includes(catId) + !entry.targets.includes(targetCatId) || + entry.payload.messageId !== messageId ) { return null; } - const before = InvocationQueue.cloneEntry(entry); - entry.queuedFailedByCatIds = entry.queuedFailedByCatIds.filter((candidate) => candidate !== catId); - if (entry.queuedFailedByCatIds.length === 0) entry.queuedFailedByCatIds = undefined; - this.clearQueuedFailure(entry, catId); - if (entry.queuedSeenInvocationIdByCatId?.[catId]) { - delete entry.queuedSeenInvocationIdByCatId[catId]; - if (Object.keys(entry.queuedSeenInvocationIdByCatId).length === 0) - entry.queuedSeenInvocationIdByCatId = undefined; - } - if (entry.queuedAwakenedInvocationIdByCatId?.[catId]) { - delete entry.queuedAwakenedInvocationIdByCatId[catId]; - delete entry.queuedAwakenedAtByCatId?.[catId]; - if (Object.keys(entry.queuedAwakenedInvocationIdByCatId).length === 0) { - entry.queuedAwakenedInvocationIdByCatId = undefined; - entry.queuedAwakenedAtByCatId = undefined; - } - } - if (attemptId) { - entry.queuedAttemptIdByCatId = { ...(entry.queuedAttemptIdByCatId ?? {}), [catId]: attemptId }; - } - return { before, after: InvocationQueue.cloneEntry(entry) }; + return this.claimLedgerEntry(entry, targetCatId); } - /** Bind the custody-CAS winner before its exact retry target can start. */ - bindRetryAttemptId( + /** + * Remove the adopted target from durable Queue and retain only process-local + * attempt evidence. History already owns the exact source→target dispatch. + */ + async commitClaimedAdoptionDurable( threadId: string, userId: string, entryId: string, - catId: string, - attemptId: string, - ): QueueEntry | null { - const entry = this.findEntry(threadId, userId, entryId); - if (!entry || entry.status !== 'queued' || !entry.targetCats.includes(catId) || !attemptId) return null; - entry.queuedAttemptIdByCatId = { ...(entry.queuedAttemptIdByCatId ?? {}), [catId]: attemptId }; - return InvocationQueue.cloneEntry(entry); - } - - /** - * F254 D1.2b: retry reuses an InvocationRecord id, so clear stale handled-evidence tokens - * before the retry attempt starts. The read marker remains: seen still suppresses nags. - */ - clearQueuedSeenInvocationForCats(threadId: string, catIds: readonly string[], invocationId: string): number { - const targetCats = new Set(catIds); - let cleared = 0; - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - for (const entry of q) { - if ( - !entry || - entry.status !== 'queued' || - (!entry.queuedSeenInvocationIdByCatId && !entry.queuedAwakenedInvocationIdByCatId) - ) { - continue; - } - for (const catId of targetCats) { - const hadSeen = entry.queuedSeenInvocationIdByCatId?.[catId] === invocationId; - const hadAwakened = entry.queuedAwakenedInvocationIdByCatId?.[catId] === invocationId; - if (!hadSeen && !hadAwakened) continue; - if (hadSeen && entry.queuedSeenInvocationIdByCatId) { - delete entry.queuedSeenInvocationIdByCatId[catId]; - } - if (hadAwakened && entry.queuedAwakenedInvocationIdByCatId) { - delete entry.queuedAwakenedInvocationIdByCatId[catId]; - delete entry.queuedAwakenedAtByCatId?.[catId]; - } - if (entry.steeredInvocationIdByCatId?.[catId] === invocationId) { - delete entry.steeredInvocationIdByCatId[catId]; - } - cleared += 1; - } - if (entry.queuedSeenInvocationIdByCatId && Object.keys(entry.queuedSeenInvocationIdByCatId).length === 0) { - entry.queuedSeenInvocationIdByCatId = undefined; - } - if ( - entry.queuedAwakenedInvocationIdByCatId && - Object.keys(entry.queuedAwakenedInvocationIdByCatId).length === 0 - ) { - entry.queuedAwakenedInvocationIdByCatId = undefined; - entry.queuedAwakenedAtByCatId = undefined; - } - if (entry.steeredInvocationIdByCatId && Object.keys(entry.steeredInvocationIdByCatId).length === 0) { - entry.steeredInvocationIdByCatId = undefined; - } - } + targetCatId: string, + invocationId: string, + seenAt: number, + ): Promise<{ entry: QueueEntry; newlySeen: boolean } | null> { + const snapshot = this.getEntrySnapshot(threadId, userId, entryId); + const claimId = this.ledgerClaimIds.get(entryId); + if (!snapshot || snapshot.status !== 'claimed' || !claimId) return null; + const durable = await this.ledgerStore.get(threadId, entryId); + if (!durable || !durable.targets.includes(targetCatId)) return null; + const admitted = await this.ledgerStore.commit(threadId, entryId, claimId, 'processing', seenAt, durable); + if (admitted.outcome !== 'updated') return null; + this.ledgerClaimIds.delete(entryId); + this.removeCachedEntry(threadId, entryId); + const remaining = await this.ledgerStore.get(threadId, entryId); + if (remaining) this.cacheLedgerEntries([remaining]); + return { entry: admitted.entry, newlySeen: true }; + } + + async claimPreAdmissionFailureAcrossUsersDurable(threadId: string, entryId: string): Promise { + const best = this.peekOldestAcrossUsers(threadId); + if (!best || best.id !== entryId) return null; + return this.claimLedgerEntry(best); + } + + /** Durable counterpart of markProcessingAcrossUsers. */ + async markProcessingDurable( + threadId: string, + userId: string, + resolvedHead: { readonly entryId: string; readonly targetCats: readonly string[] }, + ): Promise { + const best = this.peekNextQueued(threadId, userId); + if ( + !best || + best.id !== resolvedHead.entryId || + resolvedHead.targetCats.length === 0 || + resolvedHead.targetCats.some((catId) => typeof catId !== 'string' || !catId) || + new Set(resolvedHead.targetCats).size !== resolvedHead.targetCats.length + ) { + return null; } - return cleared; + const selectedTargetCatId = resolvedHead.targetCats[0]!; + if (best.targets.length > 0 && !best.targets.includes(selectedTargetCatId)) return null; + return this.claimLedgerEntry(best, selectedTargetCatId); } - /** F254 D1.2b: consume only this cat's target for queued entries it already saw. */ - markQueuedHandledForCatAcrossUsers(threadId: string, catId: string, invocationId?: string): QueuedHandledResult[] { - const handled: QueuedHandledResult[] = []; - if (!invocationId) return handled; - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - for (let i = 0; i < q.length; i++) { - const entry = q[i]; - if (!entry || !InvocationQueue.isQueuedHandledCandidate(entry, catId, invocationId)) continue; - - const result = InvocationQueue.markQueuedHandledEntry(entry, catId, i, this.originalContents.get(entry.id)); - handled.push(result); - - if (result.fullyConsumed) { - this.originalContents.delete(entry.id); - q.splice(i, 1); - i--; - } - } + /** Durable counterpart of markProcessingAcrossUsers. */ + async markProcessingAcrossUsersDurable( + threadId: string, + resolvedHead: { readonly entryId: string; readonly targetCats: readonly string[] }, + ): Promise { + const best = this.peekOldestAcrossUsers(threadId); + if ( + !best || + best.id !== resolvedHead.entryId || + resolvedHead.targetCats.length === 0 || + resolvedHead.targetCats.some((catId) => typeof catId !== 'string' || !catId) || + new Set(resolvedHead.targetCats).size !== resolvedHead.targetCats.length + ) { + return null; } - return handled; + const selectedTargetCatId = resolvedHead.targetCats[0]!; + if (best.targets.length > 0 && !best.targets.includes(selectedTargetCatId)) return null; + return this.claimLedgerEntry(best, selectedTargetCatId); } - /** - * Detect an attempted target that remained Queue-owned after a nominally - * successful execution. This is the fail-closed guard for a child that - * reached `done` without ever binding the exact prompt-body exposure. - */ - hasAttemptedQueuedTargetAcrossUsers( + async markProcessingGroupAcrossUsersDurable( threadId: string, - catId: string, - attemptedEntryIds: ReadonlySet, - ): boolean { - if (attemptedEntryIds.size === 0) return false; - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - if ( - q.some( - (entry) => entry.status === 'queued' && attemptedEntryIds.has(entry.id) && entry.targetCats.includes(catId), - ) - ) { - return true; - } + resolvedHead: { readonly entryId: string; readonly targetCats: readonly string[] }, + entryIds: readonly string[], + ): Promise<{ entry: QueueEntry; members: QueueEntry[] } | null> { + const best = this.peekOldestAcrossUsers(threadId); + if ( + !best || + best.id !== resolvedHead.entryId || + entryIds[0] !== best.id || + entryIds.length === 0 || + new Set(entryIds).size !== entryIds.length || + resolvedHead.targetCats.length === 0 || + new Set(resolvedHead.targetCats).size !== resolvedHead.targetCats.length + ) { + return null; } - return false; - } - - private static isQueuedHandledCandidate(entry: QueueEntry, catId: string, invocationId: string): boolean { - return ( - entry.status === 'queued' && - entry.targetCats.includes(catId) && - entry.queuedSeenInvocationIdByCatId?.[catId] === invocationId - ); - } - - restoreQueuedHandledResult(result: QueuedHandledResult): boolean { - if (!result.entrySnapshot) return false; - const key = this.scopeKey(result.threadId, result.userId); - const q = this.getOrCreate(key); - const restored = InvocationQueue.cloneEntry(result.entrySnapshot); - const existingIndex = q.findIndex((entry) => entry.id === result.entryId); - if (existingIndex >= 0) { - q[existingIndex] = restored; - } else { - const index = Math.max(0, Math.min(result.queueIndex ?? q.length, q.length)); - q.splice(index, 0, restored); + const selectedTargetCatId = resolvedHead.targetCats.length === 1 ? resolvedHead.targetCats[0]! : undefined; + const selected = entryIds.map((entryId) => this.findEntryAcrossUsers(threadId, entryId)); + if ( + selected.some( + (entry) => + !entry || + entry.status !== 'queued' || + queueEntryOwnerId(entry) !== queueEntryOwnerId(best) || + (selectedTargetCatId + ? entry.targets.length > 0 && !entry.targets.includes(selectedTargetCatId) + : entry.targets.length !== resolvedHead.targetCats.length || + resolvedHead.targetCats.some((catId) => !entry.targets.includes(catId))), + ) + ) { + return null; } - this.originalContents.set(restored.id, result.originalContent ?? restored.content); - return true; + const claimId = randomUUID(); + const claimedAt = Date.now(); + const claimed = await this.ledgerStore.claimPrefix(threadId, entryIds, claimId, claimedAt, selectedTargetCatId); + if (claimed.outcome !== 'claimed') return null; + const projected = this.cacheLedgerClaim(claimed.entries, claimId); + const byId = new Map(projected.map((entry) => [entry.id, entry])); + const primary = byId.get(best.id); + if (!primary) return null; + return { + entry: primary, + members: entryIds + .slice(1) + .map((entryId) => byId.get(entryId)) + .filter((entry): entry is QueueEntry => !!entry), + }; } - private static markQueuedHandledEntry( - entry: QueueEntry, - catId: string, - queueIndex: number, - originalContent?: string, - ): QueuedHandledResult { - const entrySnapshot = InvocationQueue.cloneEntry(entry); - const remainingTargetCats = entry.targetCats.filter((targetCat) => targetCat !== catId); - entry.targetCats = remainingTargetCats; - const handledCats = new Set(entry.queuedHandledByCatIds ?? []); - handledCats.add(catId); - entry.queuedHandledByCatIds = [...handledCats]; - entry.queuedFailedByCatIds = entry.queuedFailedByCatIds?.filter((candidate) => candidate !== catId); - if (entry.queuedFailureAtByCatId) { - delete entry.queuedFailureAtByCatId[catId]; - if (Object.keys(entry.queuedFailureAtByCatId).length === 0) entry.queuedFailureAtByCatId = undefined; - } - if (entry.queuedFailureReasonByCatId) { - delete entry.queuedFailureReasonByCatId[catId]; - if (Object.keys(entry.queuedFailureReasonByCatId).length === 0) entry.queuedFailureReasonByCatId = undefined; - } - entry.queuedNotifiedByCatIds = entry.queuedNotifiedByCatIds?.filter((candidate) => candidate !== catId); - entry.steerRequestedByCatIds = entry.steerRequestedByCatIds?.filter((candidate) => candidate !== catId); - if (entry.steerRequestedByCatIds?.length === 0) entry.steerRequestedByCatIds = undefined; - if (entry.steeredInvocationIdByCatId) { - delete entry.steeredInvocationIdByCatId[catId]; - if (Object.keys(entry.steeredInvocationIdByCatId).length === 0) entry.steeredInvocationIdByCatId = undefined; - } - if (entry.queuedSeenInvocationIdByCatId) { - delete entry.queuedSeenInvocationIdByCatId[catId]; - if (Object.keys(entry.queuedSeenInvocationIdByCatId).length === 0) { - entry.queuedSeenInvocationIdByCatId = undefined; - } + async markProcessingGroupDurable( + threadId: string, + userId: string, + resolvedHead: { readonly entryId: string; readonly targetCats: readonly string[] }, + entryIds: readonly string[], + ): Promise<{ entry: QueueEntry; members: QueueEntry[] } | null> { + const best = this.peekNextQueued(threadId, userId); + if ( + !best || + best.id !== resolvedHead.entryId || + entryIds[0] !== best.id || + entryIds.length === 0 || + new Set(entryIds).size !== entryIds.length || + resolvedHead.targetCats.length === 0 || + new Set(resolvedHead.targetCats).size !== resolvedHead.targetCats.length + ) { + return null; } - if (entry.queuedAwakenedInvocationIdByCatId) { - delete entry.queuedAwakenedInvocationIdByCatId[catId]; - delete entry.queuedAwakenedAtByCatId?.[catId]; - if (Object.keys(entry.queuedAwakenedInvocationIdByCatId).length === 0) { - entry.queuedAwakenedInvocationIdByCatId = undefined; - entry.queuedAwakenedAtByCatId = undefined; - } + const selectedTargetCatId = resolvedHead.targetCats.length === 1 ? resolvedHead.targetCats[0]! : undefined; + const selected = entryIds.map((entryId) => this.findEntry(threadId, userId, entryId)); + if ( + selected.some( + (entry) => + !entry || + entry.status !== 'queued' || + (selectedTargetCatId + ? entry.targets.length > 0 && !entry.targets.includes(selectedTargetCatId) + : entry.targets.length !== resolvedHead.targetCats.length || + resolvedHead.targetCats.some((catId) => !entry.targets.includes(catId))), + ) + ) { + return null; } - const messageIds = [entry.messageId ?? '', ...entry.mergedMessageIds].filter(Boolean); + const claimId = randomUUID(); + const claimedAt = Date.now(); + const claimed = await this.ledgerStore.claimPrefix(threadId, entryIds, claimId, claimedAt, selectedTargetCatId); + if (claimed.outcome !== 'claimed') return null; + const projected = this.cacheLedgerClaim(claimed.entries, claimId); + const byId = new Map(projected.map((entry) => [entry.id, entry])); + const primary = byId.get(best.id); + if (!primary) return null; return { - entryId: entry.id, - threadId: entry.threadId, - userId: entry.userId, - catId, - messageIds, - remainingTargetCats: [...remainingTargetCats], - fullyConsumed: remainingTargetCats.length === 0, - entrySnapshot, - queueIndex, - originalContent, + entry: primary, + members: entryIds + .slice(1) + .map((entryId) => byId.get(entryId)) + .filter((entry): entry is QueueEntry => !!entry), }; } - private static cloneEntry(entry: QueueEntry): QueueEntry { - return { - ...entry, - targetCats: [...entry.targetCats], - ...(entry.allTargetCats ? { allTargetCats: [...entry.allTargetCats] } : {}), - ...(entry.authorIntentByCatId ? { authorIntentByCatId: structuredClone(entry.authorIntentByCatId) } : {}), - ...(entry.cloudDispatchProvenance - ? { cloudDispatchProvenance: structuredClone(entry.cloudDispatchProvenance) } - : {}), - ...(entry.queuedNotifiedByCatIds ? { queuedNotifiedByCatIds: [...entry.queuedNotifiedByCatIds] } : {}), - ...(entry.queuedAwakenedInvocationIdByCatId - ? { queuedAwakenedInvocationIdByCatId: { ...entry.queuedAwakenedInvocationIdByCatId } } - : {}), - ...(entry.queuedAwakenedAtByCatId ? { queuedAwakenedAtByCatId: { ...entry.queuedAwakenedAtByCatId } } : {}), - mergedMessageIds: [...entry.mergedMessageIds], - ...(entry.queuedSeenByCatIds ? { queuedSeenByCatIds: [...entry.queuedSeenByCatIds] } : {}), - ...(entry.queuedSeenInvocationIdByCatId - ? { queuedSeenInvocationIdByCatId: { ...entry.queuedSeenInvocationIdByCatId } } - : {}), - ...(entry.queuedBodyExposures - ? { queuedBodyExposures: entry.queuedBodyExposures.map((exposure) => ({ ...exposure })) } - : {}), - ...(entry.queuedFailedByCatIds ? { queuedFailedByCatIds: [...entry.queuedFailedByCatIds] } : {}), - ...(entry.queuedFailureAtByCatId ? { queuedFailureAtByCatId: { ...entry.queuedFailureAtByCatId } } : {}), - ...(entry.queuedFailureReasonByCatId - ? { queuedFailureReasonByCatId: { ...entry.queuedFailureReasonByCatId } } - : {}), - ...(entry.queuedAttemptIdByCatId ? { queuedAttemptIdByCatId: { ...entry.queuedAttemptIdByCatId } } : {}), - ...(entry.queuedHandledByCatIds ? { queuedHandledByCatIds: [...entry.queuedHandledByCatIds] } : {}), - ...(entry.steerRequestedByCatIds ? { steerRequestedByCatIds: [...entry.steerRequestedByCatIds] } : {}), - ...(entry.steeredInvocationIdByCatId - ? { steeredInvocationIdByCatId: { ...entry.steeredInvocationIdByCatId } } - : {}), - ...(entry.exactSteerBatch - ? { - exactSteerBatch: { - ...entry.exactSteerBatch, - entryIds: [...entry.exactSteerBatch.entryIds], - }, - } - : {}), - ...(entry.prestartRetirement - ? { - prestartRetirement: { - ...entry.prestartRetirement, - entryIds: [...entry.prestartRetirement.entryIds], - }, - } - : {}), - ...(entry.senderMeta ? { senderMeta: { ...entry.senderMeta } } : {}), - ...(entry.callerTraceContext ? { callerTraceContext: { ...entry.callerTraceContext } } : {}), - }; + async markProcessingByIdDurable(threadId: string, entryId: string, targetCatId: string): Promise { + const entry = this.findEntryAcrossUsers(threadId, entryId); + if (!entry || entry.status !== 'queued') return null; + if (entry.targets.length > 0 && !entry.targets.includes(targetCatId)) return null; + return this.claimLedgerEntry(entry, targetCatId); } - private clearQueuedFailure(entry: QueueEntry, catId: string): void { - if (entry.queuedFailureAtByCatId?.[catId] !== undefined) { - delete entry.queuedFailureAtByCatId[catId]; - if (Object.keys(entry.queuedFailureAtByCatId).length === 0) entry.queuedFailureAtByCatId = undefined; - } - if (entry.queuedFailureReasonByCatId?.[catId] !== undefined) { - delete entry.queuedFailureReasonByCatId[catId]; - if (Object.keys(entry.queuedFailureReasonByCatId).length === 0) entry.queuedFailureReasonByCatId = undefined; + async commitClaimedProcessing(threadId: string, entryIds: readonly string[], at = Date.now()): Promise { + for (const entryId of entryIds) { + const claimId = this.ledgerClaimIds.get(entryId); + if (!claimId) continue; + const cached = this.findEntryAcrossUsers(threadId, entryId); + const committed = await this.ledgerStore.commit(threadId, entryId, claimId, 'processing', at, cached); + if (committed.outcome !== 'updated') return false; + this.ledgerClaimIds.delete(entryId); + this.removeCachedEntry(threadId, entryId); + const remaining = await this.ledgerStore.get(threadId, entryId); + if (remaining) this.cacheLedgerEntries([remaining]); + this.rememberAdmittedEntry(committed.entry); } + return true; } - /** Count of queued (not processing) entries. */ - size(threadId: string, userId: string): number { - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return 0; - return q.filter((e) => e.status === 'queued').length; - } + /** + * Retire one target after its durable History receiver/ref exists. + * + * Multi-target dequeue keeps the source claim open until every sibling has + * either retired itself or restored the remaining claim. If a sibling wins a + * restore race first, remove this already-dispatched target from the queued + * row through the same canonical target reconciliation primitive. + */ + async retireClaimedLifecycleTarget( + threadId: string, + entryId: string, + targetCatId: string, + at = Date.now(), + ): Promise { + const cached = this.findEntryAcrossUsers(threadId, entryId); + const claimId = this.ledgerClaimIds.get(entryId); + if (cached && claimId) { + const retirement = await this.commitClaimedLifecycleTarget(threadId, cached, claimId, targetCatId, at); + if (retirement) return retirement; + } - /** Clear all entries for this user. Returns removed entries. */ - clear(threadId: string, userId: string): QueueEntry[] { - const key = this.scopeKey(threadId, userId); - const q = this.queues.get(key); - if (!q) return []; - for (const e of q) { - this.originalContents.delete(e.id); - if (e.exactSteerBatch) this.exactSteerReservations.delete(e.exactSteerBatch.reservationId); + const durable = await this.ledgerStore.get(threadId, entryId); + if (!durable) { + this.ledgerClaimIds.delete(entryId); + this.removeCachedEntry(threadId, entryId); + return { outcome: 'retired', rowStatus: 'absent' }; } - this.queues.delete(key); - return q; + if (durable.status === 'claimed' && claimId && durable.claimId === claimId) { + const retirement = await this.commitClaimedLifecycleTarget(threadId, durable, claimId, targetCatId, at); + return retirement ?? { outcome: 'state_changed' }; + } + if (durable.status !== 'queued') return { outcome: 'state_changed' }; + return this.retireLifecycleTargetFromQueuedRow(durable, targetCatId, at); } - /** - * Move entry up or down in comparator order by swapping positions with its neighbor. - * Returns false if entry is processing or not found. - */ - move(threadId: string, userId: string, entryId: string, direction: 'up' | 'down'): boolean { - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return false; - const target = q.find((e) => e.id === entryId); - if (!target || target.status === 'processing') return false; - if (target.exactSteerBatch) return false; - if (isSystemPinnedQueueEntry(target)) return false; - - const queued = q.filter((e) => e.status === 'queued'); - queued.sort(InvocationQueue.compareEntries); - const sortedIdx = queued.findIndex((e) => e.id === entryId); - const neighborIdx = direction === 'up' ? sortedIdx - 1 : sortedIdx + 1; - if (neighborIdx < 0 || neighborIdx >= queued.length) return true; - - for (let i = 0; i < queued.length; i++) { - queued[i]!.position = i; - } - const a = queued[sortedIdx]!; - const b = queued[neighborIdx]!; - const tmp = a.position!; - a.position = b.position!; - b.position = tmp; + private async commitClaimedLifecycleTarget( + threadId: string, + cached: QueueEntry, + claimId: string, + targetCatId: string, + at: number, + ): Promise { + const committed = await this.ledgerStore.commit(threadId, cached.id, claimId, 'processing', at, cached, [ + targetCatId, + ]); + if (committed.outcome !== 'updated') return null; + this.removeCachedEntry(threadId, cached.id); + const remaining = await this.ledgerStore.get(threadId, cached.id); + if (remaining) this.cacheLedgerEntries([remaining]); + this.rememberAdmittedEntry(committed.entry); + if (remaining?.status === 'claimed') return { outcome: 'retired', rowStatus: 'claimed' }; + this.ledgerClaimIds.delete(cached.id); + return { outcome: 'retired', rowStatus: remaining?.status === 'queued' ? 'queued' : 'absent' }; + } + + private async retireLifecycleTargetFromQueuedRow( + durable: QueueLedgerEntry, + targetCatId: string, + at: number, + ): Promise { + const attempted = structuredClone(durable); + attempted.targets = [targetCatId]; + attempted.status = 'processing'; + attempted.processingStartedAt = at; + delete attempted.claimId; + delete attempted.claimedAt; + delete attempted.claimedTargetIds; + delete attempted.claimedFromTargetless; + + if (!durable.targets.includes(targetCatId)) { + this.rememberAdmittedEntry(attempted); + return { outcome: 'retired', rowStatus: 'queued' }; + } + + const reconciled = await this.ledgerStore.reconcileTargets(durable.threadId, durable.id, [], [targetCatId], {}); + if (reconciled.outcome === 'not_found') { + this.rememberAdmittedEntry(attempted); + return { outcome: 'retired', rowStatus: 'absent' }; + } + if (!('entry' in reconciled)) return { outcome: 'state_changed' }; + this.ledgerClaimIds.delete(durable.id); + this.removeCachedEntry(durable.threadId, durable.id); + if (reconciled.entry) this.cacheLedgerEntries([reconciled.entry]); + this.rememberAdmittedEntry(attempted); + return { outcome: 'retired', rowStatus: reconciled.entry ? 'queued' : 'absent' }; + } + + async rollbackProcessingDurable(threadId: string, entryId: string): Promise { + const claimId = this.ledgerClaimIds.get(entryId); + if (!claimId) return false; + const restored = await this.ledgerStore.restore(threadId, entryId, claimId); + if (restored.outcome !== 'updated') return false; + this.ledgerClaimIds.delete(entryId); + this.cacheLedgerEntries([restored.entry]); return true; } - /** - * Promote a queued entry to first in comparator order by setting its position - * below all existing positions. - * Returns false if not found or entry is processing. - */ - promote(threadId: string, userId: string, entryId: string): boolean { - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return false; - const entry = q.find((e) => e.id === entryId); - if (!entry || entry.status === 'processing') return false; - if (entry.exactSteerBatch) return false; - if (isSystemPinnedQueueEntry(entry)) return false; - - const minPos = q.reduce((min, e) => { - if (e.status === 'queued' && e.position !== undefined && e.position < min) return e.position; - return min; - }, 0); - entry.position = minPos - 1; - return true; + private removeCachedEntry(threadId: string, entryId: string): QueueEntry | null { + for (const queue of this.queues.values()) { + if (!this.queueMatchesThread(queue, threadId)) continue; + const index = queue.findIndex((entry) => entry.id === entryId); + if (index < 0) continue; + return queue.splice(index, 1)[0] ?? null; + } + return null; } - /** - * Atomically reserve an explicit ordinary-user allowlist before Steer preempts - * the current invocation. Every validation happens before the first mutation. - */ - reserveExactUserBatch( + async removeProcessedAcrossUsersDurable( + threadId: string, + entryId: string, + terminalOutcome: QueueAttemptSettlementOutcome = 'handled', + failureReason?: QueueTargetAttemptTerminalReason, + terminalAt = Date.now(), + ): Promise { + const admitted = this.forgetAdmittedEntries(threadId, entryId); + if (admitted[0]) return admitted[0]; + const snapshot = this.findEntryAcrossUsers(threadId, entryId); + if (!snapshot || snapshot.status !== 'claimed') return null; + const claimId = this.ledgerClaimIds.get(entryId); + if (!claimId) return null; + const committed = await this.ledgerStore.commit(threadId, entryId, claimId, 'withdrawn', terminalAt); + if (committed.outcome !== 'updated') return null; + this.ledgerClaimIds.delete(entryId); + return this.removeCachedEntry(threadId, entryId); + } + + async removeProcessedDurable(threadId: string, userId: string, entryId: string): Promise { + const snapshot = + this.getEntrySnapshot(threadId, userId, entryId) ?? this.findAdmittedEntry(threadId, entryId, undefined, userId); + if (!snapshot) return null; + return this.removeProcessedAcrossUsersDurable(threadId, entryId); + } + + /** Terminalize one exact row without manufacturing a rollback path. */ + async terminalizeEntryDurable( threadId: string, userId: string, - entryIds: readonly string[], - ): ExactUserBatchReservationResult { - const distinctIds = [...new Set(entryIds)]; - if (distinctIds.length < 2 || distinctIds.length !== entryIds.length || distinctIds.length > MAX_QUEUE_DEPTH) { - return { outcome: 'rejected', reason: 'invalid_entry_ids' }; + entryId: string, + terminalOutcome: QueueAttemptSettlementOutcome = 'handled', + failureReason?: QueueTargetAttemptTerminalReason, + ): Promise { + const snapshot = this.getEntrySnapshot(threadId, userId, entryId); + if (!snapshot) return null; + if (snapshot.status === 'queued') { + const claimed = await this.claimQueuedEntryForWithdrawal(threadId, userId, entryId); + if (!claimed) return null; + if (!(await this.commitClaimedProcessing(threadId, [entryId]))) { + await this.restoreClaimedEntries(threadId, [entryId]); + return null; + } + return this.removeProcessedAcrossUsersDurable(threadId, entryId, terminalOutcome, failureReason); } + // A reversible claim is projected as processing for legacy readers. Do not + // steal another action's in-flight authority. + if (this.ledgerClaimIds.has(entryId)) return null; + return this.removeProcessedAcrossUsersDurable(threadId, entryId, terminalOutcome, failureReason); + } - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return { outcome: 'rejected', reason: 'entry_not_found' }; - const byId = new Map(q.map((entry) => [entry.id, entry])); - const selected = distinctIds.map((entryId) => byId.get(entryId)); - if (selected.some((entry) => !entry)) return { outcome: 'rejected', reason: 'entry_not_found' }; - const entries = selected as QueueEntry[]; - - if (entries.some((entry) => !InvocationQueue.isExactUserBatchEligible(entry))) { - return { outcome: 'rejected', reason: 'entry_ineligible' }; - } + async getDurableEntry(threadId: string, entryId: string): Promise { + return this.ledgerStore.get(threadId, entryId); + } - const primary = entries[0]; - const targetCatId = primary?.targetCats[0]; - if (!primary || !targetCatId) return { outcome: 'rejected', reason: 'entry_ineligible' }; - if ( - entries.some( - (entry) => - entry.intent !== primary.intent || - entry.ownerAuthProvenance !== primary.ownerAuthProvenance || - entry.targetCats[0] !== targetCatId, - ) - ) { - return { outcome: 'rejected', reason: 'entries_incompatible' }; - } + async getDurableEntriesForMessages( + threadId: string, + messageIds: readonly string[], + ): Promise> { + return this.ledgerStore.getByMessageIds(threadId, messageIds); + } - return { outcome: 'reserved', ...this.commitExactSteerReservation(threadId, userId, entries, targetCatId) }; + /** Read every durable pending entry for recovery classification. */ + async listAllDurable(threadId: string): Promise { + return this.ledgerStore.list(threadId); } - /** - * Reserve one exact queued entry before single-message Steer crosses an await. - * The durable steer marker records intent; this process-local identity is the - * exclusive dequeue fence and cannot be claimed through ordinary queue APIs. - */ - reserveExactUserEntry( - threadId: string, - userId: string, - entryId: string, - targetCatId: string, - ): ExactUserEntryReservationResult { - const entry = this.findEntry(threadId, userId, entryId); - if ( - !entry || - entry.status !== 'queued' || - !isOrdinaryQueueTargetEligible(entry, targetCatId) || - entry.exactSteerBatch || - entry.steerRequestedByCatIds?.includes(targetCatId) || - isSystemPinnedQueueEntry(entry) - ) { - return { outcome: 'rejected', reason: 'state_changed' }; + /** Read every active durable row owned by one user across retained threads. */ + async listOwnerDurableEntries(ownerUserId: string): Promise { + const entries: QueueLedgerEntry[] = []; + for (const threadId of await this.ledgerStore.listThreadIds()) { + const rows = await this.ledgerStore.list(threadId); + entries.push(...rows.filter((row) => row.owner.kind === 'user' && row.owner.userId === ownerUserId)); } - return { - outcome: 'reserved', - ...this.commitExactSteerReservation(threadId, userId, [entry], targetCatId), - }; + return entries; } - private commitExactSteerReservation( - threadId: string, - userId: string, - entries: readonly QueueEntry[], - targetCatId: string, - ): ExactSteerBatchReservation { - const primary = entries[0]!; - const entryIds = entries.map((entry) => entry.id); - - const reservation: ExactSteerBatchReservation = { - reservationId: randomUUID(), - primaryEntryId: primary.id, - entryIds, - targetCatId, - }; - const snapshots = new Map(entries.map((entry) => [entry.id, InvocationQueue.cloneEntry(entry)])); - - const q = this.queues.get(this.scopeKey(threadId, userId))!; - - const minPos = q.reduce((min, entry) => { - if (entry.status === 'queued' && entry.position !== undefined && entry.position < min) return entry.position; - return min; - }, 0); - for (const entry of entries) { - entry.exactSteerBatch = { ...reservation, entryIds: [...reservation.entryIds] }; - const requested = new Set(entry.steerRequestedByCatIds ?? []); - requested.add(targetCatId); - entry.steerRequestedByCatIds = [...requested]; - } - primary.position = minPos - 1; - this.exactSteerReservations.set(reservation.reservationId, { - threadId, - userId, - primaryEntryId: primary.id, - targetCatId, - entries: snapshots, - phase: 'reserved', - }); - return reservation; + /** Check if any entry in the thread already carries this messageId (connector retry dedup). */ + hasEntryWithMessageId(threadId: string, messageId: string): boolean { + return this.findEntryWithMessageId(threadId, messageId) !== null; } - /** Cross the durable boundary immediately before preemption gains side effects. */ - beginExactSteerPreemption(threadId: string, userId: string, reservationId: string): boolean { - const reservation = this.exactSteerReservations.get(reservationId); - if ( - !reservation || - reservation.phase !== 'reserved' || - reservation.threadId !== threadId || - reservation.userId !== userId - ) { - return false; - } - const q = this.queues.get(this.scopeKey(threadId, userId)); - if ( - !q || - [...reservation.entries.keys()].some((entryId) => { - const entry = q.find((candidate) => candidate.id === entryId); - return entry?.status !== 'queued' || entry.exactSteerBatch?.reservationId !== reservationId; - }) - ) { - return false; + /** Return the exact Queue carrier for a persisted message across user scopes. */ + findEntryWithMessageId(threadId: string, messageId: string): QueueEntry | null { + for (const q of this.queues.values()) { + if (!this.queueMatchesThread(q, threadId)) continue; + const entry = q.find((e) => e.payload.messageId === messageId); + if (entry) return structuredClone(entry); } - reservation.phase = 'preempting'; - return true; + return null; } - /** Make one successfully preempted reservation eligible for its exact owner claim. */ - activateExactSteerReservation(threadId: string, userId: string, reservationId: string): boolean { - const reservation = this.exactSteerReservations.get(reservationId); - if ( - !reservation || - reservation.phase !== 'preempting' || - reservation.threadId !== threadId || - reservation.userId !== userId - ) { - return false; + /** Backfill messageId on a new entry (null → value). */ + async retireActionSuccessorFenceDurable(fence: ActionSuccessorFence): Promise { + const retired: ActionSuccessorQueueRetirement[] = []; + const matches = [...this.queues.values()] + .flat() + .filter((entry) => actionSuccessorFencesMatch(entry.execution.actionSuccessorFence, fence)); + for (const entry of matches) { + const ownerId = queueEntryOwnerId(entry); + if (await this.terminalizeEntryDurable(entry.threadId, ownerId, entry.id)) { + retired.push({ + entryId: entry.id, + threadId: entry.threadId, + userId: ownerId, + messageIds: exactA2ASourceMessageIds(entry), + }); + } } - const q = this.queues.get(this.scopeKey(threadId, userId)); - if ( - !q || - [...reservation.entries.keys()].some((entryId) => { - const entry = q.find((candidate) => candidate.id === entryId); - return entry?.status !== 'queued' || entry.exactSteerBatch?.reservationId !== reservationId; - }) - ) { - return false; + return retired; + } + + /** Read the exact process carriers before durable custody retirement. */ + listActionSuccessorFence(fence: ActionSuccessorFence): ActionSuccessorQueueRetirement[] { + const matches: ActionSuccessorQueueRetirement[] = []; + for (const queue of this.queues.values()) { + for (const entry of queue) { + if (!actionSuccessorFencesMatch(entry.execution.actionSuccessorFence, fence)) continue; + matches.push({ + entryId: entry.id, + threadId: entry.threadId, + userId: queueEntryOwnerId(entry), + messageIds: exactA2ASourceMessageIds(entry), + }); + } } - reservation.phase = 'activated'; - return true; + return matches; } - /** A reserved/preempting entry still has an in-flight route and cannot be withdrawn safely. */ - hasUnsettledExactSteerReservation(threadId: string, userId: string, entryId?: string): boolean { - return [...this.exactSteerReservations.values()].some( - (reservation) => - reservation.threadId === threadId && - reservation.userId === userId && - reservation.phase !== 'activated' && - (entryId === undefined || reservation.entries.has(entryId)), - ); + /** Shallow copy of all entries sorted by dequeue priority (comparator order). */ + list(threadId: string, userId: string): QueueEntry[] { + const q = this.queues.get(this.scopeKey(threadId, userId)); + if (!q) return []; + return [...q].sort(InvocationQueue.compareEntries).map((entry) => structuredClone(entry)); } - /** Peek a preempted exact reservation without granting ordinary dequeue its identity. */ - peekActivatedExactSteerReservation( - threadId: string, - userId?: string, - skipCatIds: ReadonlySet = new Set(), - onlyTargetCat?: string, - excludeAgent = false, - ): ActivatedExactSteerReservation | null { - let best: ActivatedExactSteerReservation | null = null; - for (const [reservationId, reservation] of this.exactSteerReservations) { - if (reservation.phase !== 'activated' || reservation.threadId !== threadId) continue; - if (userId !== undefined && reservation.userId !== userId) continue; - if (skipCatIds.has(reservation.targetCatId)) continue; - if (onlyTargetCat !== undefined && reservation.targetCatId !== onlyTargetCat) continue; - const queue = this.queues.get(this.scopeKey(threadId, reservation.userId)); - if ( - !queue || - [...reservation.entries.keys()].some((entryId) => { - const entry = queue.find((candidate) => candidate.id === entryId); - return entry?.status !== 'queued' || entry.exactSteerBatch?.reservationId !== reservationId; - }) - ) { - continue; - } - const current = queue.find((entry) => entry.id === reservation.primaryEntryId); - if (!current || current.exactSteerBatch?.primaryEntryId !== current.id) { - continue; - } - if (excludeAgent && current.source === 'agent') continue; - if (!best || InvocationQueue.compareEntries(current, best.entry) < 0) { - best = { reservationId, entry: InvocationQueue.cloneEntry(current) }; - } + /** Canonical hydrated scopes used to resume pending ledger work after startup. */ + listScopes(): Array<{ threadId: string; userId: string }> { + const scopes: Array<{ threadId: string; userId: string }> = []; + for (const queue of this.queues.values()) { + const first = queue[0]; + if (first) scopes.push({ threadId: first.threadId, userId: queueEntryOwnerId(first) }); } - return best; + return scopes; + } + + /** Exact process-local Queue snapshot fence used by explicit row actions. */ + snapshotRevision(threadId: string, userId: string): string { + return createHash('sha256') + .update(JSON.stringify(this.list(threadId, userId))) + .digest('base64url'); } - /** Atomically claim only the exact entry set owned by this activated identity. */ - claimExactSteerReservation( + /** + * Claim one selected public row without borrowing ordinary head-dequeue + * semantics. Revision and the complete target set are checked in the same + * synchronous mutation that crosses queued -> processing. + */ + async claimExactAppend( threadId: string, userId: string, entryId: string, - reservationId: string, - ): QueueEntry | null { - const reservation = this.exactSteerReservations.get(reservationId); - if (reservation?.phase !== 'activated' || reservation.threadId !== threadId || reservation.userId !== userId) { - return null; - } + expectedQueueRevision: string, + expectedTargetIds: readonly string[], + ): Promise { + if (this.snapshotRevision(threadId, userId) !== expectedQueueRevision) return null; const entry = this.findEntry(threadId, userId, entryId); if ( !entry || - entry.exactSteerBatch?.reservationId !== reservationId || - entry.exactSteerBatch.primaryEntryId !== entryId + entry.status !== 'queued' || + entry.kind === 'private_input' || + isSystemPinnedQueueEntry(entry) || + expectedTargetIds.length !== 1 || + !queueEntryTargetCats(entry).includes(expectedTargetIds[0]!) || + expectedTargetIds.some((targetId) => !isOrdinaryQueueTargetEligible(entry, targetId)) ) { return null; } - return this.markEntryProcessing(entry, reservationId); + return this.claimLedgerEntry(entry, expectedTargetIds[0]); } - /** Release an unstarted reservation and restore every selected entry snapshot. */ - releaseExactUserBatch(threadId: string, userId: string, reservationId: string): QueueEntry[] { - const reservation = this.exactSteerReservations.get(reservationId); - if (!reservation || reservation.threadId !== threadId || reservation.userId !== userId) return []; - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) { - this.exactSteerReservations.delete(reservationId); - return []; - } - - const restored: QueueEntry[] = []; - for (const [entryId, snapshot] of reservation.entries) { - const index = q.findIndex( - (entry) => - entry.id === entryId && entry.status === 'queued' && entry.exactSteerBatch?.reservationId === reservationId, - ); - if (index === -1) continue; - const restoredEntry = InvocationQueue.cloneEntry(snapshot); - q[index] = restoredEntry; - restored.push(InvocationQueue.cloneEntry(restoredEntry)); - } - this.exactSteerReservations.delete(reservationId); - return restored; + /** Persist the exact Active Run body exposure before the Queue row is detached. */ + getEntrySnapshot(threadId: string, userId: string, entryId: string): QueueEntry | null { + const entry = this.findEntry(threadId, userId, entryId); + return entry ? structuredClone(entry) : null; } - /** Cancel every still-queued Batch Steer reservation in one user scope. */ - releaseAllExactUserBatches(threadId: string, userId: string): QueueEntry[] { - const reservationIds = new Set( - this.list(threadId, userId).flatMap((entry) => - entry.exactSteerBatch ? [entry.exactSteerBatch.reservationId] : [], - ), - ); - return [...reservationIds].flatMap((reservationId) => this.releaseExactUserBatch(threadId, userId, reservationId)); - } + /** Resolve a durable carrier by its globally unique entry id without trusting a source-thread projection. */ - /** Release a consumed Steer target without restoring already-settled sibling snapshots. */ - pruneExactUserBatchReservation(reservationId: string): boolean { - const referenced = [...this.queues.values()].flatMap((q) => - q.filter((entry) => entry.exactSteerBatch?.reservationId === reservationId), - ); - const reservation = this.exactSteerReservations.get(reservationId); - if (referenced.length > 0) { - if ( - !reservation || - reservation.phase !== 'activated' || - referenced.some((entry) => entry.status !== 'queued' || entry.targetCats.includes(reservation.targetCatId)) - ) - return false; - for (const entry of referenced) { - entry.exactSteerBatch = undefined; - entry.position = reservation.entries.get(entry.id)?.position; - } - } - return this.exactSteerReservations.delete(reservationId); + /** Restore one exact TTL-0 Queue owner after process restart or failed persistence. Idempotent by entryId. */ + getQueuedFreshnessMessagesForCat( + threadId: string, + userId: string, + catId: string, + opts?: { excludeEntryId?: string; parentInvocationId?: string }, + ): Array<{ + entryId: string; + from: MessageFrom; + content: string; + messageId?: string | null; + sourceCategory?: QueueEntry['sourceCategory']; + }> { + return this.list(threadId, userId) + .filter((entry) => entry.id !== opts?.excludeEntryId) + .filter((entry) => entry.status === 'queued' && isOrdinaryQueueTargetEligible(entry, catId)) + .filter((entry) => InvocationQueue.canExposeToCurrentParent(entry, catId, opts?.parentInvocationId)) + .map((entry) => ({ + entryId: entry.id, + from: structuredClone(entry.from), + content: entry.payload.content, + ...(entry.payload.messageId !== undefined ? { messageId: entry.payload.messageId } : {}), + ...(entry.sourceCategory ? { sourceCategory: entry.sourceCategory } : {}), + })); } - /** Exact already-processing members, in the caller-frozen order, excluding the primary. */ - collectExactSteerBatchMembers(entry: QueueEntry): QueueEntry[] | null { - const batch = entry.exactSteerBatch; - if (!batch || batch.primaryEntryId !== entry.id) return null; - const reservation = this.exactSteerReservations.get(batch.reservationId); - if (!reservation || reservation.threadId !== entry.threadId || reservation.userId !== entry.userId) return null; - const q = this.queues.get(this.scopeKey(entry.threadId, entry.userId)); - if (!q) return null; - const byId = new Map(q.map((candidate) => [candidate.id, candidate])); - const members: QueueEntry[] = []; - for (const entryId of batch.entryIds) { - const current = byId.get(entryId); - if ( - !current || - current.status !== 'processing' || - current.exactSteerBatch?.reservationId !== batch.reservationId - ) { - return null; - } - if (entryId !== batch.primaryEntryId) members.push(InvocationQueue.cloneEntry(current)); - } - return members; + /** Queued bodies readable by a target cat until one exact active child adopts them. */ + getQueuedBodyMessagesForCat( + threadId: string, + userId: string, + catId: string, + parentInvocationId?: string, + ): Array<{ + entryId: string; + from: MessageFrom; + content: string; + messageId?: string | null; + alreadyExposed: boolean; + readDisposition: 'adopt' | 'seen_only'; + }> { + return this.list(threadId, userId) + .filter((entry) => entry.status === 'queued' && isOrdinaryQueueTargetEligible(entry, catId)) + .filter((entry) => InvocationQueue.canExposeToCurrentParent(entry, catId, parentInvocationId)) + .map((entry) => ({ + entryId: entry.id, + from: structuredClone(entry.from), + content: entry.payload.content, + alreadyExposed: false, + // A full-body read is actual delivery. Structured successor/hold truth + // moves to its own store after adoption; Queue must not retain the + // target merely to mirror that later lifecycle. + readDisposition: entry.payload.messageId !== undefined ? 'adopt' : 'seen_only', + ...(entry.payload.messageId !== undefined ? { messageId: entry.payload.messageId } : {}), + })); } - /** Restore every member after a start-reservation persistence failure. */ - rollbackExactSteerBatchProcessing(threadId: string, reservationId: string): QueueEntry[] { - const reservation = this.exactSteerReservations.get(reservationId); - if (!reservation || reservation.threadId !== threadId) return []; - const q = this.queues.get(this.scopeKey(reservation.threadId, reservation.userId)); - if (!q) return []; - const rolledBack: QueueEntry[] = []; - for (const entryId of reservation.entries.keys()) { - const entry = q.find( - (candidate) => - candidate.id === entryId && - candidate.status === 'processing' && - candidate.exactSteerBatch?.reservationId === reservationId, - ); - if (!entry) continue; - entry.status = 'queued'; - delete entry.processingStartedAt; - rolledBack.push(InvocationQueue.cloneEntry(entry)); - } - return rolledBack; - } - - private static isExactUserBatchEligible(entry: QueueEntry): boolean { - const targetCatId = entry.targetCats[0]; - return ( - entry.status === 'queued' && - entry.source === 'user' && - entry.ownerAuthProvenance !== 'unknown' && - entry.targetCats.length === 1 && - !!targetCatId && - !entry.autoExecute && - !entry.sourceCategory && - !entry.actionSuccessorFence && - !entry.waitContinuationCarrier && - !entry.freshnessClosureId && - !entry.freshnessSupplementId && - !entry.continuationKey && - !entry.callerCatId && - !entry.a2aParentInvocationId && - !entry.a2aTriggerMessageId && - !entry.exactSteerBatch && - isOrdinaryQueueTargetEligible(entry, targetCatId) && - entry.authorIntentByCatId?.[targetCatId]?.requested !== 'continue_current' + private static canExposeToCurrentParent( + entry: Pick, + catId: string, + parentInvocationId: string | undefined, + ): boolean { + // Author disposition belongs only to human-authored work. Agent and connector + // carriers retain their typed custody/continuation path and may be read at a + // current safe boundary without manufacturing a human queue preference. + if (entry.from.kind !== 'user') return true; + const authorIntent = entry.delivery.authorIntentByTarget?.[catId]; + return Boolean( + parentInvocationId && + authorIntent?.requested === 'continue_current' && + authorIntent.fallbackAt === undefined && + authorIntent.boundParentInvocationId === parentInvocationId, ); } - /** Mark a primary plus its frozen exact allowlist in one synchronous mutation. */ - private markEntryProcessing( - entry: QueueEntry, - exactReservationId?: string, - selectedTargetCatId?: string, - ): QueueEntry | null { - const batch = entry.exactSteerBatch; - const targetCatId = - selectedTargetCatId ?? - batch?.targetCatId ?? - entry.targetCats.find((catId) => isOrdinaryQueueTargetEligible(entry, catId)); - if (!targetCatId || !isOrdinaryQueueTargetEligible(entry, targetCatId)) return null; - if (!batch) { - entry.status = 'processing'; - entry.processingStartedAt = Date.now(); - return InvocationQueue.cloneEntry(entry); - } - if (exactReservationId !== batch.reservationId) return null; - if (batch.primaryEntryId !== entry.id) return null; - const reservation = this.exactSteerReservations.get(batch.reservationId); - if ( - reservation?.phase !== 'activated' || - reservation.threadId !== entry.threadId || - reservation.userId !== entry.userId - ) { - return null; - } - const q = this.queues.get(this.scopeKey(entry.threadId, entry.userId)); - if (!q) return null; - const byId = new Map(q.map((candidate) => [candidate.id, candidate])); - const selected = batch.entryIds.map((entryId) => byId.get(entryId)); - if ( - selected.some( - (candidate) => - !candidate || - candidate.status !== 'queued' || - candidate.exactSteerBatch?.reservationId !== batch.reservationId, - ) - ) { - return null; - } - const processingStartedAt = Date.now(); - for (const candidate of selected as QueueEntry[]) { - candidate.status = 'processing'; - candidate.processingStartedAt = processingStartedAt; - } - return InvocationQueue.cloneEntry(entry); - } - - /** F175: Mark the highest-priority queued entry as processing (stays in array). */ - markProcessing(threadId: string, userId: string): QueueEntry | null { + size(threadId: string, userId: string): number { const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return null; - const queued = q.filter( - (e) => - e.status === 'queued' && - !e.exactSteerBatch && - e.targetCats.some((catId) => isOrdinaryQueueTargetEligible(e, catId)), - ); - if (queued.length === 0) return null; - queued.sort(InvocationQueue.compareEntries); - const best = queued[0]!; - return this.markEntryProcessing(best); + if (!q) return 0; + return q.filter((e) => e.status === 'queued').length; } - /** F175: Peek at the highest-priority queued entry without mutating state. */ + /** + * Move entry up or down in comparator order by swapping positions with its neighbor. + * Returns false if entry is processing or not found. + */ peekNextQueued(threadId: string, userId: string): QueueEntry | null { const q = this.queues.get(this.scopeKey(threadId, userId)); if (!q) return null; - const queued = q.filter( - (e) => - e.status === 'queued' && - !e.exactSteerBatch && - e.targetCats.some((catId) => isOrdinaryQueueTargetEligible(e, catId)), - ); + const queued = q.filter((entry) => entry.status === 'queued'); if (queued.length === 0) return null; queued.sort(InvocationQueue.compareEntries); - return { ...queued[0]! }; + return structuredClone(queued[0]!); } /** Rollback a processing entry back to queued (undo markProcessing/markProcessingAcrossUsers). */ - rollbackProcessing(threadId: string, entryId: string): boolean { - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - const entry = q.find((e) => e.id === entryId && e.status === 'processing'); - if (entry) { - entry.status = 'queued'; - delete entry.processingStartedAt; - return true; - } - } - return false; - } - - /** Remove a processing entry for this user by entryId. */ - removeProcessed(threadId: string, userId: string, entryId: string): QueueEntry | null { - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return null; - const idx = q.findIndex((e) => e.status === 'processing' && e.id === entryId); - if (idx === -1) return null; - this.originalContents.delete(entryId); - - return q.splice(idx, 1)[0] ?? null; - } - - // ── Cross-user methods (system-level only) ── - - /** F175: Find the highest-priority queued entry across all users for a thread. */ peekOldestAcrossUsers(threadId: string): QueueEntry | null { let best: QueueEntry | null = null; for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; for (const e of q) { - if (e.status !== 'queued' || e.exactSteerBatch) continue; - if (!e.targetCats.some((catId) => isOrdinaryQueueTargetEligible(e, catId))) continue; + if (e.status !== 'queued') continue; if (!best || InvocationQueue.compareEntries(e, best) < 0) { best = e; } @@ -1847,127 +1721,20 @@ export class InvocationQueue { return best ? { ...best } : null; } - /** F175: Mark the highest-priority queued entry across users as processing. - * skipCatIds: skip entries whose primary target cat is in this set (slot busy). - * onlyCatId: restrict recovery to the exact slot whose pause elapsed. */ - markProcessingAcrossUsers( - threadId: string, - skipCatIds?: Set, - onlyCatId?: string, - excludeAgent = false, - ): QueueEntry | null { - let best: QueueEntry | null = null; - let bestTargetCatId: string | undefined; - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - for (const e of q) { - if (e.status !== 'queued' || e.exactSteerBatch) continue; - if (excludeAgent && e.source === 'agent') continue; - const targetCatId = - onlyCatId !== undefined - ? isOrdinaryQueueTargetEligible(e, onlyCatId) - ? onlyCatId - : undefined - : e.targetCats.find((catId) => isOrdinaryQueueTargetEligible(e, catId) && !skipCatIds?.has(catId)); - if (!targetCatId || skipCatIds?.has(targetCatId)) continue; - if (!best || InvocationQueue.compareEntries(e, best) < 0) { - best = e; - bestTargetCatId = targetCatId; - } - } - } - if (!best) return null; - return this.markEntryProcessing(best, undefined, bestTargetCatId); - } - - /** Remove a processing entry across all users for a thread by entryId. */ - removeProcessedAcrossUsers(threadId: string, entryId: string): QueueEntry | null { - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - const idx = q.findIndex((e) => e.status === 'processing' && e.id === entryId); - if (idx !== -1) { - this.originalContents.delete(entryId); - - return q.splice(idx, 1)[0] ?? null; - } - } - return null; - } - - private resolveProcessingGroupAcrossUsers( - threadId: string, - entryId: string, - ): { queue: QueueEntry[]; members: Array<{ candidate: QueueEntry; index: number }>; reservationId?: string } | null { - for (const queue of this.queues.values()) { - if (!this.queueMatchesThread(queue, threadId)) continue; - const entry = queue.find((candidate) => candidate.status === 'processing' && candidate.id === entryId); - if (!entry) continue; - const batch = entry.exactSteerBatch; - if (!batch && entry.prestartRetirement) { - const intent = entry.prestartRetirement; - const members = queue - .map((candidate, index) => ({ candidate, index })) - .filter( - ({ candidate }) => - candidate.status === 'processing' && - candidate.prestartRetirement?.id === intent.id && - intent.entryIds.includes(candidate.id), - ); - if (members.length === 0) return null; - if ( - members.some( - ({ candidate }) => - candidate.threadId !== entry.threadId || - candidate.userId !== entry.userId || - JSON.stringify(candidate.prestartRetirement) !== JSON.stringify(intent), - ) - ) { - return null; - } - return { queue, members }; - } - if (!batch) return { queue, members: [{ candidate: entry, index: queue.indexOf(entry) }] }; - - const reservation = this.exactSteerReservations.get(batch.reservationId); - if ( - !reservation || - reservation.threadId !== threadId || - reservation.userId !== entry.userId || - reservation.primaryEntryId !== batch.primaryEntryId || - reservation.entries.size !== batch.entryIds.length || - batch.entryIds.some((memberId) => !reservation.entries.has(memberId)) - ) { - return null; - } - - const byId = new Map(queue.map((candidate, index) => [candidate.id, { candidate, index }])); - const members: Array<{ candidate: QueueEntry; index: number }> = []; - for (const memberId of batch.entryIds) { - const member = byId.get(memberId); - if ( - !member || - member.candidate.status !== 'processing' || - member.candidate.exactSteerBatch?.reservationId !== batch.reservationId - ) { - return null; - } - members.push(member); - } - return { queue, members, reservationId: batch.reservationId }; - } - return null; - } - - /** Fail-closed preflight for synchronous external replacement of a processing group. */ - canRemoveProcessingGroupAcrossUsers(threadId: string, entryId: string): boolean { - return this.resolveProcessingGroupAcrossUsers(threadId, entryId) !== null; - } - - /** Snapshot a complete processing group without making it invisible. */ + /** Mark the strict comparator head across users as processing. */ getProcessingGroupAcrossUsers(threadId: string, entryId: string): QueueEntry[] | null { - const group = this.resolveProcessingGroupAcrossUsers(threadId, entryId); - if (!group) return null; - return group.members.map((member) => InvocationQueue.cloneEntry(member.candidate)); + const selected = this.findAdmittedEntry(threadId, entryId) ?? this.findEntryAcrossUsers(threadId, entryId); + if (!selected || (selected.status !== 'claimed' && selected.status !== 'processing')) return null; + if (!selected.retiringGroupId) return [structuredClone(selected)]; + const group = [...this.queues.values(), ...this.admittedEntries.values()] + .flatMap((entries) => entries) + .filter( + (entry) => + entry.threadId === threadId && + (entry.status === 'claimed' || entry.status === 'processing') && + entry.retiringGroupId === selected.retiringGroupId, + ); + return group.length > 0 ? group.map((entry) => structuredClone(entry)) : null; } /** @@ -1976,30 +1743,14 @@ export class InvocationQueue { * primary row as the whole reservation. Ordinary attempt settlement remains * per-entry and must keep using removeProcessedAcrossUsers. */ - removeProcessingGroupAcrossUsers(threadId: string, entryId: string): QueueEntry[] | null { - const group = this.resolveProcessingGroupAcrossUsers(threadId, entryId); - if (!group) return null; - const removed = group.members.map((member) => member.candidate); - for (const member of [...group.members].sort((left, right) => right.index - left.index)) { - const removedEntry = group.queue.splice(member.index, 1)[0]; - if (removedEntry) this.originalContents.delete(removedEntry.id); - } - if (group.reservationId) this.exactSteerReservations.delete(group.reservationId); - return removed; - } - - /** - * Find the in-flight (processing) entry occupying a cat's per-cat slot in a thread, across all - * users. 2026-06-02 (Steer 无法抢占): steer-immediate uses this to locate the entry whose - * executeEntry holds the slot, so it can tombstone it (removeProcessedAcrossUsers) instead of - * force-releasing the slot — the tombstone makes executeEntry self-abort at its post-startAll - * guard, which is race-safe through the pre-start (create-await) window. Returns null if none. - */ - findProcessingByCat(threadId: string, catId: string): QueueEntry | null { - for (const q of this.queues.values()) { + findProcessingByCat(threadId: string, catId: string, excludeEntryId?: string): QueueEntry | null { + for (const q of [...this.queues.values(), ...this.admittedEntries.values()]) { if (!this.queueMatchesThread(q, threadId)) continue; - const entry = q.find((e) => e.status === 'processing' && (e.targetCats[0] ?? 'unknown') === catId); - if (entry) return entry; + const entry = q.find( + (e) => + (e.status === 'claimed' || e.status === 'processing') && e.id !== excludeEntryId && e.targets.includes(catId), + ); + if (entry) return structuredClone(entry); } return null; } @@ -2009,7 +1760,7 @@ export class InvocationQueue { const users: string[] = []; for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId) || q.length === 0) continue; - users.push(q[0]!.userId); + users.push(queueEntryOwnerId(q[0]!)); } return users; } @@ -2022,11 +1773,11 @@ export class InvocationQueue { for (const e of q) { if ( e.status !== 'queued' || - !e.autoExecute || - !e.targetCats.some((catId) => isOrdinaryQueueTargetEligible(e, catId)) + !e.execution.autoExecute || + (e.targets.length > 0 && e.targets.every((catId) => !isOrdinaryQueueTargetEligible(e, catId))) ) continue; - result.push({ ...e }); + result.push(structuredClone(e)); } } return result; @@ -2040,10 +1791,13 @@ export class InvocationQueue { for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; for (const e of q) { - if (e.source !== 'agent' || !e.targetCats.some((catId) => isQueueTargetPending(e, catId))) continue; + if (e.from.kind !== 'agent' || e.targets.length === 0) continue; count++; } } + for (const entries of this.admittedEntries.values()) { + count += entries.filter((entry) => entry.threadId === threadId && entry.from.kind === 'agent').length; + } return count; } @@ -2055,7 +1809,7 @@ export class InvocationQueue { for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; for (const e of q) { - if (e.source === 'agent' && e.status === 'queued' && isQueueTargetPending(e, catId)) { + if (e.from.kind === 'agent' && e.status === 'queued' && isQueueTargetPending(e, catId)) { return true; } } @@ -2063,158 +1817,21 @@ export class InvocationQueue { return false; } - /** - * F-coalesce: find the best in-flight agent entry to coalesce a same-turn handoff into. - * - * Used by callback-a2a-trigger to merge a caller's repeated same-turn handoffs to the same target - * instead of dispatching duplicate invocations. Resolution PREFERS a mergeable 'queued' entry over - * a 'processing' one: a queued entry can be merged in place (coalesceContentIntoQueuedAgent), - * whereas a processing entry is already running and can only be superseded (abort+restart, deferred - * to F216). So when a cat has BOTH a running entry and a queued follow-up, a third handoff must - * merge into the queued follow-up — not spawn yet another entry. Hence the two-pass scan. - * - * Stale processing entries (zombie invocations past STALE_PROCESSING_THRESHOLD_MS) are ignored so - * a hung invocation never permanently swallows new handoffs. Returns a copy (never a live ref). - */ - findInFlightAgentEntry( - threadId: string, - catId: string, - callerCatId?: string, - a2aParentInvocationId?: string, - ownerAuthProvenance?: OwnerAuthProvenance, - ): QueueEntry | null { - // 云端 codex R4 P1: scope to sourceCategory 'a2a'. `source: 'agent'` alone also matches - // self-continuation entries (QueueProcessor.enqueueContinuation → source:'agent', - // sourceCategory:'continuation'). Without this filter an A2A handoff to a cat that has a queued - // continuation would merge INTO the continuation prompt — mixing unrelated control-flow content - // with another cat's handoff AND suppressing the real A2A route. Only same-category 'a2a' entries - // are the caller's repeated same-turn handoffs and thus semantically mergeable. (Mirrors the - // existing sourceCategory discrimination in isSystemPinnedQueueEntry / normalizedPriority.) - // - // F216 c0 (砚砚 GPT-5.5 review P1): ALSO scope by callerCatId. Only the SAME caller's repeated - // same-turn handoffs are mergeable — without this, cat A's queued handoff to a target gets - // coalesced/superseded by cat B's later handoff to the same target (cross-caller串味). - // - // Parallel invocations of one cat still share callerCatId, so callback callers additionally - // pass a2aParentInvocationId. Both supplied scopes use strict matching: a missing field never - // adopts a scoped lookup. Omitted scopes preserve compatibility for legacy/non-invocation - // callers, preferring a fresh entry whenever the lineage is known but does not match. - const matches = (e: QueueEntry): boolean => { - if (!(e.source === 'agent' && e.sourceCategory === 'a2a' && isOrdinaryQueueTargetEligible(e, catId))) { - return false; - } - if (callerCatId !== undefined && !(e.callerCatId !== undefined && e.callerCatId === callerCatId)) return false; - if ( - a2aParentInvocationId !== undefined && - !(e.a2aParentInvocationId !== undefined && e.a2aParentInvocationId === a2aParentInvocationId) - ) { - return false; - } - if (ownerAuthProvenance !== undefined && e.ownerAuthProvenance !== ownerAuthProvenance) return false; - return true; - }; - // Pass 1: prefer a mergeable queued entry (in-place coalesce, no abort needed). - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - for (const e of q) { - if (matches(e) && e.status === 'queued') return { ...e }; - } - } - // Pass 2: fall back to a fresh (non-stale) processing entry — caller defers supersede to F216. - const now = Date.now(); - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - for (const e of q) { - if (!matches(e) || e.status !== 'processing') continue; - const age = now - (e.processingStartedAt ?? e.createdAt); - if (age < InvocationQueue.STALE_PROCESSING_THRESHOLD_MS) return { ...e }; - } - } - return null; - } - - /** - * F-coalesce: merge new content + messageId into an existing QUEUED agent entry. - * - * Only succeeds while the entry is still 'queued' (not yet dispatched) — returns false if it has - * already started processing (the caller must supersede via abort+restart instead, see F216). - * Content is appended with a blank-line separator so the target cat sees both handoffs as one - * coherent message (parity with collectUserBatch's user-message coalescing). The new messageId is - * tracked in mergedMessageIds so delivery/ack covers both trigger messages. - */ - coalesceContentIntoQueuedAgent( - threadId: string, - userId: string, - entryId: string, - content: string, - messageId?: string, - callerCatId?: string, - a2aParentInvocationId?: string, - ownerAuthProvenance?: OwnerAuthProvenance, - targetCatId?: string, - queueCustodyAdmissionId?: string, - ): boolean { - const e = this.findEntry(threadId, userId, entryId); - if (!e || e.status !== 'queued') return false; - // 云端 codex R4 P1 (defense-in-depth): only A2A entries are mergeable. findInFlightAgentEntry - // already scopes to sourceCategory 'a2a', but guard here too so a future caller passing a - // continuation/other entryId can never splice a handoff into unrelated control-flow content. - if (!(e.source === 'agent' && e.sourceCategory === 'a2a')) return false; - const intendedTargetCatId = targetCatId ?? e.targetCats[0]; - if (!intendedTargetCatId || !isOrdinaryQueueTargetEligible(e, intendedTargetCatId)) return false; - // Defense-in-depth source scope: a stale/wrong entryId from another caller or parallel - // invocation can never splice content. Supplied scopes require a defined exact match; omitted - // scopes stay off for legacy/non-invocation callers. - if (callerCatId !== undefined && !(e.callerCatId !== undefined && e.callerCatId === callerCatId)) return false; - if ( - a2aParentInvocationId !== undefined && - !(e.a2aParentInvocationId !== undefined && e.a2aParentInvocationId === a2aParentInvocationId) - ) { - return false; - } - if (ownerAuthProvenance !== undefined && e.ownerAuthProvenance !== ownerAuthProvenance) return false; - if (e.queueCustodyAdmissionId) return false; - e.content = `${e.content}\n\n${content}`; - if (messageId && e.messageId !== messageId && !e.mergedMessageIds.includes(messageId)) { - e.mergedMessageIds.push(messageId); - } - // New queued content invalidates previous read markers; the target has not seen the appended body. - e.queuedNotifiedByCatIds = undefined; - e.queuedSeenByCatIds = undefined; - e.queuedSeenInvocationIdByCatId = undefined; - e.queueCustodyAdmissionId = queueCustodyAdmissionId; - return true; - } - - /** - * Cross-path dedup: checks processing + fresh queued agent entries. - * Used by route-serial to prevent text-scan @mention when callback already dispatched. - * - * 'processing' entries block only if fresh (< STALE_PROCESSING_THRESHOLD_MS). - * Zombie processing entries (invocation hung without cleanup) are ignored to - * prevent permanent A2A routing deadlock. - * - * 'queued' entries always block: they are legitimate pending dispatches and - * listAutoExecute/markProcessingAcrossUsers will still pick them up after a long wait. - */ - /** @deprecated queued agent entries are no longer expired by age; retained for old migration tests. */ - static readonly STALE_QUEUED_THRESHOLD_MS = 60_000; - static readonly STALE_PROCESSING_THRESHOLD_MS = 600_000; // 10 minutes - + /** Cross-path dedup guard for queued or live agent work targeting one cat. */ hasActiveOrQueuedAgentForCat(threadId: string, catId: string, opts?: { excludeEntryId?: string }): boolean { const now = Date.now(); for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; for (const e of q) { if (opts?.excludeEntryId && e.id === opts.excludeEntryId) continue; - if (e.source !== 'agent' || !isQueueTargetPending(e, catId)) continue; + if (e.from.kind !== 'agent' || !isQueueTargetPending(e, catId)) continue; - if (e.status === 'processing') { + if (e.status === 'claimed' || e.status === 'processing') { // Use processingStartedAt (when the entry actually began processing), // NOT createdAt (when it was enqueued). An entry may sit queued for a // long time before being picked up — using createdAt would falsely // expire it the moment it starts processing. (P1 fix per codex review) - const processingAge = now - (e.processingStartedAt ?? e.createdAt); + const processingAge = now - (e.processingStartedAt ?? e.claimedAt ?? e.enqueuedAt); if (processingAge < InvocationQueue.STALE_PROCESSING_THRESHOLD_MS) { this.log?.info( { @@ -2224,7 +1841,7 @@ export class InvocationQueue { entryId: e.id, status: e.status, processingAgeMs: processingAge, - userId: e.userId, + owner: e.owner, }, }, '[DIAG] hasActiveOrQueuedAgentForCat hit', @@ -2240,7 +1857,7 @@ export class InvocationQueue { entryId: e.id, status: e.status, processingAgeMs: processingAge, - userId: e.userId, + owner: e.owner, }, }, '[DIAG] hasActiveOrQueuedAgentForCat: ignoring stale processing entry (zombie defense)', @@ -2256,8 +1873,8 @@ export class InvocationQueue { matchedEntry: { entryId: e.id, status: e.status, - queuedAgeMs: now - e.createdAt, - userId: e.userId, + queuedAgeMs: now - e.enqueuedAt, + owner: e.owner, }, }, '[DIAG] hasActiveOrQueuedAgentForCat hit', @@ -2266,6 +1883,19 @@ export class InvocationQueue { } } } + for (const entries of this.admittedEntries.values()) { + if ( + entries.some( + (entry) => + entry.threadId === threadId && + entry.id !== opts?.excludeEntryId && + entry.from.kind === 'agent' && + entry.targets.includes(catId), + ) + ) { + return true; + } + } return false; } @@ -2276,7 +1906,7 @@ export class InvocationQueue { opts?: { excludeEntryId?: string; userId?: string; - sources?: QueueEntry['source'][]; + sources?: Array>; sourceCategories?: NonNullable[]; continuationKey?: string; }, @@ -2286,20 +1916,20 @@ export class InvocationQueue { if (!this.queueMatchesThread(q, threadId)) continue; for (const e of q) { if (opts?.excludeEntryId && e.id === opts.excludeEntryId) continue; - if (opts?.userId && e.userId !== opts.userId) continue; + if (opts?.userId && queueEntryOwnerId(e) !== opts.userId) continue; if (!isQueueTargetPending(e, catId)) continue; - if (opts?.sources && !opts.sources.includes(e.source)) continue; + if (opts?.sources && !opts.sources.includes(queueEntrySource(e))) continue; if (opts?.sourceCategories) { if (!e.sourceCategory || !opts.sourceCategories.includes(e.sourceCategory)) continue; } - if (opts?.continuationKey !== undefined && e.continuationKey !== opts.continuationKey) continue; + if (opts?.continuationKey !== undefined && e.payload.sourceRecordId !== opts.continuationKey) continue; if (e.status === 'queued') { return true; } - if (e.status === 'processing') { - const processingAge = now - (e.processingStartedAt ?? e.createdAt); + if (e.status === 'claimed' || e.status === 'processing') { + const processingAge = now - (e.processingStartedAt ?? e.claimedAt ?? e.enqueuedAt); if (processingAge >= InvocationQueue.STALE_PROCESSING_THRESHOLD_MS) { this.log?.warn( { @@ -2309,7 +1939,7 @@ export class InvocationQueue { entryId: e.id, status: e.status, processingAgeMs: processingAge, - userId: e.userId, + owner: e.owner, }, }, '[DIAG] hasPendingForCat: ignoring stale processing entry (zombie defense)', @@ -2320,59 +1950,25 @@ export class InvocationQueue { } } } - return false; - } - - /** F122B: Mark a specific entry as processing by ID (cross-user). */ - markProcessingById(threadId: string, entryId: string, targetCatId?: string): boolean { - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - const entry = q.find((e) => e.id === entryId && e.status === 'queued' && !e.exactSteerBatch); - if (entry) { - const selectedTargetCatId = - targetCatId ?? entry.targetCats.find((catId) => isOrdinaryQueueTargetEligible(entry, catId)); - if (!selectedTargetCatId || !isOrdinaryQueueTargetEligible(entry, selectedTargetCatId)) return false; - return this.markEntryProcessing(entry, undefined, selectedTargetCatId) !== null; + for (const entries of this.admittedEntries.values()) { + for (const entry of entries) { + if (entry.threadId !== threadId || !entry.targets.includes(catId)) continue; + if (opts?.excludeEntryId && entry.id === opts.excludeEntryId) continue; + if (opts?.userId && queueEntryOwnerId(entry) !== opts.userId) continue; + if (opts?.sources && !opts.sources.includes(queueEntrySource(entry))) continue; + if ( + opts?.sourceCategories && + (!entry.sourceCategory || !opts.sourceCategories.includes(entry.sourceCategory)) + ) { + continue; + } + if (opts?.continuationKey !== undefined && entry.payload.sourceRecordId !== opts.continuationKey) continue; + return true; } } return false; } - /** - * F175: Collect a batch of adjacent user entries for unified execution. - * Non-user sources always return a single-entry batch. - * User entries batch while: same source, same intent, same targetCats (set equality), - * and the exact same server-derived owner authentication provenance. - * Returns copies — caller is responsible for marking processing. - */ - collectUserBatch(threadId: string, userId: string): QueueEntry[] { - const key = this.scopeKey(threadId, userId); - const q = this.queues.get(key); - if (!q) return []; - - const queued = q.filter((e) => e.status === 'queued'); - if (queued.length === 0) return []; - queued.sort(InvocationQueue.compareEntries); - - const first = queued[0]!; - if (first.source !== 'user') return [{ ...first }]; - - const batch: QueueEntry[] = [{ ...first }]; - const firstTargetsSorted = sorted(first.targetCats); - for (let i = 1; i < queued.length; i++) { - const e = queued[i]!; - if ( - e.source !== 'user' || - e.intent !== first.intent || - e.ownerAuthProvenance !== first.ownerAuthProvenance || - !arraysEqual(sorted(e.targetCats), firstTargetsSorted) - ) - break; - batch.push({ ...e }); - } - return batch; - } - /** #555: Whether a specific cat has any queued or processing entries in this thread (any source). * Queued entries remain valid pending work regardless of age; only stale processing * entries are ignored to prevent zombie entries from permanently blocking a cat. */ @@ -2385,45 +1981,36 @@ export class InvocationQueue { if (e.status === 'queued') { return true; } - if (e.status === 'processing') { - const age = now - (e.processingStartedAt ?? e.createdAt); + if (e.status === 'claimed' || e.status === 'processing') { + const age = now - (e.processingStartedAt ?? e.claimedAt ?? e.enqueuedAt); if (age < InvocationQueue.STALE_PROCESSING_THRESHOLD_MS) return true; } } } + for (const entries of this.admittedEntries.values()) { + if (entries.some((entry) => entry.threadId === threadId && entry.targets.includes(catId))) return true; + } return false; } - /** Whether any scope has fresh queued entries for this thread. - * Agent-sourced entries are dispatchable pending work regardless of age; - * user/connector entries keep the stale guard so old interactive messages - * do not permanently force thread-wide queue/busy mode. - */ + /** Durable work remains thread-visible until an explicit admission or terminal transition removes it. */ hasQueuedForThread(threadId: string): boolean { - const now = Date.now(); for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; - if ( - q.some((e) => { - if (e.status !== 'queued') return false; - if (e.source === 'agent') return true; - return now - e.createdAt < InvocationQueue.STALE_QUEUED_THRESHOLD_MS; - }) - ) { - return true; - } + if (q.some((entry) => entry.status === 'queued')) return true; } return false; } - /** Whether ordinary scheduling has at least one nonfailed target to select. */ + /** Whether ordinary scheduling has at least one queued row to select. */ hasOrdinaryEligibleQueuedForThread(threadId: string): boolean { for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; if ( q.some( (entry) => - entry.status === 'queued' && entry.targetCats.some((catId) => isOrdinaryQueueTargetEligible(entry, catId)), + entry.status === 'queued' && + (entry.targets.length === 0 || entry.targets.some((catId) => isOrdinaryQueueTargetEligible(entry, catId))), ) ) { return true; @@ -2432,11 +2019,7 @@ export class InvocationQueue { return false; } - /** - * Whether Queue still owns work for this thread, including failed targets - * awaiting explicit retry. Actual dispatch selectors apply per-target - * eligibility separately. - */ + /** Whether Queue still owns queued work for this thread. */ hasDispatchableQueuedForThread(threadId: string): boolean { for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; @@ -2445,72 +2028,15 @@ export class InvocationQueue { return false; } - /** F185 AC-6: Whether any non-agent entry (user or connector) is queued for this thread. */ - hasQueuedNonAgentForThread(threadId: string): boolean { - for (const q of this.queues.values()) { - if (!this.queueMatchesThread(q, threadId)) continue; - if (q.some((e) => e.status === 'queued' && e.source !== 'agent')) return true; - } - return false; - } - - /** - * Whether any user-sourced message is queued for this thread (user-only filter). - * F185 Phase B: text-scan fairness gate now uses hasQueuedNonAgentForThread instead. - * Retained for backward compatibility but no longer used by fairness gates. - */ - hasQueuedUserMessagesForThread(threadId: string): boolean { + /** Whether a public conversation input is waiting; private/wake rows do not drive text-scan fairness. */ + hasQueuedConversationInputsForThread(threadId: string): boolean { for (const q of this.queues.values()) { if (!this.queueMatchesThread(q, threadId)) continue; - if (q.some((e) => e.status === 'queued' && e.source === 'user')) return true; + if (q.some((entry) => entry.status === 'queued' && entry.kind === 'conversation_input')) return true; } return false; } - /** - * #815: Find queued A2A trigger entries whose target cats are all active. - * Scoped to a single userId — prompt context assembly is per-user, so - * consuming another user's A2A entry would silently lose their trigger. - * Returns candidates without removing them — caller performs async - * delivery-status filtering, then calls `consumeEntriesById` to remove. - */ - findSubsumedA2ACandidates(threadId: string, userId: string, activeCatSet: Set): QueueEntry[] { - const q = this.queues.get(this.scopeKey(threadId, userId)); - if (!q) return []; - const candidates: QueueEntry[] = []; - for (const e of q) { - if (e.status !== 'queued') continue; - if (e.sourceCategory !== 'a2a') continue; - if (!e.targetCats.every((cat) => activeCatSet.has(cat))) continue; - if (!e.targetCats.every((cat) => isOrdinaryQueueTargetEligible(e, cat))) continue; - candidates.push(e); - } - return candidates; - } - - /** - * #815: Remove specific entries by ID. Returns removed entries. - * Used after async filtering of A2A candidates by delivery status. - */ - consumeEntriesById(entryIds: Set): QueueEntry[] { - const consumed: QueueEntry[] = []; - for (const q of this.queues.values()) { - for (let i = q.length - 1; i >= 0; i--) { - if (entryIds.has(q[i]!.id)) { - this.originalContents.delete(q[i]!.id); - consumed.push(q.splice(i, 1)[0]!); - } - } - } - if (consumed.length > 0) { - this.log.info( - { count: consumed.length, entryIds: consumed.map((e) => e.id) }, - '#815: consumed A2A entries by ID', - ); - } - return consumed; - } - // ── Internal helpers ── private findEntry(threadId: string, userId: string, entryId: string): QueueEntry | undefined { @@ -2518,17 +2044,3 @@ export class InvocationQueue { return q?.find((e) => e.id === entryId); } } - -/** Sort a string array (returns new array). */ -function sorted(arr: string[]): string[] { - return [...arr].sort(); -} - -/** Compare two sorted string arrays for equality. */ -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts index 76ed53fe23..960f383f5d 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts @@ -9,10 +9,15 @@ * F118 post-close: age only marks a lease as a reaper candidate. Read APIs are * observational and never abort or delete provider ownership. */ + +import type { LifecycleActiveRun } from '@cat-cafe/shared'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; +import type { AgentClientActiveRunDispatcher } from '../../types.js'; const log = createModuleLogger('invocation-tracker'); export const DEFAULT_INVOCATION_SLOT_TTL_MS = 75 * 60_000; +/** A Queue claim may exist briefly before startAll publishes its tracker owner. */ +export const DEFAULT_PRESTART_RESERVATION_TTL_MS = 30_000; interface ActiveInvocation { controller: AbortController; @@ -45,6 +50,10 @@ interface ActiveInvocation { * completes, the tombstone is kept for resolveFinalStatus() but no longer blocks seal. */ teardownComplete?: boolean; + /** Exact conversation-delivery run, bound only after its durable processing bubble exists. */ + activeRun?: LifecycleActiveRun; + /** Live provider adapter for the exact activeRun; never serialized or recovered. */ + activeRunDispatcher?: AgentClientActiveRunDispatcher; } /** F-parallel-cancel: observable slot lifecycle state for callers that need to distinguish @@ -54,6 +63,7 @@ export type SlotState = 'active' | 'canceled' | 'absent'; export interface ActiveSlotInfo { catId: string; startedAt: number; + activeRun?: LifecycleActiveRun; } export interface StaleInvocationSlotInfo { @@ -483,13 +493,156 @@ export class InvocationTracker { return this.active.get(key)?.userId ?? null; } - /** Exact execution fence for non-interrupting per-target reminder attempts. */ + /** Exact execution fence for active-run admission and control operations. */ getExecutionId(threadId: string, catId: string): string | undefined { const inv = this.active.get(this.slotKey(threadId, catId)); if (!inv || inv.state !== 'active') return undefined; return inv.executionId; } + /** + * Publish the non-durable ActiveRun only after durable admission has created + * the exact response bubble and input dispatch refs. Parent execution + * matching prevents a late child callback from binding a replacement slot. + */ + bindLifecycleActiveRun(run: LifecycleActiveRun, expectedExecutionId?: string): boolean { + const inv = this.active.get(this.slotKey(run.threadId, run.targetId)); + if (!inv || inv.state !== 'active') return false; + if (expectedExecutionId !== undefined && inv.executionId !== expectedExecutionId) return false; + if (inv.activeRun) { + return ( + inv.activeRun.invocationId === run.invocationId && inv.activeRun.responseMessageId === run.responseMessageId + ); + } + inv.activeRun = structuredClone(run); + inv.startedAt = run.startedAt; + return true; + } + + /** + * Bind the provider-native dispatcher only after both durable ActiveRun + * admission and provider turn acceptance. The returned release hook is + * identity-fenced so an old carrier cannot erase a replacement adapter. + */ + bindAgentClientActiveRunDispatcher( + threadId: string, + catId: string, + dispatcher: AgentClientActiveRunDispatcher, + expectedExecutionId?: string, + ): (() => void) | null { + const inv = this.active.get(this.slotKey(threadId, catId)); + if (!inv || inv.state !== 'active') return null; + if (expectedExecutionId !== undefined && inv.executionId !== expectedExecutionId) return null; + if (!inv.activeRun || inv.activeRun.invocationId !== dispatcher.invocationId) return null; + if (inv.activeRunDispatcher) return null; + inv.activeRunDispatcher = dispatcher; + return () => { + const current = this.active.get(this.slotKey(threadId, catId)); + if (current?.activeRunDispatcher === dispatcher) delete current.activeRunDispatcher; + }; + } + + /** Exact live adapter snapshot; absence means Append/Steer is unsupported now. */ + getAgentClientActiveRunDispatcher(threadId: string, catId: string): AgentClientActiveRunDispatcher | undefined { + const inv = this.active.get(this.slotKey(threadId, catId)); + if ( + !inv || + inv.state !== 'active' || + !inv.activeRun || + inv.activeRun.invocationId !== inv.activeRunDispatcher?.invocationId + ) { + return undefined; + } + return inv.activeRunDispatcher; + } + + /** Mirror a durably admitted Append onto the non-durable Active Run projection. */ + appendLifecycleActiveRunInputs( + threadId: string, + catId: string, + expected: { invocationId: string; responseMessageId: string }, + entryId: string, + messageIds: readonly string[], + ): boolean { + return this.extendLifecycleActiveRunInputs(threadId, catId, expected, entryId, messageIds, true); + } + + /** + * Mirror an exact body that the current turn has already read. Unlike a + * provider Append, prompt adoption does not require a live dispatcher: the + * callback itself is the durable proof that this exact child received it. + */ + adoptLifecycleActiveRunInputs( + threadId: string, + catId: string, + expected: { invocationId: string; responseMessageId: string }, + entryId: string, + messageIds: readonly string[], + ): boolean { + return this.extendLifecycleActiveRunInputs(threadId, catId, expected, entryId, messageIds, false); + } + + private extendLifecycleActiveRunInputs( + threadId: string, + catId: string, + expected: { invocationId: string; responseMessageId: string }, + entryId: string, + messageIds: readonly string[], + requireDispatcher: boolean, + ): boolean { + const inv = this.active.get(this.slotKey(threadId, catId)); + const run = inv?.activeRun; + if ( + !inv || + inv.state !== 'active' || + !run || + run.invocationId !== expected.invocationId || + run.responseMessageId !== expected.responseMessageId || + (requireDispatcher && inv.activeRunDispatcher?.invocationId !== expected.invocationId) + ) { + return false; + } + const hasEntry = run.inputEntryIds.includes(entryId); + const presentMessageIds = messageIds.filter((messageId) => run.inputMessageIds.includes(messageId)); + if ((hasEntry && presentMessageIds.length !== messageIds.length) || (!hasEntry && presentMessageIds.length > 0)) { + return false; + } + if (hasEntry) return true; + inv.activeRun = { + ...run, + inputEntryIds: [...run.inputEntryIds, entryId], + inputMessageIds: [...run.inputMessageIds, ...messageIds], + }; + return true; + } + + /** Remove one rejected Append from the live projection after durable compensation. */ + detachLifecycleActiveRunInputs( + threadId: string, + catId: string, + expected: { invocationId: string; responseMessageId: string }, + entryId: string, + messageIds: readonly string[], + ): boolean { + const inv = this.active.get(this.slotKey(threadId, catId)); + const run = inv?.activeRun; + if ( + !inv || + !run || + run.invocationId !== expected.invocationId || + run.responseMessageId !== expected.responseMessageId + ) { + return false; + } + const rejectedIds = new Set(messageIds); + inv.activeRun = { + ...run, + inputEntryIds: run.inputEntryIds.filter((candidate) => candidate !== entryId), + inputMessageIds: run.inputMessageIds.filter((candidate) => !rejectedIds.has(candidate)), + }; + return true; + } + /** Get target cat IDs of the active invocation for a specific slot. */ getCatIds(threadId: string, catId: string): string[] { const key = this.slotKey(threadId, catId); @@ -528,6 +681,13 @@ export class InvocationTracker { return inv.state; } + /** Exact canceled-slot fence for read repair; slot state alone is too broad. */ + getCanceledSlotIdentity(threadId: string, catId: string): { executionId: string; userId: string } | undefined { + const inv = this.active.get(this.slotKey(threadId, catId)); + if (!inv || inv.state !== 'canceled' || !inv.executionId) return undefined; + return { executionId: inv.executionId, userId: inv.userId }; + } + /** * F-parallel-cancel: aggregate final status of a (possibly multi-cat) invocation, per the model * agreed with 砚砚: @@ -837,7 +997,11 @@ export class InvocationTracker { for (const [key, inv] of this.active) { // F-parallel-cancel: a canceled tombstone is not an active slot. if (key.startsWith(prefix) && inv.state !== 'canceled') { - result.push({ catId: inv.catId, startedAt: inv.startedAt }); + result.push({ + catId: inv.catId, + startedAt: inv.startedAt, + ...(inv.activeRun ? { activeRun: structuredClone(inv.activeRun) } : {}), + }); } } return result; diff --git a/packages/api/src/domains/cats/services/agents/invocation/MessageDeliveryService.ts b/packages/api/src/domains/cats/services/agents/invocation/MessageDeliveryService.ts index b8eef06efe..bf6c7eb1e0 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/MessageDeliveryService.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/MessageDeliveryService.ts @@ -1,63 +1,34 @@ -import type { RoutingPreflightDecisionV1 } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; export interface CallbackDeliveryDecisionInput { canEnqueueA2A: boolean; - willEnqueueToQueue: boolean; messageId: string; threadId: string; log: Pick; logContext?: Record; - enqueueA2A: () => Promise<{ - enqueued: readonly string[]; - coalesced?: readonly string[]; - routingPreflight?: RoutingPreflightDecisionV1; - }>; - markDelivered?: (deliveredAt: number) => Promise | unknown; - preserveQueuedOnEnqueueFailure?: boolean; - zeroEnqueuedWarnMessage: string; + enqueueA2A: () => Promise<{ enqueued: readonly string[]; coalesced?: readonly string[] }>; enqueueFailureMessage: string; } export interface CallbackDeliveryDecision { - shouldBroadcastNow: boolean; enqueued: readonly string[]; enqueueAttempted: boolean; enqueueFailed: boolean; - routingPreflight?: RoutingPreflightDecisionV1; -} - -async function recoverQueuedMessage(input: CallbackDeliveryDecisionInput, warnMessage: string): Promise { - try { - await input.markDelivered?.(Date.now()); - } catch (err) { - input.log.warn({ ...input.logContext, err, messageId: input.messageId, threadId: input.threadId }, warnMessage); - } -} - -function shouldPreserveQueuedMessage(input: CallbackDeliveryDecisionInput): boolean { - return Boolean(input.willEnqueueToQueue && input.preserveQueuedOnEnqueueFailure); } /** - * Centralizes callback delivery decisions shared by agent-key and invocation - * callbacks: queued messages must wait for QueueProcessor's messages_delivered - * event, while enqueue failures/zero-target outcomes fail open to live broadcast. + * Centralizes callback wake decisions. Agent speech is already public when this + * runs; Queue admission controls only recipient execution and never publication. */ export class MessageDeliveryService { static async resolveCallbackDeliveryDecision( input: CallbackDeliveryDecisionInput, ): Promise { if (!input.canEnqueueA2A) { - const preserveQueued = shouldPreserveQueuedMessage(input); - if (input.willEnqueueToQueue && !preserveQueued) { - await recoverQueuedMessage(input, input.zeroEnqueuedWarnMessage); - } return { - shouldBroadcastNow: !preserveQueued, enqueued: [], enqueueAttempted: false, - enqueueFailed: preserveQueued, + enqueueFailed: false, }; } @@ -66,43 +37,24 @@ export class MessageDeliveryService { // F216 AC-D6: coalesced targets are handled (content merged into existing entry). // Only warn/recover when truly nothing was handled (enqueued=0 AND coalesced=0). const anyHandled = a2aResult.enqueued.length > 0 || (a2aResult.coalesced?.length ?? 0) > 0; - if (input.willEnqueueToQueue && !anyHandled) { - if (input.preserveQueuedOnEnqueueFailure) { - return { - shouldBroadcastNow: false, - enqueued: a2aResult.enqueued, - enqueueAttempted: true, - enqueueFailed: true, - ...(a2aResult.routingPreflight ? { routingPreflight: a2aResult.routingPreflight } : {}), - }; - } - await recoverQueuedMessage(input, input.zeroEnqueuedWarnMessage); + if (!anyHandled) { return { - shouldBroadcastNow: true, enqueued: a2aResult.enqueued, enqueueAttempted: true, - enqueueFailed: false, - ...(a2aResult.routingPreflight ? { routingPreflight: a2aResult.routingPreflight } : {}), + enqueueFailed: true, }; } return { - shouldBroadcastNow: !input.willEnqueueToQueue, enqueued: a2aResult.enqueued, enqueueAttempted: true, enqueueFailed: false, - ...(a2aResult.routingPreflight ? { routingPreflight: a2aResult.routingPreflight } : {}), }; } catch (err) { input.log.error( { ...input.logContext, err, messageId: input.messageId, threadId: input.threadId }, input.enqueueFailureMessage, ); - const preserveQueued = shouldPreserveQueuedMessage(input); - if (input.willEnqueueToQueue && !preserveQueued) { - await recoverQueuedMessage(input, input.enqueueFailureMessage); - } return { - shouldBroadcastNow: !preserveQueued, enqueued: [], enqueueAttempted: true, enqueueFailed: true, diff --git a/packages/api/src/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.ts b/packages/api/src/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.ts index 2bcb718d93..3a0d2da9f8 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.ts @@ -6,18 +6,12 @@ * producer applies the same fail-closed terminal contract. */ -import type { QueueTerminalConsumptionWitness } from '@cat-cafe/shared'; -import { normalizeQueueTerminalConsumptions } from './queue-terminal-consumption.js'; - export interface TerminalDispositionEvent { type: string; catId?: string; error?: unknown; errorCode?: unknown; errorDisposition?: 'transient' | 'terminal'; - invocationId?: string; - turnCustodyTerminalWitness?: QueueTerminalConsumptionWitness; - turnCustodyTerminalWitnesses?: readonly QueueTerminalConsumptionWitness[]; } export interface PerCatTerminalDispositionCollectorOptions { @@ -38,13 +32,12 @@ export function isTerminalDispositionEvent(event: TerminalDispositionEvent): boo export class PerCatTerminalDispositionCollector { private readonly disqualifiedCatIds = new Set(); + private readonly failedCatIds = new Set(); + private readonly canceledCatIds = new Set(); private readonly successfulCatIds = new Set(); - private readonly preflightRejectedCatIds = new Set(); private readonly targetCatIds: Set; private readonly isCanceled: (catId: string) => boolean; private primaryTerminalError: string | undefined; - private readonly terminalInvocationIdByCatId = new Map(); - private readonly terminalConsumptionByInvocationId = new Map(); constructor(options: PerCatTerminalDispositionCollectorOptions) { this.targetCatIds = new Set(options.targetCatIds); @@ -52,14 +45,13 @@ export class PerCatTerminalDispositionCollector { } observe(event: TerminalDispositionEvent): void { - this.observeCustody(event); const { catId } = event; if (!catId || !this.targetCatIds.has(catId)) return; if (event.type === 'error') { if (event.errorDisposition === 'transient') return; - if (event.errorCode === 'routing_preflight_rejected') this.preflightRejectedCatIds.add(catId); this.primaryTerminalError ??= this.readTerminalError(event.error, event.errorCode, catId); + this.failedCatIds.add(catId); this.disqualify(catId); return; } @@ -69,6 +61,9 @@ export class PerCatTerminalDispositionCollector { if (event.errorCode !== undefined || this.isCanceled(catId)) { if (event.errorCode !== undefined) { this.primaryTerminalError ??= this.readTerminalError(undefined, event.errorCode, catId); + this.failedCatIds.add(catId); + } else { + this.canceledCatIds.add(catId); } this.disqualify(catId); return; @@ -79,33 +74,6 @@ export class PerCatTerminalDispositionCollector { } } - private observeCustody(event: TerminalDispositionEvent): void { - // Custody belongs to an exact child/source, including adopted wakes. Every - // route consumer must forward it independently of aggregate parent success. - if ( - event.catId && - typeof event.invocationId === 'string' && - event.invocationId.length > 0 && - isTerminalDispositionEvent(event) - ) { - this.terminalInvocationIdByCatId.set(event.catId, event.invocationId); - if (event.type === 'done') { - const witnesses = normalizeQueueTerminalConsumptions( - event.turnCustodyTerminalWitnesses ?? event.turnCustodyTerminalWitness, - ); - if (witnesses.length > 0) { - this.terminalConsumptionByInvocationId.set( - event.invocationId, - normalizeQueueTerminalConsumptions([ - ...(this.terminalConsumptionByInvocationId.get(event.invocationId) ?? []), - ...witnesses, - ]), - ); - } - } - } - } - getSuccessfulCatIds(): string[] { return [...this.successfulCatIds]; } @@ -114,16 +82,11 @@ export class PerCatTerminalDispositionCollector { return this.primaryTerminalError; } - getTerminalInvocationIdByCatId(): Record { - return Object.fromEntries(this.terminalInvocationIdByCatId); - } - - getTerminalConsumptionByInvocationId(): Record { - return Object.fromEntries(this.terminalConsumptionByInvocationId); - } - - getPreflightRejectedCatIds(): string[] { - return [...this.preflightRejectedCatIds]; + getTerminalStatus(catId: string): 'succeeded' | 'failed' | 'canceled' | undefined { + if (this.successfulCatIds.has(catId)) return 'succeeded'; + if (this.failedCatIds.has(catId)) return 'failed'; + if (this.canceledCatIds.has(catId)) return 'canceled'; + return undefined; } private readTerminalError(error: unknown, errorCode: unknown, catId: string): string { diff --git a/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueCarrier.ts b/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueCarrier.ts index 02831f7cc7..58433807eb 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueCarrier.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueCarrier.ts @@ -1,13 +1,5 @@ -import { isDeepStrictEqual } from 'node:util'; -import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; +import type { IMessageStore } from '../../stores/ports/MessageStore.js'; import type { InvocationQueue, QueueEntry } from './InvocationQueue.js'; -import { queueCarrierMessageIds, readQueueCarrierMessages } from './QueueCarrierSourceProjection.js'; -import { - activeCarrierEntryIds, - carrierEntryId, - isQueuedCarrierTarget, -} from './QueuedMessageCustodyCarrierProjection.js'; -import { buildQueueEntry } from './QueuedMessageCustodyStartupQueueEntry.js'; export type OwnedQueueProgress = | 'started' @@ -30,133 +22,43 @@ export type PersistedCarrierResult = | { state: 'unavailable' | 'conflict'; reason: string }; export interface PersistedCarrierDeps { - messages: Pick; - queue: Pick; + messages: Pick; + queue: Pick; progress: (entry: QueueEntry, targetCatId: string) => Promise; } -/** The store's exhaustive raw timeline is the membership source; recent UI history is insufficient. */ -async function readCompleteCarrier( - deps: PersistedCarrierDeps, - input: PersistedCarrierCoordinates, - entryId: string, -): Promise { - const timeline = await deps.messages.getByThreadAfter(input.threadId, undefined, undefined, input.ownerUserId, { - includeQueuedCatMessages: true, - includeQueuedUserMessages: true, - }); - const members = timeline - .filter((message) => activeCarrierEntryIds(message).includes(entryId)) - .map((message) => structuredClone(message)); - if (members.some((message) => message.queueCustody?.ownerUserId !== input.ownerUserId)) { - throw new Error('Queue carrier member owner does not match'); - } - const live = deps.queue.getEntrySnapshot(input.threadId, input.ownerUserId, entryId); - const required = new Set([input.sourceMessageId, ...(live ? queueCarrierMessageIds(live) : [])]); - if ([...required].some((id) => !members.some((message) => message.id === id))) { - throw new Error('Complete Queue carrier membership is unavailable'); - } - const current = await readQueueCarrierMessages(buildQueueEntry(members, entryId), deps.messages); - if (current.some((message, index) => !isDeepStrictEqual(message.queueCustody, members[index]?.queueCustody))) { - throw new Error('Queue carrier changed while reading complete membership'); - } - return current; -} - -/** Compare every durable scheduling/exposure field, normalizing only absent empty projections. */ -function projection(entry: QueueEntry) { - return { - id: entry.id, - threadId: entry.threadId, - userId: entry.userId, - ownerAuthProvenance: entry.ownerAuthProvenance, - executionScope: entry.executionScope, - messageId: entry.messageId, - mergedMessageIds: entry.mergedMessageIds, - content: entry.content, - source: entry.source, - sourceCategory: entry.sourceCategory, - callerCatId: entry.callerCatId, - a2aParentInvocationId: entry.a2aParentInvocationId, - a2aTriggerMessageId: entry.a2aTriggerMessageId, - targetCats: entry.targetCats, - allTargetCats: entry.allTargetCats ?? entry.targetCats, - authorIntentByCatId: entry.authorIntentByCatId ?? {}, - intent: entry.intent, - createdAt: entry.createdAt, - priority: entry.priority, - position: entry.position, - autoExecute: entry.autoExecute, - notified: entry.queuedNotifiedByCatIds ?? [], - awakened: entry.queuedAwakenedInvocationIdByCatId ?? {}, - awakenedAt: entry.queuedAwakenedAtByCatId ?? {}, - seen: entry.queuedSeenByCatIds ?? [], - seenInvocations: entry.queuedSeenInvocationIdByCatId ?? {}, - exposures: entry.queuedBodyExposures ?? [], - failed: entry.queuedFailedByCatIds ?? [], - attempts: entry.queuedAttemptIdByCatId ?? {}, - handled: entry.queuedHandledByCatIds ?? [], - steer: entry.steerRequestedByCatIds ?? [], - steered: entry.steeredInvocationIdByCatId ?? {}, - prestartRetirement: entry.prestartRetirement, - waitContinuationCarrier: entry.waitContinuationCarrier, - actionSuccessorFence: entry.actionSuccessorFence, - }; -} - -/** Confirm durable custody and request normal Dispatch progress without transferring or replaying its owner. */ +/** Verify the canonical Queue row for one persisted source before requesting normal Dispatch progress. */ export async function ensurePersistedCarrierOwnedAndScheduled( deps: PersistedCarrierDeps, input: PersistedCarrierCoordinates, ): Promise { try { const source = await deps.messages.getById(input.sourceMessageId); - const custody = source?.queueCustody; - if (!source || !custody) return { state: 'unavailable', reason: 'Queue custody is unavailable' }; - const entryId = carrierEntryId(custody, input.targetCatId); if ( - !entryId || + !source || source.threadId !== input.threadId || source.userId !== input.ownerUserId || - custody.ownerUserId !== input.ownerUserId || - !custody.allTargetCats.some((cat) => cat === input.targetCatId) || - (input.expectedEntryId && input.expectedEntryId !== entryId) + source.deliveryStatus === 'canceled' ) { - return { state: 'conflict', reason: 'Queue custody coordinates do not match' }; + return { state: 'unavailable', reason: 'Persisted source is unavailable' }; } + const entryId = input.expectedEntryId; + if (!entryId) return { state: 'conflict', reason: 'Queue entry identity is required' }; + const entry = await deps.queue.getDurableEntry(input.threadId, entryId); if ( - custody.status === 'terminal' || - custody.handledByCatIds.some((cat) => cat === input.targetCatId) || - custody.withdrawnByCatIds?.some((cat) => cat === input.targetCatId) || - custody.failedByCatIds.some((cat) => cat === input.targetCatId) + !entry || + entry.payload.messageId !== source.id || + entry.owner.kind !== 'user' || + entry.owner.userId !== input.ownerUserId || + !entry.targets.includes(input.targetCatId) ) { - return { state: 'terminal_owned', entryId }; + return { state: 'conflict', reason: 'Queue ledger coordinates do not match the persisted source' }; } - if (!custody.pendingTargetCats.some((cat) => cat === input.targetCatId)) { - return { state: 'conflict', reason: 'Queue target has no current owner' }; - } - // An accepted child or processing carrier belongs to Dispatch recovery, never a fresh admission. - if ( - !isQueuedCarrierTarget(custody, input.targetCatId) || - custody.awakenedInvocationIdByCatId?.[input.targetCatId] - ) { + if (entry.status === 'claimed' || entry.status === 'processing') { return { state: 'already_processing', entryId }; } - const members = await readCompleteCarrier(deps, input, entryId); - const expected = buildQueueEntry(members, entryId); - const live = deps.queue.getEntrySnapshot(input.threadId, input.ownerUserId, entryId); - if (live && !isDeepStrictEqual(projection(live), projection(expected))) { - return { state: 'conflict', reason: 'Live Queue projection disagrees with complete durable custody' }; - } - if (live?.status === 'processing') return { state: 'already_processing', entryId }; - if (expected.status !== 'queued') return { state: 'already_processing', entryId }; - // restoreDurableEntry rejects a same-id row in another scope. No substitute entry is minted. - try { - deps.queue.restoreDurableEntry(expected); - } catch (error) { - return { state: 'conflict', reason: error instanceof Error ? error.message : String(error) }; - } - return { state: await deps.progress(expected, input.targetCatId), entryId }; + if (entry.status === 'terminal') return { state: 'terminal_owned', entryId }; + return { state: await deps.progress(entry, input.targetCatId), entryId }; } catch (error) { return { state: 'unavailable', reason: error instanceof Error ? error.message : String(error) }; } diff --git a/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts b/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts index 9fb2e54a7f..7005322306 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts @@ -1,14 +1,9 @@ import { isDeepStrictEqual } from 'node:util'; import { createCatId } from '@cat-cafe/shared'; import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; -import type { InvocationQueue } from './InvocationQueue.js'; -import { - ensurePersistedCarrierOwnedAndScheduled, - type PersistedCarrierDeps, - type PersistedCarrierResult, -} from './PersistedQueueCarrier.js'; -import { carrierEntryId } from './QueuedMessageCustodyCarrierProjection.js'; -import { createInitialQueuedMessageCustody } from './QueuedMessageCustodyCoordinator.js'; +import type { InvocationQueue, QueueEntry } from './InvocationQueue.js'; +import type { OwnedQueueProgress, PersistedCarrierResult } from './PersistedQueueCarrier.js'; +import { queueEntryId } from './queue-ledger/QueueLedger.js'; export interface PersistedQueueDeliveryInput { ownerUserId: string; @@ -23,99 +18,113 @@ export interface PersistedQueueDeliveryPort { deliver(input: PersistedQueueDeliveryInput): Promise; } -/** Dispatch owns the admission/resumption boundary; producers supply only an authorized immutable envelope. */ +type PersistedQueueDeliveryResult = PersistedCarrierResult & { message?: StoredMessage }; + +/** Dispatch owns atomic Message + Queue admission; producers supply only an authorized immutable envelope. */ export class PersistedQueueDelivery implements PersistedQueueDeliveryPort { constructor( private readonly deps: { - messages: Pick; - queue: Pick< - InvocationQueue, - | 'enqueue' - | 'backfillMessageId' - | 'rollbackEnqueue' - | 'getEntrySnapshot' - | 'restoreDurableEntry' - | 'commitQueueCustodyAdmission' - >; - progress: PersistedCarrierDeps['progress']; + messages: IMessageStore; + queue: Pick; + progress: (entry: QueueEntry, targetCatId: string) => Promise; }, ) {} async deliver(input: PersistedQueueDeliveryInput) { - let message = await this.deps.messages.getByIdempotencyKey(input.ownerUserId, input.threadId, input.idempotencyKey); - if (!message) message = await this.admit(input); - if ( - message.userId !== input.ownerUserId || - message.threadId !== input.threadId || - message.catId !== null || - message.content !== input.content || - message.source?.connector !== input.source.connector || - !isDeepStrictEqual(message.source.meta, input.source.meta) || - !message.mentions.some((cat) => cat === input.targetCatId) - ) { - return { state: 'conflict' as const, reason: 'Persisted producer envelope does not match', message }; + const targetCat = createCatId(input.targetCatId); + const from = { + kind: 'external' as const, + connectorId: input.source.connector, + ...(input.source.label ? { sender: { id: input.source.label, name: input.source.label } } : {}), + }; + const existing = await this.deps.messages.getByIdempotencyKey( + input.ownerUserId, + input.threadId, + input.idempotencyKey, + ); + if (existing) { + return this.progressExistingMessage(existing, input); } - const result = await ensurePersistedCarrierOwnedAndScheduled( + const admitted = await this.deps.queue.appendAndEnqueueDurable( + this.deps.messages, { - ...this.deps, - progress: async (entry, targetCatId) => { - // The complete durable group has been verified. Release only this admission's existing queue fence. - if ( - !this.deps.queue.commitQueueCustodyAdmission( - entry.threadId, - entry.userId, - `producer:${input.idempotencyKey}`, - [entry.id], - ) - ) - throw new Error('Producer queue admission fence changed'); - return this.deps.progress(entry, targetCatId); - }, - }, - { ...input, sourceMessageId: message.id }, - ); - return { ...result, message }; - } - - private async admit(input: PersistedQueueDeliveryInput): Promise { - const targetCat = createCatId(input.targetCatId); - const queued = this.deps.queue.enqueue({ - threadId: input.threadId, - userId: input.ownerUserId, - ownerAuthProvenance: 'strict', - idempotencyKey: input.idempotencyKey, - queueCustodyAdmissionId: `producer:${input.idempotencyKey}`, - content: input.content, - source: 'connector', - targetCats: [targetCat], - intent: 'execute', - }); - if (queued.outcome === 'full' || !queued.entry) throw new Error('Producer return queue is full'); - try { - const message = await this.deps.messages.append({ userId: input.ownerUserId, - catId: null, + threadId: input.threadId, + from, content: input.content, mentions: [targetCat], - timestamp: queued.entry.createdAt, - threadId: input.threadId, - idempotencyKey: input.idempotencyKey, + timestamp: Date.now(), deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(queued.entry), source: input.source, extra: { targetCats: [targetCat] }, - }); - const durableEntryId = message.queueCustody && carrierEntryId(message.queueCustody, input.targetCatId); - if (durableEntryId !== queued.entry.id) { - // append's atomic idempotency check may find an older durable owner after our initial lookup. - if (!queued.deduped) this.deps.queue.rollbackEnqueue(input.threadId, input.ownerUserId, queued.entry.id); - return message; + idempotencyKey: input.idempotencyKey, + }, + { + threadId: input.threadId, + userId: input.ownerUserId, + sourceId: input.idempotencyKey, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + idempotencyKey: input.idempotencyKey, + content: input.content, + from, + targetCats: [targetCat], + intent: 'execute', + }, + ); + if (admitted.outcome === 'full') throw new Error('Producer return queue is full'); + const message = admitted.message; + if (!matchesPersistedEnvelope(message, input)) { + return { state: 'conflict' as const, reason: 'Persisted producer envelope does not match', message }; + } + const entry = admitted.entry; + if (!entry) return { state: 'unavailable' as const, reason: 'Queue admission is unavailable', message }; + if (entry.status === 'claimed' || entry.status === 'processing') { + return { state: 'already_processing' as const, entryId: entry.id, message }; + } + return { state: await this.deps.progress(entry, input.targetCatId), entryId: entry.id, message }; + } + + private async progressExistingMessage( + existing: StoredMessage, + input: PersistedQueueDeliveryInput, + ): Promise { + if (!matchesPersistedEnvelope(existing, input)) { + return { state: 'conflict', reason: 'Persisted producer envelope does not match', message: existing }; + } + const entryId = queueEntryId(existing.id); + const entry = await this.deps.queue.getDurableEntry(input.threadId, entryId); + if (entry) { + if (entry.status === 'claimed' || entry.status === 'processing') { + return { state: 'already_processing', entryId, message: existing }; } - this.deps.queue.backfillMessageId(input.threadId, input.ownerUserId, queued.entry.id, message.id); - return message; - } catch (error) { - if (!queued.deduped) this.deps.queue.rollbackEnqueue(input.threadId, input.ownerUserId, queued.entry.id); - throw error; + if (entry.status === 'terminal') return { state: 'terminal_owned', entryId, message: existing }; + return { state: await this.deps.progress(entry, input.targetCatId), entryId, message: existing }; + } + if ( + this.deps.queue.findAdmittedEntriesForMessages( + input.threadId, + [existing.id], + input.ownerUserId, + input.targetCatId, + ).length > 0 + ) { + return { state: 'already_processing', entryId, message: existing }; + } + if (existing.deliveryStatus === 'delivered') { + return { state: 'terminal_owned', entryId, message: existing }; } + return { state: 'conflict', reason: 'Persisted producer Queue carrier is missing', message: existing }; } } + +function matchesPersistedEnvelope(message: StoredMessage, input: PersistedQueueDeliveryInput): boolean { + return ( + message.userId === input.ownerUserId && + message.threadId === input.threadId && + message.content === input.content && + message.source?.connector === input.source.connector && + isDeepStrictEqual(message.source.meta, input.source.meta) && + message.mentions.some((cat) => cat === input.targetCatId) + ); +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueueCarrierSourceProjection.ts b/packages/api/src/domains/cats/services/agents/invocation/QueueCarrierSourceProjection.ts deleted file mode 100644 index 096b8b230b..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueCarrierSourceProjection.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; -import { isOwnerVisibleManagedHoldConnector } from '../../stores/visibility.js'; -import type { QueueEntry } from './InvocationQueue.js'; -import { carrierEntryId, isQueuedCarrierTarget } from './QueuedMessageCustodyCarrierProjection.js'; - -/** The physical carrier is a projection of each source × target, not custody.entryId. */ -export function queueSourceTargetState( - message: StoredMessage, - entryId: string, - catId: string, -): 'unmanaged' | 'pending' | 'failed' | 'retired' | 'unknown' { - const custody = message.queueCustody; - if (!custody) return 'unmanaged'; - if ( - carrierEntryId(custody, catId) !== entryId || - custody.handledByCatIds.some((id) => id === catId) || - custody.withdrawnByCatIds?.some((id) => id === catId) - ) - return 'retired'; - if (custody.failedByCatIds.some((id) => id === catId)) return 'failed'; - if (custody.pendingTargetCats.some((id) => id === catId)) return 'pending'; - return 'unknown'; -} - -export function queueCarrierMessageIds(entry: Pick): string[] { - return [ - ...new Set( - [entry.messageId, ...(entry.mergedMessageIds ?? [])].filter( - (id): id is string => typeof id === 'string' && id.length > 0, - ), - ), - ]; -} - -export async function readQueueCarrierMessages( - entry: Pick, - store: Pick, -): Promise { - return Promise.all( - queueCarrierMessageIds(entry).map(async (id) => { - const message = await store.getById(id); - if ( - !message || - message.threadId !== entry.threadId || - (message.userId !== entry.userId && !isOwnerVisibleManagedHoldConnector(message, entry.userId)) - ) { - throw new Error(`Queue source unavailable or out of scope: ${entry.id}/${id}`); - } - return message; - }), - ); -} - -/** Remove only proven retired members; missing/unrecognized truth must stay visible. */ -export function projectUnconsumedQueueCarrier( - entry: QueueEntry, - messages: readonly StoredMessage[], -): QueueEntry | null { - if (messages.length === 0 || !Array.isArray(entry.targetCats)) return entry; - const active = messages.filter((message) => - entry.targetCats.some((catId) => queueSourceTargetState(message, entry.id, catId) !== 'retired'), - ); - const targets = entry.targetCats.filter((catId) => - active.some((message) => queueSourceTargetState(message, entry.id, catId) !== 'retired'), - ); - const primary = active[0]; - if (!primary || targets.length === 0) return null; - if (active.length === messages.length && targets.length === entry.targetCats.length) return entry; - const targetSet = new Set(targets); - const filterTargets = (ids: readonly string[] | undefined) => ids?.filter((id) => targetSet.has(id)); - const filterMap = (values: Readonly> | undefined) => - values && Object.fromEntries(Object.entries(values).filter(([catId]) => targetSet.has(catId))); - return { - ...entry, - messageId: primary.id, - mergedMessageIds: active.slice(1).map((message) => message.id), - ...(active.length !== messages.length ? { content: active.map((message) => message.content).join('\n') } : {}), - ...(entry.a2aTriggerMessageId && !active.some((message) => message.id === entry.a2aTriggerMessageId) - ? { a2aTriggerMessageId: primary.id } - : {}), - targetCats: targets, - allTargetCats: filterTargets(entry.allTargetCats), - queuedNotifiedByCatIds: filterTargets(entry.queuedNotifiedByCatIds), - queuedAwakenedInvocationIdByCatId: filterMap(entry.queuedAwakenedInvocationIdByCatId), - queuedAwakenedAtByCatId: filterMap(entry.queuedAwakenedAtByCatId), - queuedSeenByCatIds: filterTargets(entry.queuedSeenByCatIds), - queuedSeenInvocationIdByCatId: filterMap(entry.queuedSeenInvocationIdByCatId), - queuedBodyExposures: entry.queuedBodyExposures?.filter((exposure) => targetSet.has(exposure.targetCatId)), - queuedFailedByCatIds: filterTargets(entry.queuedFailedByCatIds), - queuedFailureAtByCatId: filterMap(entry.queuedFailureAtByCatId), - queuedFailureReasonByCatId: filterMap(entry.queuedFailureReasonByCatId), - queuedAttemptIdByCatId: filterMap(entry.queuedAttemptIdByCatId), - queuedHandledByCatIds: filterTargets(entry.queuedHandledByCatIds), - steerRequestedByCatIds: filterTargets(entry.steerRequestedByCatIds), - steeredInvocationIdByCatId: filterMap(entry.steeredInvocationIdByCatId), - }; -} - -export function canSteerQueueSources(entry: QueueEntry, messages: readonly StoredMessage[], catId: string): boolean { - return messages.every((message) => { - const state = queueSourceTargetState(message, entry.id, catId); - return ( - state === 'unmanaged' || - (state === 'pending' && !!message.queueCustody && isQueuedCarrierTarget(message.queueCustody, catId)) - ); - }); -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts index f08da4e7ca..6be1787cbe 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts @@ -10,10 +10,11 @@ import type { CatId, FreshnessSupplementFailureReason, + LifecycleActiveRun, MessageContent, + MessageFrom, OutputCommitDecision, QueueTargetOutcome, - QueueTerminalConsumptionWitness, RichBlock, WaitContinuationCarrierV1, } from '@cat-cafe/shared'; @@ -23,20 +24,15 @@ import { unresolvedSubjectWithoutActiveCustodyTotal, } from '../../../../../infrastructure/telemetry/instruments.js'; import { - emitQueueUpdated, - enrichQueueEntries, - failedQueueRecoveryActionId, -} from '../../../../../utils/queue-enrichment.js'; -import type { A2ADispatchDispositionService } from '../../../../ball-custody/A2ADispatchDispositionService.js'; + commitCompletedResponseAndEnqueueA2ATargets, + commitFailedResponseAndEnqueueA2ACaller, +} from '../../../../../routes/callback-a2a-trigger.js'; +import { emitQueueUpdated, isPublicQueueEntry } from '../../../../../utils/queue-enrichment.js'; import type { ActionSuccessorLeaseStore } from '../../../../ball-custody/ActionSuccessorLeaseStore.js'; -import { retireConsumedCoordinationTerminal } from '../../../../ball-custody/CoordinationTerminalRetirement.js'; -import type { TurnCustodyWakeProvenance } from '../../../../ball-custody/TurnCustodyProjectionService.js'; import { resolveQueueTurnCustodyWake, retargetTurnCustodyWake, } from '../../../../ball-custody/turn-custody-wake-provenance.js'; -import type { RetryAuthorityCommit } from '../../../../ball-custody/WaitContinuationRetryCommitter.js'; -import type { RetryAuthorityFailureReason } from '../../../../ball-custody/WaitContinuationRetryPreflight.js'; import { waitContinuationCarriersMatch } from '../../../../ball-custody/wait-continuation-carrier.js'; import type { MemoryCueOpportunitySeed } from '../../../../memory/cue/MemoryCueInvocationPromptService.js'; import { readTrustedConnectorMemoryCueSeeds } from '../../../../memory/cue/MemoryCueTrustedConnector.js'; @@ -65,6 +61,9 @@ import { projectFreshnessSupplement, SUPPLEMENT_DECLINE_MARKER, } from '../../freshness/glass-box/FreshnessOutputCommitCoordinator.js'; +import { shouldMarkDecisionNotification } from '../../push/decision-notification-policy.js'; +import type { PushPayload } from '../../push/PushNotificationService.js'; +import { messageFrom } from '../../stores/message-from.js'; import type { DeliveryCursorStore } from '../../stores/ports/DeliveryCursorStore.js'; import type { InvocationActionLeaseCarrier, @@ -72,16 +71,28 @@ import type { InvocationStatus, } from '../../stores/ports/InvocationRecordStore.js'; import { classifyInvocationRecoveryStatus } from '../../stores/ports/invocation-state-machine.js'; -import { hydrateReplyPreview, type IMessageStore, type StoredMessage } from '../../stores/ports/MessageStore.js'; -import { projectQueueReceipt } from '../../stores/ports/queued-message-receipt.js'; +import { + hydrateReplyPreview, + type IMessageStore, + isTimelinePublished, + lifecycleInputIdentityForStoredMessage, + type StoredMessage, + settleLifecycleResponseInputs, +} from '../../stores/ports/MessageStore.js'; import type { IThreadStore } from '../../stores/ports/ThreadStore.js'; import type { ITurnExecutionStore } from '../../stores/ports/TurnExecutionStore.js'; -import { type AgentMessage, mergeTokenUsage, type TokenUsage } from '../../types.js'; +import { + type AgentClientActiveRunDispatcher, + type AgentMessage, + mergeTokenUsage, + type TokenUsage, +} from '../../types.js'; +import { extractImagePaths } from '../providers/image-paths.js'; import { userFacingSystemInfoNoticeContent } from '../routing/persist-system-info-warnings.js'; import { - createA2ASlotTrackingBridge, type PersistedPromptMessage, type PersistenceContext, + type RouteExecutionOptions, type RouteOptions, } from '../routing/route-helpers.js'; import { @@ -91,7 +102,10 @@ import { flattenTurnTextParts, } from '../text-aggregation.js'; import { - buildDispatchHandledContinuationCapsule, + type CallerDispatchObservationProjection, + CallerDispatchObservationRegistry, +} from './CallerDispatchObservationRegistry.js'; +import { type CollaborationContinuityCapsuleV1, extractContinuityCapsuleFromAgentMessage, formatContinuationPrompt, @@ -101,36 +115,33 @@ import { type EnsureTerminalDeps, ensureTerminalStatus, RouteChainCompletionTrac import type { StaleProcessingOwnerLease } from './InvocationOwnerLeaseCandidates.js'; import { actionSuccessorInvocationIdempotencyKey, - exactA2ASourceMessageIds, type InvocationQueue, isOrdinaryQueueTargetEligible, - type QueuedHandledResult, type QueueEntry, + queueEntryCallerCatId, + queueEntryMessageIds, + queueEntryOwnerId, + queueEntrySenderMeta, + queueEntrySource, + queueEntryTargetCats, } from './InvocationQueue.js'; import { - DEFAULT_INVOCATION_SLOT_TTL_MS, + DEFAULT_PRESTART_RESERVATION_TTL_MS, type ExactExecutionOwnerState, type ExecutionAdmissionGuard, type ExecutionOwnerMatch, } from './InvocationTracker.js'; +import { projectLifecycleAppendAction } from './lifecycle-append-projection.js'; import { requireOwnerAuthProvenance } from './owner-auth-provenance.js'; import { isTerminalDispositionEvent, PerCatTerminalDispositionCollector, } from './PerCatTerminalDispositionCollector.js'; import type { OwnedQueueProgress } from './PersistedQueueCarrier.js'; -import { projectUnconsumedQueueCarrier, readQueueCarrierMessages } from './QueueCarrierSourceProjection.js'; -import { carrierEntryId } from './QueuedMessageCustodyCarrierProjection.js'; -import { - createInitialQueuedMessageCustody, - type QueuedMessageCustodyCoordinator, -} from './QueuedMessageCustodyCoordinator.js'; -import { type QueueEntryDurableTerminalOwner, resolveQueueEntrySettlement } from './queue-entry-settlement.js'; import { type ContinuationOutcome, classifyContinuationOutcome, describeContinuationOutcome, - parkRequeuedEntryVisibly, } from './queue-liveness-diagnostics.js'; import { commitPreparedPrestartRetirements, @@ -139,14 +150,6 @@ import { preparePrestartRetirements, terminalizePreparedPrestartRetirements, } from './queue-prestart-group-retirement.js'; -import { - resolveQueueSourceResponseEvidence, - resolveQueueSourceResponseEvidenceFromMessages, -} from './queue-source-response-evidence.js'; -import { - normalizeQueueTerminalConsumptions, - type QueueTerminalConsumptionCollection, -} from './queue-terminal-consumption.js'; import { requireInvocationRecordUpdate } from './require-invocation-record-update.js'; import { type CommitInvocationInput, @@ -160,12 +163,6 @@ import { import { ToolExecutionPolicyUnavailableError } from './tool-execution-policy.js'; import { stampVisibleTurn } from './visible-turn.js'; -function exactSteerReservationTarget(entry: QueueEntry, reservationId: string): string | undefined { - const reservation = entry.exactSteerBatch; - if (!reservation || reservation.reservationId !== reservationId) return undefined; - return isOrdinaryQueueTargetEligible(entry, reservation.targetCatId) ? reservation.targetCatId : undefined; -} - /** Minimal interfaces for deps — avoid importing full types for testability */ interface TrackerLike { @@ -186,7 +183,9 @@ interface TrackerLike { executionId?: string, ): boolean; has(threadId: string, catId?: string): boolean; + cancelInvocation(threadId: string, catIds: string[], userId?: string, reason?: string): unknown; getUserId?(threadId: string, catId: string): string | null; + getExecutionId?(threadId: string, catId: string): string | undefined; /** F-parallel-cancel: expose a slot's own controller for per-cat cancel isolation. */ getController?(threadId: string, catId: string): AbortController | undefined; classifyExecutionId?(threadId: string, catId: string, executionId: string): ExecutionOwnerMatch; @@ -200,22 +199,45 @@ interface TrackerLike { ): 'succeeded' | 'canceled' | 'canceled_by_user'; completeByExecutionId(threadId: string, catId: string, executionId: string): ExactExecutionOwnerState; releaseTerminalByExecutionId(threadId: string, catId: string, executionId: string): ExactExecutionOwnerState; + bindLifecycleActiveRun?(run: LifecycleActiveRun, expectedExecutionId?: string): boolean; + bindAgentClientActiveRunDispatcher?( + threadId: string, + catId: string, + dispatcher: AgentClientActiveRunDispatcher, + expectedExecutionId?: string, + ): (() => void) | null; + getAgentClientActiveRunDispatcher?(threadId: string, catId: string): AgentClientActiveRunDispatcher | undefined; + getActiveSlots?(threadId: string): Array<{ catId: string; startedAt: number; activeRun?: LifecycleActiveRun }>; + appendLifecycleActiveRunInputs?( + threadId: string, + catId: string, + expected: { invocationId: string; responseMessageId: string }, + entryId: string, + messageIds: readonly string[], + ): boolean; + adoptLifecycleActiveRunInputs?( + threadId: string, + catId: string, + expected: { invocationId: string; responseMessageId: string }, + entryId: string, + messageIds: readonly string[], + ): boolean; + detachLifecycleActiveRunInputs?( + threadId: string, + catId: string, + expected: { invocationId: string; responseMessageId: string }, + entryId: string, + messageIds: readonly string[], + ): boolean; } interface QueueExecutionResult { status: InvocationFinalStatus; invocationId?: string; - successfulCatIds: string[]; /** Queue rows actually reserved into this attempt, including F175 batch siblings. */ attemptedQueueEntryIds: string[]; - /** Exact child that received each target's persisted Queue body. */ - terminalInvocationIdByCatId: Record; - /** Typed terminal clean-stop proof, keyed by the exact child that emitted it. */ - terminalConsumptionByInvocationId: Record; - /** The primary row was rolled back to queued after this failed attempt. */ - primaryEntryRequeued?: boolean; - /** No provider started; the independent busy owner will trigger the next drain. */ - deferredForBusyTarget?: boolean; + /** Exact primary settlement failed, so automatic draining must stop for recovery. */ + primarySettlementIncomplete?: boolean; } type ProcessingSlotReservation = PrestartRetirementReservation; @@ -232,23 +254,13 @@ interface MarkDeliveredAndEmitResult { failedIds: string[]; } -interface PausedQueueNotification { - userId: string; - queue: Awaited>; -} - -interface BoundQueueExecution { - invocationId: string; - userIds: ReadonlySet; -} - -interface ResolvedBoundQueueExecutions { - records: Array<{ - invocationId: string; - status: 'running' | 'succeeded' | 'failed' | 'canceled' | 'interrupted'; - }>; - unresolvedInvocationIds: string[]; -} +export type AdoptExposedQueuedEntriesResult = + | { outcome: 'adopted'; adoptedEntryIds: string[] } + | { + outcome: 'rejected'; + reason: 'active_run_missing' | 'state_changed' | 'lifecycle_conflict' | 'persistence_unavailable'; + entryId?: string; + }; interface PromptMessagesExposedInput { threadId: string; @@ -259,16 +271,6 @@ interface PromptMessagesExposedInput { seenAt: number; } -interface PromptExposureAdoptionReservation { - commit(): void; - abort(): void; -} - -interface PromptMessagesSeenOptions { - /** Resolve every fallible adoption dependency before append-only exposure is persisted. */ - prepareAdoption?: (wakes: readonly TurnCustodyWakeProvenance[]) => Promise; -} - interface PromptMessagesAwakenedInput { threadId: string; userId: string; @@ -286,32 +288,9 @@ export interface InvocationRecordStoreLike { update(id: string, data: Record): Promise; } -function queueTerminalConsumptionForMessage( - consumptions: readonly QueueTerminalConsumptionWitness[], - messageId: string, -): QueueTerminalConsumptionWitness | undefined { - return ( - consumptions.find( - (candidate) => - (candidate.kind === 'managed_hold_continued' || candidate.kind === 'dispatch_handled_continuation') && - candidate.sourceMessageId === messageId, - ) ?? consumptions.find((candidate) => candidate.kind === 'terminal_silent' || candidate.kind === 'source_response') - ); -} - -function ownsPersistedInvocationLineage(message: StoredMessage, invocationId: string): boolean { - const stream = message.extra?.stream; - return ( - stream?.invocationId === invocationId || - stream?.turnInvocationId === invocationId || - message.extra?.turnExecution?.invocationId === invocationId || - message.extra?.auxiliaryTurnExecutions?.some((execution) => execution.invocationId === invocationId) === true - ); -} - function readOrdinaryInvocationCreated( message: unknown, -): { catId: string; invocationId: string; startedAt: number } | null { +): { catId: string; invocationId: string; startedAt: number; activeRun?: LifecycleActiveRun } | null { if (!message || typeof message !== 'object') return null; const candidate = message as Partial; if (candidate.type !== 'system_info' || typeof candidate.catId !== 'string' || !candidate.catId) return null; @@ -333,6 +312,12 @@ function readOrdinaryInvocationCreated( catId: candidate.catId, invocationId: candidate.turnInvocationId, startedAt: candidate.turnExecutionStartedAt, + ...(candidate.activeRun && + candidate.activeRun.threadId && + candidate.activeRun.targetId === candidate.catId && + candidate.activeRun.invocationId === candidate.turnInvocationId + ? { activeRun: candidate.activeRun } + : {}), }; } @@ -369,6 +354,8 @@ function isExactReplayableQueueRecord( } export interface RouterLike { + resolveExplicitTargets(requestedCatIds: readonly string[], threadId: string): Promise; + resolveConversationTargetsAtAdmission(requestedCatIds: readonly string[], threadId: string): Promise; routeExecution( userId: string, content: string, @@ -376,7 +363,7 @@ export interface RouterLike { messageId: string | null, targetCats: string[], intent: { intent: string }, - opts?: Record, + opts: RouteExecutionOptions, ): AsyncIterable<{ type: string; catId?: string; [key: string]: unknown }>; ackCollectedCursors(userId: string, threadId: string, cursors: Map): Promise; } @@ -393,6 +380,10 @@ interface LoggerLike { error(obj: unknown, msg?: string): void; } +interface PushNotificationServiceLike { + notifyUser(userId: string, payload: PushPayload): Promise; +} + /** #813: Minimal thread store interface for passive continuation. */ export interface ThreadStoreLike { get?(threadId: string): ReturnType; @@ -492,8 +483,9 @@ export interface QueueProcessorDeps { socketManager: SocketManagerLike; messageStore: IMessageStore; /** F254: durable owner for ordinary queued-user lifecycle transitions. */ - queueCustodyCoordinator?: QueuedMessageCustodyCoordinator; log: LoggerLike; + /** User-facing completion/error notifications for canonical queued web ingress. */ + getPushService?: () => PushNotificationServiceLike | null; /** F088 fix: optional outbound delivery hook (late-bound after gateway bootstrap). */ outboundHook?: OutboundDeliveryHookLike; /** F088 fix: optional streaming outbound hook (late-bound after gateway bootstrap). */ @@ -506,7 +498,7 @@ export interface QueueProcessorDeps { threadStore?: ThreadStoreLike; /** F224: continuation lifecycle coordinator boundary. */ sessionContinuationCoordinator?: SessionContinuationCoordinatorLike; - /** F254 D1.2b: audit stream for queued_seen → queued_handled closure. */ + /** F254: audit stream for exact queued-body adoption and other freshness lifecycle events. */ freshnessEventLog?: FreshnessAttentionEventLog; /** F254 Phase E: typed successor preflight/adoption and crash closure. */ freshnessClosureStore?: FreshnessClosureStore; @@ -514,17 +506,14 @@ export interface QueueProcessorDeps { turnExecutionStore?: Pick; /** F167 Phase S.1: carrier preflight plus failed/canceled runtime outcomes; success requires Evidence→Verdict. */ actionSuccessorLeaseStore?: Pick; - /** F167: inspect replacement and retire exact consumed terminals through the canonical dispatch fence. */ - a2aDispatchDispositionService?: Pick< - A2ADispatchDispositionService, - 'inspectHandoff' | 'completeFromCoordinationTerminal' - >; /** * F254 Phase E (ADR-041 §5): seed the freshness seenCursor when closure adoption * injects required bodies — injection must count as seen, or the output gate * re-reads a frozen cursor and supersedes every replacement forever. */ - deliveryCursorStore?: Pick; + deliveryCursorStore?: Pick; + /** F117 Phase H: process-local index; History remains the only lifecycle truth. */ + callerDispatchObservationRegistry?: CallerDispatchObservationRegistry; } /** F122B B6: Completion hook — called when a queue entry finishes execution. */ @@ -534,6 +523,11 @@ export type EntryCompleteHook = ( responseText: string, ) => void; +interface RegisteredEntryCompleteHook { + readonly hook: EntryCompleteHook; + readonly targetCatId?: string; +} + export type ContinuationEnqueueOutcome = | 'enqueued' | 'skipped_missing_capsule' @@ -542,12 +536,49 @@ export type ContinuationEnqueueOutcome = | 'skipped_rate_limited' | 'queue_full'; +export type AppendExactEntryResult = + | { outcome: 'appended'; entry: QueueEntry; acceptedTargetIds: string[]; rejectedTargetIds?: string[] } + | { + outcome: 'rejected'; + reason: + | 'append_unavailable' + | 'state_changed' + | 'custody_unavailable' + | 'lifecycle_conflict' + | 'provider_rejected'; + rejectedTargetIds?: string[]; + }; + interface AutoResumeSuppression { setAt: number; + epoch: number; executionIds: Set; hasAnonymousFence: boolean; } +interface ThreadDrainState { + dirty: boolean; + owner?: Promise; + /** + * A newer, unrelated cancellation may finish while an older cancel-all + * identity is still waiting for its exact terminal. That old fence must stay + * consumable, but it must not strand work released by the newer invocation. + */ + bypassSuppressionEpochByCatId?: Map; +} + +interface ConversationBatchResolution { + readonly routingClass: 'explicit' | 'targetless'; + readonly requestedTargets: readonly string[]; + readonly resolvedTargets: readonly string[]; +} + +interface QueueAdmissionAttempt { + readonly started: boolean; + readonly progressed?: boolean; + readonly entry?: QueueEntry; +} + export class QueueProcessor { private deps: QueueProcessorDeps; /** F108: Per-slot mutex — prevents concurrent double-start per (thread, cat) pair. @@ -555,16 +586,22 @@ export class QueueProcessor { * F194: the reservation object is the exact pre-start owner; invocationId is * bound immediately after durable record creation. */ private processingSlots = new Map(); - /** F108: Per-slot pause tracking (set on canceled/failed, cleared on next execution) */ - private pausedSlots = new Map(); - private pauseEpoch = new Map(); /** Suppress automatic admission per slot while cancelAll/force-reset settles. * Observers use the slot fence; only a canceled execution named by the owning * cancel action may consume it. TTL bounds lock-only/missing-terminal cases. */ private suppressedAutoResume = new Map(); + private nextAutoResumeSuppressionEpoch = 0; + /** RFC #1356: one event-driven drain owner plus a no-lost-wakeup dirty bit per thread. */ + private readonly threadDrains = new Map(); + /** + * RFC #1356 admission handoff. Queue owns only pre-admission work; once the + * provider is admitted, this process-local registry keeps the immutable + * execution snapshot needed to persist exact child/body witnesses. The + * durable Queue ledger remains the crash-recovery truth. + */ private static readonly SUPPRESS_TTL_MS = 60_000; /** F122B B6: Per-entry completion hooks (for multi-mention response aggregation). */ - private entryCompleteHooks = new Map(); + private entryCompleteHooks = new Map(); /** F118: age threshold for explicit owner-reaper candidacy (default 75min). */ private processingSlotTtlMs: number; private readonly sessionContinuationCoordinator?: SessionContinuationCoordinatorLike; @@ -573,6 +610,10 @@ export class QueueProcessor { private static readonly CONTINUATION_WINDOW_MS = 60 * 60 * 1000; private static readonly MAX_CONTINUATIONS_PER_WINDOW = 5; private readonly routeChainTracker = new RouteChainCompletionTracker(); + private readonly callerDispatchObservations: CallerDispatchObservationRegistry; + private readonly callerDispatchProcessStart?: { + processGenerationId: string; + }; private broadcastFreshnessClosure(closure: Awaited>): void { if (!closure) return; @@ -650,12 +691,11 @@ export class QueueProcessor { invocationId: string | undefined, finalStatus: InvocationFinalStatus, executionError: unknown, - ): Promise { - const supplementId = entry.freshnessSupplementId; + ): Promise { + const supplementId = entry.execution.freshnessSupplementId; const store = this.deps.freshnessClosureStore; - if (!supplementId || !store) return { kind: 'none' }; + if (!supplementId || !store) return; - let durableTerminalOwner: QueueEntryDurableTerminalOwner = { kind: 'none' }; try { let supplement = await store.getSupplement(supplementId); let durableBodyFound = false; @@ -664,7 +704,6 @@ export class QueueProcessor { supplement = recovered.supplement; durableBodyFound = recovered.durableBodyFound; if (supplement?.status === 'committed') { - durableTerminalOwner = { kind: 'freshness_supplement', supplementId: supplement.id }; this.broadcastFreshnessSupplement(supplement); } } @@ -676,7 +715,6 @@ export class QueueProcessor { now: Date.now(), }); supplement = failed; - durableTerminalOwner = { kind: 'freshness_supplement', supplementId: failed.id }; this.broadcastFreshnessSupplement(failed); if (invocationId) { await this.deps.invocationRecordStore.update(invocationId, { @@ -687,27 +725,56 @@ export class QueueProcessor { } } } - if ( - supplement && - supplement.id === supplementId && - (supplement.status === 'committed' || supplement.status === 'declined' || supplement.status === 'failed') - ) { - durableTerminalOwner = { kind: 'freshness_supplement', supplementId: supplement.id }; - } } catch (err) { this.deps.log.error( { err, threadId: entry.threadId, entryId: entry.id, supplementId }, '[F254] failed to close unfinished supplement attempt', ); } - return durableTerminalOwner; } - constructor(deps: QueueProcessorDeps, opts?: { processingSlotTtlMs?: number }) { + constructor( + deps: QueueProcessorDeps, + opts?: { + processingSlotTtlMs?: number; + callerDispatchProcessStart?: { processGenerationId: string }; + }, + ) { this.deps = deps; - this.processingSlotTtlMs = opts?.processingSlotTtlMs ?? DEFAULT_INVOCATION_SLOT_TTL_MS; + this.processingSlotTtlMs = opts?.processingSlotTtlMs ?? DEFAULT_PRESTART_RESERVATION_TTL_MS; this.sessionContinuationCoordinator = deps.sessionContinuationCoordinator ?? QueueProcessor.createSessionContinuationCoordinator(deps.threadStore); + this.callerDispatchObservations = deps.callerDispatchObservationRegistry ?? new CallerDispatchObservationRegistry(); + this.callerDispatchProcessStart = opts?.callerDispatchProcessStart; + } + + /** + * Record durable Queue target selection for an agent-authored source. This is + * process-local caller context only; Queue and History remain canonical. + */ + registerCallerDispatchInitialTargets(source: StoredMessage, targetIds: readonly string[]): void { + this.callerDispatchObservations.registerInitialSource(source, targetIds); + } + + /** Record only Steer changes that already committed to the durable Queue. */ + registerCallerDispatchSteerChanges( + source: StoredMessage, + changes: { readonly addedTargetIds: readonly string[]; readonly removedTargetIds: readonly string[] }, + ): void { + this.callerDispatchObservations.registerSteerChanges(source, changes); + } + + /** Record a committed whole-entry withdrawal without a post-commit History read. */ + registerCallerDispatchQueueWithdrawal(entry: QueueEntry): void { + const sourceMessageId = entry.payload.messageId; + if (!isPublicQueueEntry(entry) || entry.from.kind !== 'agent' || !sourceMessageId) return; + this.callerDispatchObservations.registerQueueWithdrawalByIdentity({ + ownerId: queueEntryOwnerId(entry), + threadId: entry.threadId, + callerCatId: entry.from.catId, + sourceMessageId, + targetIds: queueEntryTargetCats(entry), + }); } private static createSessionContinuationCoordinator( @@ -761,8 +828,10 @@ export class QueueProcessor { * Called by multi-mention dispatch to capture response text for aggregation. * Hook is auto-removed after invocation (one-shot). */ - registerEntryCompleteHook(entryId: string, hook: EntryCompleteHook): void { - this.entryCompleteHooks.set(entryId, hook); + registerEntryCompleteHook(entryId: string, hook: EntryCompleteHook, targetCatId?: string): void { + const hooks = this.entryCompleteHooks.get(entryId) ?? []; + hooks.push({ hook, ...(targetCatId ? { targetCatId } : {}) }); + this.entryCompleteHooks.set(entryId, hooks); } /** F122B B6: Remove a completion hook (e.g. on abort before execution). */ @@ -770,119 +839,721 @@ export class QueueProcessor { this.entryCompleteHooks.delete(entryId); } - /** ADR-042: removing a queued carrier must also close its durable responsibility. */ - async finalizeRemovedEntry( - entry: Pick | null | undefined, - reason: FreshnessSupplementFailureReason = 'user_cancel', - ): Promise { - if (!entry?.freshnessSupplementId || !this.deps.freshnessClosureStore) return true; - try { - const supplement = await this.deps.freshnessClosureStore.getSupplement(entry.freshnessSupplementId); - if (supplement?.status !== 'pending') return true; - const failed = await this.deps.freshnessClosureStore.failSupplement(supplement.id, { - reason, - now: Date.now(), + private async compensateLifecycleAppendTargets(input: { + entry: QueueEntry; + inputMessageIds: readonly string[]; + sourceMessages: readonly StoredMessage[]; + runs: readonly { targetId: string; invocationId: string; responseMessageId: string }[]; + failedTargetIds: readonly string[]; + failedAtLowerBound: number; + }): Promise { + const { invocationTracker, messageStore } = this.deps; + for (const targetId of input.failedTargetIds) { + const run = input.runs.find((candidate) => candidate.targetId === targetId); + if (!run) throw new Error(`lifecycle Append compensation target is not fenced: ${targetId}`); + const failedAt = Math.max(Date.now(), input.failedAtLowerBound); + const failureMessages: StoredMessage[] = []; + for (const sourceMessage of input.sourceMessages) { + failureMessages.push( + await messageStore.append({ + from: { kind: 'system', service: 'message-delivery' }, + userId: sourceMessage.userId, + threadId: input.entry.threadId, + content: `${targetId} 的当前 Agent Client 已关闭,消息未追加到该回合。`, + mentions: [], + timestamp: failedAt, + idempotencyKey: `lifecycle-append-rejection:${input.entry.id}:${targetId}:${sourceMessage.id}`, + lifecycle: { + kind: 'delivery_failure', + orderKey: `${failedAt}:append-rejection:${input.entry.id}:${targetId}:${sourceMessage.id}`, + status: 'failed', + sourceEntryId: input.entry.id, + inputMessageId: sourceMessage.id, + requestedTargets: [targetId], + reason: 'control_carrier_replaced', + createdAt: failedAt, + }, + }), + ); + } + const compensation = await messageStore.commitLifecycleAppendRejection({ + threadId: input.entry.threadId, + entryId: input.entry.id, + inputMessageIds: input.inputMessageIds, + failureMessageIds: failureMessages.map((message) => message.id), + run, }); - this.broadcastFreshnessSupplement(failed); - return true; - } catch (err) { - this.deps.log.error( - { err, supplementId: entry.freshnessSupplementId, reason }, - '[F254] failed to terminalize removed supplement carrier', - ); - return false; + if (compensation.kind !== 'applied' && compensation.kind !== 'replayed') { + throw new Error( + `lifecycle Append rejection compensation ${compensation.kind}:${'reason' in compensation ? compensation.reason : ''}`, + ); + } + if ( + !invocationTracker.detachLifecycleActiveRunInputs?.( + input.entry.threadId, + targetId, + run, + input.entry.id, + input.inputMessageIds, + ) + ) { + this.deps.log.warn( + { threadId: input.entry.threadId, targetId, invocationId: run.invocationId }, + '[QueueProcessor] compensated rejected Append after its live Active Run had already closed', + ); + } + for (const message of [...compensation.messages, ...failureMessages]) { + this.emitLifecycleMessageUpdated(queueEntryOwnerId(input.entry), message); + } } } - private static slotKey(threadId: string, catId: string): string { - return JSON.stringify([threadId, catId]); - } - - private static slotMatchesThread(key: string, threadId: string): boolean { - return QueueProcessor.parseSlotKey(key)?.threadId === threadId; - } - - private static parseSlotKey(key: string): { threadId: string; catId: string } | null { - try { - const parsed = JSON.parse(key); - if ( - Array.isArray(parsed) && - parsed.length === 2 && - typeof parsed[0] === 'string' && - typeof parsed[1] === 'string' - ) { - return { threadId: parsed[0], catId: parsed[1] }; + /** + * Admission-owned automatic Queue -> Active Run transfer. Human work must + * still be bound to the same parent selected by its continue-current intent; + * agent A2A carriers have no human disposition and are eligible by default. + * The exact run/capability/revision fences remain owned by the shared + * lifecycle projection and append transaction below. + */ + async tryAutoAppendExactEntry(input: { + threadId: string; + userId: string; + entryId: string; + targetCatId?: string; + }): Promise { + const { queue, invocationTracker } = this.deps; + const entry = queue.getEntrySnapshot(input.threadId, input.userId, input.entryId); + if (!entry || (entry.from.kind !== 'user' && entry.from.kind !== 'agent')) { + return { outcome: 'rejected', reason: 'append_unavailable' }; + } + if (entry.from.kind === 'user') { + if (!invocationTracker.getExecutionId) { + return { outcome: 'rejected', reason: 'append_unavailable' }; + } + const requestedTargets = input.targetCatId ? [input.targetCatId] : queueEntryTargetCats(entry); + const remainsBoundToRequestedParent = requestedTargets.every((targetId) => { + const intent = entry.delivery.authorIntentByTarget?.[targetId]; + return ( + intent?.requested === 'continue_current' && + intent.fallbackAt === undefined && + typeof intent.boundParentInvocationId === 'string' && + invocationTracker.getExecutionId?.(input.threadId, targetId) === intent.boundParentInvocationId + ); + }); + if (!remainsBoundToRequestedParent) { + return { outcome: 'rejected', reason: 'append_unavailable' }; } - } catch { - // Legacy in-memory keys from older code are not expected after restart. } - const legacySep = key.indexOf(':'); - if (legacySep > 0) { - return { threadId: key.slice(0, legacySep), catId: key.slice(legacySep + 1) }; + if (!invocationTracker.getActiveSlots || !invocationTracker.getUserId) { + return { outcome: 'rejected', reason: 'append_unavailable' }; } - return null; + const projection = projectLifecycleAppendAction({ + threadId: input.threadId, + userId: input.userId, + queueRevision: queue.snapshotRevision(input.threadId, input.userId), + entry, + invocationTracker: { + getActiveSlots: (threadId) => invocationTracker.getActiveSlots?.(threadId) ?? [], + getUserId: (threadId, catId) => invocationTracker.getUserId?.(threadId, catId) ?? null, + getAgentClientActiveRunDispatcher: (threadId, catId) => + invocationTracker.getAgentClientActiveRunDispatcher?.(threadId, catId), + }, + ...(input.targetCatId ? { targetIds: [input.targetCatId] } : {}), + }); + if (!projection.available) return { outcome: 'rejected', reason: 'append_unavailable' }; + return this.appendExactEntry({ + threadId: input.threadId, + userId: input.userId, + entryId: input.entryId, + expectedQueueRevision: projection.action.expectedQueueRevision, + expectedRuns: projection.action.expectedRuns, + }); } - private reserveProcessingSlot(key: string, entryId: string, userId: string): ProcessingSlotReservation { - const reservation: ProcessingSlotReservation = { startedAt: Date.now(), entryId, userId }; - this.processingSlots.set(key, reservation); - return reservation; - } + /** + * Explicit Queue -> existing Active Run transfer. Every capability/run fence + * is revalidated before the synchronous Queue claim; provider side effects + * occur only after exposure, lifecycle refs, and History admission are durable. + */ + async appendExactEntry(input: { + threadId: string; + userId: string; + entryId: string; + expectedQueueRevision: string; + expectedRuns: readonly { targetId: string; invocationId: string; responseMessageId: string }[]; + }): Promise { + if (input.expectedRuns.length > 1) { + if (this.deps.queue.snapshotRevision(input.threadId, input.userId) !== input.expectedQueueRevision) { + return { outcome: 'rejected', reason: 'state_changed' }; + } + const acceptedTargetIds: string[] = []; + const rejectedTargetIds: string[] = []; + let acceptedEntry: QueueEntry | undefined; + let firstRejection: Extract | undefined; + for (const expectedRun of input.expectedRuns) { + const result = await this.appendExactEntry({ + ...input, + expectedQueueRevision: this.deps.queue.snapshotRevision(input.threadId, input.userId), + expectedRuns: [expectedRun], + }); + if (result.outcome === 'appended') { + acceptedTargetIds.push(...result.acceptedTargetIds); + acceptedEntry = result.entry; + } else { + firstRejection ??= result; + rejectedTargetIds.push(expectedRun.targetId); + } + } + if (acceptedEntry) { + return { + outcome: 'appended', + entry: acceptedEntry, + acceptedTargetIds, + ...(rejectedTargetIds.length > 0 ? { rejectedTargetIds } : {}), + }; + } + return firstRejection ?? { outcome: 'rejected', reason: 'append_unavailable' }; + } + const { queue, invocationTracker, messageStore, socketManager } = this.deps; + if (input.expectedRuns.length === 0) { + return { outcome: 'rejected', reason: 'custody_unavailable' }; + } + const entry = queue.getEntrySnapshot(input.threadId, input.userId, input.entryId); + if ( + !entry || + input.expectedRuns.length !== 1 || + input.expectedRuns.some((run) => !queueEntryTargetCats(entry).includes(run.targetId)) + ) { + return { outcome: 'rejected', reason: 'state_changed' }; + } + const activeRunByTarget = new Map( + (invocationTracker.getActiveSlots?.(input.threadId) ?? []).flatMap((slot) => + slot.activeRun ? [[slot.catId, slot.activeRun] as const] : [], + ), + ); + if ( + input.expectedRuns.some((run) => { + const current = activeRunByTarget.get(run.targetId); + return ( + !current || + current.invocationId !== run.invocationId || + current.responseMessageId !== run.responseMessageId || + invocationTracker.getUserId?.(input.threadId, run.targetId) !== input.userId + ); + }) + ) { + return { outcome: 'rejected', reason: 'append_unavailable' }; + } + const dispatchers = input.expectedRuns.map((run) => { + const dispatcher = invocationTracker.getAgentClientActiveRunDispatcher?.(input.threadId, run.targetId); + return dispatcher?.capabilities.append === true && dispatcher.invocationId === run.invocationId + ? dispatcher + : undefined; + }); + if (dispatchers.some((dispatcher) => !dispatcher)) { + return { outcome: 'rejected', reason: 'append_unavailable' }; + } - private releaseProcessingSlot(key: string, reservation: ProcessingSlotReservation): boolean { - if (this.processingSlots.get(key) !== reservation) return false; - this.processingSlots.delete(key); - return true; - } + const claimed = await queue.claimExactAppend( + input.threadId, + input.userId, + input.entryId, + input.expectedQueueRevision, + input.expectedRuns.map((run) => run.targetId), + ); + if (!claimed) return { outcome: 'rejected', reason: 'state_changed' }; + const seenAt = Math.max(Date.now(), claimed.enqueuedAt); + const inputMessageIds = queueEntryMessageIds(claimed); + if (inputMessageIds.length === 0) { + await queue.restoreClaimedEntries(input.threadId, [input.entryId]); + return { outcome: 'rejected', reason: 'lifecycle_conflict' }; + } + let removed: QueueEntry | null = null; + let lifecycleAdmissionCommitted = false; + let providerDispatchStarted = false; + const mirroredRuns: (typeof input.expectedRuns)[number][] = []; + let sourceMessages: StoredMessage[] = []; + try { + const sourceMessagesBeforeAdmission = ( + await Promise.all(inputMessageIds.map((messageId) => messageStore.getById(messageId))) + ).filter((message): message is StoredMessage => !!message); + if (sourceMessagesBeforeAdmission.length !== inputMessageIds.length) { + throw new Error(`lifecycle Append source vanished before admission: ${input.entryId}`); + } + sourceMessages = sourceMessagesBeforeAdmission; + const imagePaths = sourceMessagesBeforeAdmission.flatMap((message) => extractImagePaths(message.contentBlocks)); + for (const run of input.expectedRuns) { + if ( + !invocationTracker.appendLifecycleActiveRunInputs?.( + input.threadId, + run.targetId, + run, + input.entryId, + inputMessageIds, + ) + ) { + throw new Error(`Active Run changed during Append admission: ${run.targetId}/${run.invocationId}`); + } + mirroredRuns.push(run); + } - private publishRequeuedPrestartEntry(entry: QueueEntry): void { - void (async () => { - try { - await this.persistQueueEntry(entry); - } catch (err) { - this.deps.log.error( - { err, threadId: entry.threadId, userId: entry.userId, entryId: entry.id }, - '[QueueProcessor] zombie pre-start Queue custody persistence failed', + const admission = await messageStore.commitLifecycleAppendAdmission({ + threadId: input.threadId, + entryId: input.entryId, + inputMessageIds, + runs: input.expectedRuns.map((run) => ({ ...run, dispatchedAt: seenAt })), + }); + if (admission.kind !== 'applied' && admission.kind !== 'replayed') { + throw new Error( + `lifecycle Append admission ${admission.kind}:${'reason' in admission ? admission.reason : ''}`, + ); + } + lifecycleAdmissionCommitted = true; + sourceMessages = admission.messages.slice(0, inputMessageIds.length); + for (const sourceMessage of sourceMessages) { + this.callerDispatchObservations.registerPersistedSource( + sourceMessage, + input.expectedRuns.map((run) => run.targetId), ); } + const delivery = await this.markDeliveredAndEmit( + input.userId, + input.threadId, + inputMessageIds, + seenAt, + new Set(), + ); + if (delivery.failedIds.length > 0) { + throw new Error(`lifecycle Append delivery transition failed: ${delivery.failedIds.join(',')}`); + } + if (!(await queue.commitClaimedProcessing(input.threadId, [input.entryId], seenAt))) { + throw new Error(`claimed Append Queue target retirement did not commit: ${input.entryId}`); + } + removed = await queue.removeProcessedDurable(input.threadId, input.userId, input.entryId); + if (!removed) throw new Error(`claimed Append Queue entry vanished: ${input.entryId}`); + try { + for (const message of admission.messages) this.emitLifecycleMessageUpdated(input.userId, message); await emitQueueUpdated( - this.deps.socketManager, - entry.userId, - entry.threadId, - this.deps.queue.list(entry.threadId, entry.userId), - this.deps.messageStore, - 'zombie_prestart_requeued', + socketManager, + input.userId, + input.threadId, + queue.list(input.threadId, input.userId), + messageStore, + 'appended', ); - } catch (err) { + } catch (projectionErr) { this.deps.log.warn( - { err, threadId: entry.threadId, userId: entry.userId, entryId: entry.id }, - '[QueueProcessor] zombie pre-start queue update failed', + { projectionErr, threadId: input.threadId, entryId: input.entryId }, + '[QueueProcessor] lifecycle Append committed but live projection emit failed', ); } - })(); - } - - private recoverExpiredPrestartReservation(threadId: string, reservation: ProcessingSlotReservation): boolean { - if (reservation.trackerStarted) return false; - const current = this.deps.queue.getEntrySnapshot(threadId, reservation.userId, reservation.entryId); - if (current?.status !== 'processing') return false; - if (!this.deps.queue.rollbackProcessing(threadId, reservation.entryId)) return false; - const requeued = this.deps.queue.getEntrySnapshot(threadId, reservation.userId, reservation.entryId); - if (requeued?.status !== 'queued') return false; - this.publishRequeuedPrestartEntry(requeued); - return true; + providerDispatchStarted = true; + const results = await Promise.all( + input.expectedRuns.map(async (run, index) => { + try { + return await dispatchers[index]!.dispatch( + { + text: claimed.payload.content, + ...(imagePaths.length > 0 ? { imagePaths } : {}), + messageIds: inputMessageIds, + }, + { force: false, expectedInvocationId: run.invocationId }, + ); + } catch { + return { accepted: false as const, reason: 'provider_rejected' as const }; + } + }), + ); + const rejectedTargetIds = results.flatMap((result, index) => + result.accepted ? [] : [input.expectedRuns[index]!.targetId], + ); + if (rejectedTargetIds.length > 0) { + await this.compensateLifecycleAppendTargets({ + entry: claimed, + inputMessageIds, + sourceMessages, + runs: input.expectedRuns, + failedTargetIds: rejectedTargetIds, + failedAtLowerBound: seenAt + 1, + }); + return { outcome: 'rejected', reason: 'provider_rejected', rejectedTargetIds }; + } + return { outcome: 'appended', entry: removed, acceptedTargetIds: input.expectedRuns.map((run) => run.targetId) }; + } catch (err) { + if (!providerDispatchStarted && lifecycleAdmissionCommitted) { + try { + if (!removed) { + await queue.reconcileClaimedLifecycleTargets(input.threadId, [input.entryId], messageStore); + removed = claimed; + } + await this.compensateLifecycleAppendTargets({ + entry: claimed, + inputMessageIds, + sourceMessages, + runs: input.expectedRuns, + failedTargetIds: input.expectedRuns.map((run) => run.targetId), + failedAtLowerBound: seenAt + 1, + }); + await emitQueueUpdated( + socketManager, + input.userId, + input.threadId, + queue.list(input.threadId, input.userId), + messageStore, + 'append_failed', + ); + } catch (compensationErr) { + this.deps.log.error( + { compensationErr, threadId: input.threadId, entryId: input.entryId }, + '[QueueProcessor] failed to compensate lifecycle Append after durable admission', + ); + } + } else if (!providerDispatchStarted) { + for (const run of mirroredRuns) { + invocationTracker.detachLifecycleActiveRunInputs?.( + input.threadId, + run.targetId, + run, + input.entryId, + inputMessageIds, + ); + } + const restored = await queue.restoreClaimedEntries(input.threadId, [input.entryId]); + if (restored) { + try { + await emitQueueUpdated( + socketManager, + input.userId, + input.threadId, + queue.list(input.threadId, input.userId), + messageStore, + 'append_rollback', + ); + } catch (restoreErr) { + this.deps.log.error( + { restoreErr, threadId: input.threadId, entryId: input.entryId }, + '[QueueProcessor] failed to project lifecycle Append rollback', + ); + } + } + } + this.deps.log.error( + { err, threadId: input.threadId, entryId: input.entryId }, + '[QueueProcessor] explicit lifecycle Append failed closed', + ); + return { outcome: 'rejected', reason: 'lifecycle_conflict' }; + } } - private bindProcessingSlotInvocation( - key: string, - reservation: ProcessingSlotReservation, - invocationId: string, - ): boolean { - if (this.processingSlots.get(key) !== reservation) return false; - reservation.invocationId = invocationId; - return true; - } + /** + * Adopt full queued bodies that one exact active child has already requested. + * Each adopted target leaves its source entry immediately; sibling targets + * remain pending there, while Message lifecycle points at the existing + * processing response instead of creating a second invocation. + */ + async adoptExposedQueuedEntries(input: { + threadId: string; + userId: string; + catId: string; + invocationId: string; + entries: readonly { entryId: string; messageId: string }[]; + seenAt?: number; + }): Promise { + const { queue, invocationTracker, messageStore, socketManager } = this.deps; + const activeRun = invocationTracker + .getActiveSlots?.(input.threadId) + .find((slot) => slot.catId === input.catId)?.activeRun; + if ( + !activeRun || + activeRun.invocationId !== input.invocationId || + activeRun.targetId !== input.catId || + invocationTracker.getUserId?.(input.threadId, input.catId) !== input.userId + ) { + return { outcome: 'rejected', reason: 'active_run_missing' }; + } + if ( + input.entries.length === 0 || + new Set(input.entries.map((entry) => entry.entryId)).size !== input.entries.length || + new Set(input.entries.map((entry) => entry.messageId)).size !== input.entries.length + ) { + return { outcome: 'rejected', reason: 'state_changed' }; + } + + const adoptedEntryIds: string[] = []; + for (const candidate of input.entries) { + const result = await this.adoptExposedQueuedEntry({ + ...input, + candidate, + run: { + targetId: input.catId, + invocationId: activeRun.invocationId, + responseMessageId: activeRun.responseMessageId, + }, + }); + if (result.outcome === 'rejected') return result; + adoptedEntryIds.push(candidate.entryId); + } + + await emitQueueUpdated( + socketManager, + input.userId, + input.threadId, + queue.list(input.threadId, input.userId), + messageStore, + 'queued_adopted', + ); + return { outcome: 'adopted', adoptedEntryIds }; + } + + private async adoptExposedQueuedEntry(input: { + threadId: string; + userId: string; + catId: string; + invocationId: string; + candidate: { entryId: string; messageId: string }; + run: { targetId: string; invocationId: string; responseMessageId: string }; + seenAt?: number; + }): Promise { + const { queue, invocationTracker, messageStore } = this.deps; + const claimed = await queue.claimExactExposureDurable( + input.threadId, + input.userId, + input.candidate.entryId, + input.catId, + input.candidate.messageId, + ); + if (!claimed) return { outcome: 'rejected', reason: 'state_changed', entryId: input.candidate.entryId }; + + const messageIds = queueEntryMessageIds(claimed); + const newlySeen = true; + let lifecycleCommitted = false; + let liveProjectionExtended = false; + try { + liveProjectionExtended = + invocationTracker.adoptLifecycleActiveRunInputs?.( + input.threadId, + input.catId, + input.run, + claimed.id, + messageIds, + ) ?? false; + if (!liveProjectionExtended) { + await queue.restoreClaimedEntries(input.threadId, [claimed.id]); + return { outcome: 'rejected', reason: 'active_run_missing', entryId: claimed.id }; + } + + const seenAt = Math.max(input.seenAt ?? Date.now(), claimed.enqueuedAt); + const delivery = await this.markDeliveredAndEmit(input.userId, input.threadId, messageIds, seenAt, new Set()); + if (delivery.failedIds.length > 0) { + invocationTracker.detachLifecycleActiveRunInputs?.( + input.threadId, + input.catId, + input.run, + claimed.id, + messageIds, + ); + await queue.restoreClaimedEntries(input.threadId, [claimed.id]); + return { outcome: 'rejected', reason: 'persistence_unavailable', entryId: claimed.id }; + } + + const admission = await messageStore.commitLifecycleAppendAdmission({ + threadId: input.threadId, + entryId: claimed.id, + inputMessageIds: messageIds, + runs: [{ ...input.run, dispatchedAt: seenAt }], + }); + if (admission.kind !== 'applied' && admission.kind !== 'replayed') { + invocationTracker.detachLifecycleActiveRunInputs?.( + input.threadId, + input.catId, + input.run, + claimed.id, + messageIds, + ); + await queue.restoreClaimedEntries(input.threadId, [claimed.id]); + return { outcome: 'rejected', reason: 'lifecycle_conflict', entryId: claimed.id }; + } + lifecycleCommitted = true; + for (const sourceMessage of admission.messages.slice(0, messageIds.length)) { + this.callerDispatchObservations.registerPersistedSource(sourceMessage, [input.catId]); + } + + const committed = await queue.commitClaimedAdoptionDurable( + input.threadId, + input.userId, + claimed.id, + input.catId, + input.invocationId, + seenAt, + ); + if (!committed) { + const terminalized = await queue.removeProcessedDurable(input.threadId, input.userId, claimed.id); + if (!terminalized) { + // The response lifecycle already owns this input. Best-effort bind the + // row to processing so startup terminalizes it as interrupted instead + // of restoring a claimed row to executable Queue work. + await queue.commitClaimedProcessing(input.threadId, [claimed.id], seenAt); + return { outcome: 'rejected', reason: 'persistence_unavailable', entryId: claimed.id }; + } + } + if (newlySeen) recordQueuedSeenTelemetry(); + recordQueuedHandledTelemetry({ fullyConsumed: true }); + for (const message of admission.messages) this.emitLifecycleMessageUpdated(input.userId, message); + return { outcome: 'adopted', adoptedEntryIds: [claimed.id] }; + } catch (err) { + if (!lifecycleCommitted) { + if (liveProjectionExtended) { + invocationTracker.detachLifecycleActiveRunInputs?.( + input.threadId, + input.catId, + input.run, + claimed.id, + messageIds, + ); + } + await queue.restoreClaimedEntries(input.threadId, [claimed.id]); + } else { + // Once the source is attached to the response, queued is no longer a + // truthful recovery state. Preserve processing ownership if storage + // recovers enough to accept this final defensive write. + await queue + .commitClaimedProcessing(input.threadId, [claimed.id], input.seenAt ?? Date.now()) + .catch(() => false); + } + this.deps.log.error( + { err, threadId: input.threadId, entryId: claimed.id, invocationId: input.invocationId }, + '[QueueProcessor] exact queued-body adoption failed closed', + ); + return { + outcome: 'rejected', + reason: lifecycleCommitted ? 'persistence_unavailable' : 'lifecycle_conflict', + entryId: claimed.id, + }; + } + } + + /** ADR-042: removing a queued carrier must also close its durable responsibility. */ + async finalizeRemovedEntry( + entry: Pick | null | undefined, + reason: FreshnessSupplementFailureReason = 'user_cancel', + ): Promise { + if (!entry?.execution.freshnessSupplementId || !this.deps.freshnessClosureStore) return true; + try { + const supplement = await this.deps.freshnessClosureStore.getSupplement(entry.execution.freshnessSupplementId); + if (supplement?.status !== 'pending') return true; + const failed = await this.deps.freshnessClosureStore.failSupplement(supplement.id, { + reason, + now: Date.now(), + }); + this.broadcastFreshnessSupplement(failed); + return true; + } catch (err) { + this.deps.log.error( + { err, supplementId: entry.execution.freshnessSupplementId, reason }, + '[F254] failed to terminalize removed supplement carrier', + ); + return false; + } + } + + private static slotKey(threadId: string, catId: string): string { + return JSON.stringify([threadId, catId]); + } + + private static slotMatchesThread(key: string, threadId: string): boolean { + return QueueProcessor.parseSlotKey(key)?.threadId === threadId; + } + + private static parseSlotKey(key: string): { threadId: string; catId: string } | null { + try { + const parsed = JSON.parse(key); + if ( + Array.isArray(parsed) && + parsed.length === 2 && + typeof parsed[0] === 'string' && + typeof parsed[1] === 'string' + ) { + return { threadId: parsed[0], catId: parsed[1] }; + } + } catch { + // Legacy in-memory keys from older code are not expected after restart. + } + const legacySep = key.indexOf(':'); + if (legacySep > 0) { + return { threadId: key.slice(0, legacySep), catId: key.slice(legacySep + 1) }; + } + return null; + } + + private reserveProcessingSlot(key: string, entryId: string, userId: string): ProcessingSlotReservation { + const reservation: ProcessingSlotReservation = { startedAt: Date.now(), entryId, userId }; + this.processingSlots.set(key, reservation); + return reservation; + } + + private releaseProcessingSlot(key: string, reservation: ProcessingSlotReservation): boolean { + if (this.processingSlots.get(key) !== reservation) return false; + this.processingSlots.delete(key); + return true; + } + + private publishRequeuedPrestartEntry(entry: QueueEntry): void { + void (async () => { + try { + await emitQueueUpdated( + this.deps.socketManager, + queueEntryOwnerId(entry), + entry.threadId, + this.deps.queue.list(entry.threadId, queueEntryOwnerId(entry)), + this.deps.messageStore, + 'zombie_prestart_requeued', + ); + } catch (err) { + this.deps.log.warn( + { err, threadId: entry.threadId, userId: queueEntryOwnerId(entry), entryId: entry.id }, + '[QueueProcessor] zombie pre-start queue update failed', + ); + } + })(); + } + + private async recoverExpiredPrestartReservation( + threadId: string, + catId: string, + reservation: ProcessingSlotReservation, + ): Promise<'requeued' | 'terminalized' | 'released' | 'blocked'> { + if (reservation.trackerStarted) return 'blocked'; + // A successful Queue claim commit removes the durable pending row and + // retains only the process-local admitted carrier until execution starts. + // Recovery therefore has to inspect the same pending-or-admitted view as + // pre-start retirement; a durable-only snapshot would silently release a + // stale processing reservation without recording its History failure. + const current = this.deps.queue + .getProcessingGroupAcrossUsers(threadId, reservation.entryId) + ?.find((entry) => queueEntryOwnerId(entry) === reservation.userId); + if (!current) return 'released'; + if (current.status === 'claimed') { + if (!(await this.deps.queue.rollbackProcessingDurable(threadId, reservation.entryId))) return 'blocked'; + const requeued = this.deps.queue.getEntrySnapshot(threadId, reservation.userId, reservation.entryId); + if (requeued?.status !== 'queued') return 'blocked'; + this.publishRequeuedPrestartEntry(requeued); + return 'requeued'; + } + if (current.status !== 'processing') return 'released'; + const outcome = await this.failPrestartProcessingGroup(threadId, catId, reservation.userId, 'prestart_timeout'); + return outcome === 'retired' ? 'terminalized' : 'blocked'; + } + + private bindProcessingSlotInvocation( + key: string, + reservation: ProcessingSlotReservation, + invocationId: string, + ): boolean { + if (this.processingSlots.get(key) !== reservation) return false; + reservation.invocationId = invocationId; + return true; + } private ownsProcessingSlotInvocation( key: string, @@ -892,24 +1563,24 @@ export class QueueProcessor { return this.processingSlots.get(key) === reservation && reservation.invocationId === invocationId; } - private reservedTargetStartBlocker( + private canStartReservedTargetSet( threadId: string, targetCats: readonly string[], primaryCat: string, reservation: ProcessingSlotReservation, invocationId: string, - ): 'reservation_replaced' | 'target_busy' | undefined { + ): boolean { if (!this.ownsProcessingSlotInvocation(QueueProcessor.slotKey(threadId, primaryCat), reservation, invocationId)) { - return 'reservation_replaced'; + return false; } for (const catId of new Set(targetCats)) { - if (this.deps.invocationTracker.has(threadId, catId)) return 'target_busy'; + if (this.deps.invocationTracker.has(threadId, catId)) return false; const currentReservation = this.processingSlots.get(QueueProcessor.slotKey(threadId, catId)); if (!currentReservation) continue; - if (catId !== primaryCat || currentReservation !== reservation) return 'target_busy'; + if (catId !== primaryCat || currentReservation !== reservation) return false; } - return undefined; + return true; } private canReplaceExternalTargetSet(threadId: string, catIds: readonly string[], userId: string): boolean { @@ -947,13 +1618,31 @@ export class QueueProcessor { private async terminalizePreparedPrestartRetirements( retirements: readonly PreparedPrestartRetirement[], ): Promise { + const retiringEntryIds = new Set(retirements.flatMap((retirement) => retirement.carriers.map((entry) => entry.id))); return terminalizePreparedPrestartRetirements(retirements, { finalizeSupplement: (entry) => this.finalizeRemovedEntry(entry, 'user_cancel'), messageStore: this.deps.messageStore, - queueCustodyCoordinator: this.deps.queueCustodyCoordinator, + shouldCancelMessage: (entry, messageId) => + !this.deps.queue + .listUsersForThread(entry.threadId) + .some((userId) => + this.deps.queue + .list(entry.threadId, userId) + .some( + (candidate) => + !retiringEntryIds.has(candidate.id) && queueEntryMessageIds(candidate).includes(messageId), + ), + ), + commitCarrier: async (entry) => + (await this.deps.queue.removeProcessedAcrossUsersDurable( + entry.threadId, + entry.id, + 'interrupted', + 'invocation_cancelled', + )) !== null, emitMessageDeleted: (userId, threadId, messageId) => this.deps.socketManager.emitToUser(userId, 'message_deleted', { - messageId, + messageId: messageId ?? null, threadId, deletedBy: userId, }), @@ -963,11 +1652,17 @@ export class QueueProcessor { /** Commit disappearance only after every carrier reached durable terminal truth. */ private commitPreparedPrestartRetirements(retirements: readonly PreparedPrestartRetirement[]): boolean { - return commitPreparedPrestartRetirements({ + const committed = commitPreparedPrestartRetirements({ retirements, slots: this.processingSlots, queue: this.deps.queue, }); + if (committed) { + for (const retirement of retirements) { + for (const carrier of retirement.carriers) this.registerCallerDispatchQueueWithdrawal(carrier); + } + } + return committed; } async retirePrestartProcessingGroup( @@ -981,6 +1676,71 @@ export class QueueProcessor { return this.commitPreparedPrestartRetirements(retirements) ? 'retired' : 'state_changed'; } + /** + * Fail a tracker-less create→startAll group through the ordinary delivery + * lifecycle. Public sources receive an adjacent delivery_failure and settle + * their exact target ref; typed/private carriers keep their own producer + * terminalization and never manufacture a History row. + */ + async failPrestartProcessingGroup( + threadId: string, + catId: string, + userId: string, + reason: 'control_plane_unavailable' | 'execution_owner_lost' | 'prestart_timeout', + ): Promise { + const retirements = this.preparePrestartRetirements(threadId, [catId], userId); + if (!retirements || retirements.length !== 1) return 'state_changed'; + + for (const retirement of retirements) { + for (const carrier of retirement.carriers) { + if (!(await this.finalizeRemovedEntry(carrier, 'infrastructure'))) return 'terminalization_failed'; + if (isPublicQueueEntry(carrier)) { + const sourceMessageId = carrier.payload.messageId; + const source = sourceMessageId ? await this.deps.messageStore.getById(sourceMessageId) : null; + if (!source) return 'terminalization_failed'; + const requestedTargets = [...queueEntryTargetCats(carrier)]; + const failedAt = Math.max(Date.now(), source.timestamp); + const reasonText = + reason === 'control_plane_unavailable' + ? '执行控制面不可用' + : reason === 'execution_owner_lost' + ? '执行进程归属已丢失' + : '启动阶段超时'; + const failure = await this.deps.messageStore.commitLifecyclePreAdmissionFailure({ + sourceMessageId: source.id, + expectedEntryId: carrier.id, + requestedTargets, + reason, + content: `唤起${requestedTargets.join('、') || catId}失败:${reasonText}(${reason})。来源消息:${source.id}。`, + failedAt, + }); + if (failure.kind !== 'applied' && failure.kind !== 'replayed') return 'terminalization_failed'; + this.callerDispatchObservations.registerPersistedSource(failure.inputMessage, requestedTargets); + this.emitLifecycleMessageUpdated(queueEntryOwnerId(carrier), failure.inputMessage); + this.emitLifecycleMessageUpdated(queueEntryOwnerId(carrier), failure.failureMessage); + } + const terminal = await this.deps.queue.removeProcessedAcrossUsersDurable( + carrier.threadId, + carrier.id, + 'failed', + reason, + ); + if (!terminal) return 'terminalization_failed'; + } + } + + if (!this.commitPreparedPrestartRetirements(retirements)) return 'state_changed'; + await emitQueueUpdated( + this.deps.socketManager, + userId, + threadId, + this.deps.queue.list(threadId, userId), + this.deps.messageStore, + 'pre_admission_failed', + ); + return 'retired'; + } + /** * Force-reset recovery for canonical pre-start owners that have no tracker, * invocation record, or session lock witness yet. Snapshot the user-owned @@ -992,95 +1752,42 @@ export class QueueProcessor { userId: string, ): Promise { const catIds: string[] = []; + const targetCatIds = new Set(); for (const [key, reservation] of this.processingSlots) { const scope = QueueProcessor.parseSlotKey(key); - if (scope?.threadId === threadId && reservation.userId === userId) catIds.push(scope.catId); - } - const retiredCatIds = new Set(); - if (catIds.length > 0) { - const retirements = this.preparePrestartRetirements(threadId, catIds, userId); - if (!retirements || retirements.length === 0) return { outcome: 'state_changed', retiredCatIds: [] }; - if (!(await this.terminalizePreparedPrestartRetirements(retirements))) { - return { outcome: 'terminalization_failed', retiredCatIds: [] }; - } - if (!this.commitPreparedPrestartRetirements(retirements)) { - return { outcome: 'state_changed', retiredCatIds: [] }; + if (scope?.threadId !== threadId || reservation.userId !== userId) continue; + catIds.push(scope.catId); + targetCatIds.add(scope.catId); + for (const carrier of this.deps.queue.getProcessingGroupAcrossUsers(threadId, reservation.entryId) ?? []) { + for (const targetCatId of queueEntryTargetCats(carrier)) targetCatIds.add(targetCatId); } - for (const retirement of retirements) retiredCatIds.add(retirement.targetCatId); + } + if (catIds.length === 0) return { outcome: 'none', retiredCatIds: [] }; + // A pre-start group still owns its complete target set even though the + // process-local reservation is keyed by the primary cat. Never retire that + // group through another user's live tracker owner on a sibling target. + if (!this.canReplaceExternalTargetSet(threadId, [...targetCatIds], userId)) { + return { outcome: 'state_changed', retiredCatIds: [] }; } - // A pre-start coroutine may have exited and released its slot while leaving - // a processing row. Reuse the same durable retirement protocol for this - // ownerless group; never infer a live owner from the row's status alone. - const candidates = this.deps.queue.list(threadId, userId).filter((entry) => entry.status === 'processing'); - for (const candidate of candidates) { - const current = this.deps.queue.getEntrySnapshot(threadId, userId, candidate.id); - if (!current || current.status !== 'processing') continue; - const group = this.deps.queue.getProcessingGroupAcrossUsers(threadId, current.id); - if (!group || group.some((entry) => entry.userId !== userId)) { - return { outcome: 'state_changed', retiredCatIds: [...retiredCatIds] }; - } - const targets = [...new Set(group.flatMap((entry) => entry.targetCats))]; - const target = current.prestartRetirement?.targetCatId ?? targets[0]; - if (!target || targets.some((catId) => this.hasActiveExecutionForCat(threadId, catId))) { - return { outcome: 'state_changed', retiredCatIds: [...retiredCatIds] }; - } - // This is synchronous with the ownership check. Any newer acquisition - // replaces the exact object and makes the existing commit fence reject. - this.reserveProcessingSlot(QueueProcessor.slotKey(threadId, target), current.id, userId); - const outcome = await this.retirePrestartProcessingGroup(threadId, target, userId); - if (outcome !== 'retired') return { outcome, retiredCatIds: [...retiredCatIds] }; - retiredCatIds.add(target); + const retirements = this.preparePrestartRetirements(threadId, catIds, userId); + if (!retirements || retirements.length === 0) return { outcome: 'state_changed', retiredCatIds: [] }; + if (!(await this.terminalizePreparedPrestartRetirements(retirements))) { + return { outcome: 'terminalization_failed', retiredCatIds: [] }; } - return { outcome: retiredCatIds.size > 0 ? 'retired' : 'none', retiredCatIds: [...retiredCatIds] }; + if (!this.commitPreparedPrestartRetirements(retirements)) { + return { outcome: 'state_changed', retiredCatIds: [] }; + } + return { + outcome: 'retired', + retiredCatIds: [...new Set(retirements.map((retirement) => retirement.targetCatId))], + }; } /** * Reinstall the process-local slot barrier from one restart-stable retirement * intent before normal Queue resume can expose a surviving subset. */ - async resumeDurablePrestartRetirement(entries: readonly QueueEntry[]): Promise { - const first = entries[0]; - const intent = first?.prestartRetirement; - if (!first || !intent) return false; - if ( - entries.some( - (entry) => - entry.threadId !== first.threadId || - entry.userId !== first.userId || - entry.status !== 'processing' || - JSON.stringify(entry.prestartRetirement) !== JSON.stringify(intent), - ) - ) { - return false; - } - - const key = QueueProcessor.slotKey(first.threadId, intent.targetCatId); - if (this.processingSlots.has(key)) return false; - const barrier: ProcessingSlotReservation = { - startedAt: intent.startedAt, - entryId: first.id, - userId: first.userId, - retirementBarrier: true, - }; - this.processingSlots.set(key, barrier); - const carriers = this.deps.queue.getProcessingGroupAcrossUsers(first.threadId, first.id); - if (!carriers) { - if (this.processingSlots.get(key) === barrier) this.processingSlots.delete(key); - return false; - } - const retirement: PreparedPrestartRetirement = { - key, - barrier, - threadId: first.threadId, - targetCatId: intent.targetCatId, - entryId: first.id, - carriers, - }; - if (!(await this.terminalizePreparedPrestartRetirements([retirement]))) return false; - return this.commitPreparedPrestartRetirements([retirement]); - } - private completeProcessingSlotByExecutionId( threadId: string, catId: string, @@ -1116,16 +1823,6 @@ export class QueueProcessor { ); } - private supersedePausedSlot(key: string): void { - const epoch = this.pauseEpoch.get(key); - this.pausedSlots.delete(key); - if (epoch !== undefined) this.pauseEpoch.set(key, epoch + 1); - } - - private supersedePausedTerminalEffects(threadId: string, catId: string): void { - this.supersedePausedSlot(QueueProcessor.slotKey(threadId, catId)); - } - private runOwnershipValidatedHook(hook: (() => void) | undefined): void { hook?.(); } @@ -1220,9 +1917,6 @@ export class QueueProcessor { ); return null; } - if (!controller.signal.aborted) { - for (const catId of uniqueCatIds) this.supersedePausedTerminalEffects(threadId, catId); - } return controller; } finally { admission.release(); @@ -1234,7 +1928,7 @@ export class QueueProcessor { * tracker. Unlike the former read-side sweep, this is called only by the serialized * owner reaper and never guesses about a started provider. */ - reapStalePrestartReservations(now = Date.now()): number { + async reapStalePrestartReservations(now = Date.now()): Promise { if (this.processingSlotTtlMs <= 0) return 0; let reaped = 0; for (const [key, reservation] of this.processingSlots) { @@ -1246,8 +1940,9 @@ export class QueueProcessor { continue; const scope = QueueProcessor.parseSlotKey(key); if (!scope || this.deps.invocationTracker.has(scope.threadId, scope.catId)) continue; - if (!this.releaseProcessingSlot(key, reservation)) continue; - const queueEntryRequeued = this.recoverExpiredPrestartReservation(scope.threadId, reservation); + const recovery = await this.recoverExpiredPrestartReservation(scope.threadId, scope.catId, reservation); + if (recovery === 'blocked') continue; + if (this.processingSlots.get(key) === reservation && !this.releaseProcessingSlot(key, reservation)) continue; reaped += 1; this.deps.log.warn( { @@ -1256,7 +1951,7 @@ export class QueueProcessor { catId: scope.catId, entryId: reservation.entryId, ageMs: now - reservation.startedAt, - queueEntryRequeued, + recovery, }, '[F118] stale pre-provider reservation released by explicit owner reaper', ); @@ -1290,35 +1985,34 @@ export class QueueProcessor { return result; } - /** Check if a slot's queue is paused (canceled/failed AND has queued entries). */ - isPaused(threadId: string, catId?: string): boolean { - if (catId) { - return ( - this.pausedSlots.has(QueueProcessor.slotKey(threadId, catId)) && this.hasDispatchableQueuedForThread(threadId) - ); - } - // Backward compat: check if any slot for this thread is paused - for (const key of this.pausedSlots.keys()) { - if (QueueProcessor.slotMatchesThread(key, threadId)) { - if (this.hasDispatchableQueuedForThread(threadId)) return true; - } - } - return false; - } - /** Expose queued-state for route fairness decisions in non-queue entry paths (retry/connector). */ hasQueuedForThread(threadId: string): boolean { return this.deps.queue.hasQueuedForThread(threadId); } - /** A2A fairness gate: only user-sourced entries should block text-scan A2A. */ - hasQueuedUserMessagesForThread(threadId: string): boolean { - return this.deps.queue.hasQueuedUserMessagesForThread(threadId); + /** Public-conversation fairness is entry-kind based; sender identity is not lifecycle state. */ + hasQueuedConversationInputsForThread(threadId: string): boolean { + return this.deps.queue.hasQueuedConversationInputsForThread(threadId); } - /** F185 Phase B: non-agent fairness gate for text-scan A2A — user + connector block, agent does not. */ - hasQueuedNonAgentForThread(threadId: string): boolean { - return this.deps.queue.hasQueuedNonAgentForThread(threadId); + private async ackPromptMentionCursors(input: PromptMessagesExposedInput): Promise { + const cursorStore = this.deps.deliveryCursorStore; + if (!cursorStore) return; + for (const messageId of new Set(input.messageIds)) { + try { + const message = await this.deps.messageStore.getById(messageId); + if (!message?.mentions.includes(input.catId as CatId)) continue; + const cursor = this.deps.messageStore.canonicalizeCursor + ? await this.deps.messageStore.canonicalizeCursor(messageId, input.threadId) + : messageId; + await cursorStore.ackMentionCursor(input.userId, input.catId as CatId, input.threadId, cursor); + } catch (err) { + this.deps.log.warn( + { err, threadId: input.threadId, catId: input.catId, invocationId: input.invocationId, messageId }, + '[QueueProcessor] prompt mention cursor ack failed after durable body exposure', + ); + } + } } /** F254 D1.1: queued freshness input scoped to the cat that would process it. */ @@ -1327,7 +2021,7 @@ export class QueueProcessor { userId: string, catId: string, parentInvocationId?: string, - ): Array<{ entryId: string; source: string; content: string; callerCatId?: string; messageId?: string | null }> { + ): Array<{ entryId: string; from: MessageFrom; content: string; messageId?: string | null }> { return this.deps.queue.getQueuedFreshnessMessagesForCat(threadId, userId, catId, { parentInvocationId }); } @@ -1336,113 +2030,25 @@ export class QueueProcessor { * current invocation prompt. This is the prompt-transport analogue of an * explicit full-body get_thread_context read. */ - async markPromptMessagesSeen( - input: PromptMessagesExposedInput, - options: PromptMessagesSeenOptions = {}, - ): Promise { - const exposed = new Set(input.messageIds); - const expectedWitnessMessageIds = new Set(); - let receiptChanged = false; - const candidates: QueueEntry[] = []; - const candidateMessageIds = new Set(); - for (const candidate of this.deps.queue.list(input.threadId, input.userId)) { - const entryMessageIds = this.fullyExposedCandidateMessageIds(candidate, input.catId, exposed); - if (!entryMessageIds) continue; - candidates.push(candidate); - for (const messageId of entryMessageIds) candidateMessageIds.add(messageId); - } - - let adoptedWakes: readonly TurnCustodyWakeProvenance[] = []; - let adoptionReservation: PromptExposureAdoptionReservation | null | undefined; - if (options.prepareAdoption) { - adoptedWakes = await this.resolvePromptMessageCustodyWakes({ - ...input, - messageIds: [...candidateMessageIds], - }); - adoptionReservation = adoptedWakes.length > 0 ? await options.prepareAdoption(adoptedWakes) : undefined; - if (adoptedWakes.length > 0 && !adoptionReservation) { - throw new Error('turn custody adoption owner unavailable before prompt exposure'); - } - } - - try { - for (const candidate of candidates) { - const persisted = await this.persistPromptCandidateSeen(candidate, input); - for (const messageId of persisted.witnessMessageIds) expectedWitnessMessageIds.add(messageId); - receiptChanged = persisted.receiptChanged || receiptChanged; - } - - await this.deps.queueCustodyCoordinator?.assertPromptExposurePersisted( - [...expectedWitnessMessageIds], - input.catId, - input.invocationId, - ); - await this.deps.queueCustodyCoordinator?.assertPromptBodiesNotRecalled( - input.messageIds, + async markPromptMessagesSeen(input: PromptMessagesExposedInput): Promise { + await this.ackPromptMentionCursors(input); + const attempts = this.deps.queue.findAdmittedEntriesForMessages( + input.threadId, + input.messageIds, + input.userId, + input.catId, + ); + for (const entry of attempts) { + const result = await this.deps.queue.markProcessingSeen( + input.threadId, + input.userId, + entry.id, input.catId, input.invocationId, + input.seenAt, ); - if (!options.prepareAdoption) { - adoptedWakes = await this.resolvePromptMessageCustodyWakes({ - ...input, - messageIds: [...expectedWitnessMessageIds], - }); - } - adoptionReservation?.commit(); - } catch (error) { - adoptionReservation?.abort(); - throw error; - } - - if (receiptChanged) { - try { - await emitQueueUpdated( - this.deps.socketManager, - input.userId, - input.threadId, - this.deps.queue.list(input.threadId, input.userId), - this.deps.messageStore, - 'queued_seen', - ); - } catch (error) { - this.deps.log.warn( - { error, threadId: input.threadId, catId: input.catId, invocationId: input.invocationId }, - '[F236] queue exposure persisted but queue_updated projection failed', - ); - } + if (result?.newlySeen) recordQueuedSeenTelemetry(); } - return adoptedWakes; - } - - /** Resolve the structured obligations that must be prepared before exact body exposure is committed. */ - async resolvePromptMessageCustodyWakes( - input: Pick, - ): Promise { - const adoptedManagedHoldWakes: TurnCustodyWakeProvenance[] = []; - for (const messageId of new Set(input.messageIds)) { - const message = await this.deps.messageStore.getById(messageId); - const meta = message?.source?.meta; - const taskId = typeof meta?.taskId === 'string' ? meta.taskId : undefined; - if ( - message?.source?.connector !== 'hold-ball' || - meta?.wakeWhen !== true || - !taskId || - message.threadId !== input.threadId || - meta.threadId !== input.threadId || - meta.catId !== input.catId - ) { - continue; - } - adoptedManagedHoldWakes.push({ - kind: 'structured', - protocol: 'hold', - subjectKey: `ball:thread:${input.threadId}`, - holderCatId: input.catId, - sourceMessageId: messageId, - taskId, - }); - } - return adoptedManagedHoldWakes; } /** @@ -1450,53 +2056,28 @@ export class QueueProcessor { * to prompt exposure. This is intentionally separate from queued_seen. */ async markPromptMessagesAwakened(input: PromptMessagesAwakenedInput): Promise { - const exposed = new Set(input.messageIds); - let receiptChanged = false; - - for (const candidate of this.deps.queue.list(input.threadId, input.userId)) { - if (candidate.status !== 'queued' && candidate.status !== 'processing') continue; - if (!candidate.targetCats.includes(input.catId)) continue; - const entryMessageIds = this.queueEntryMessageIds(candidate); - if (entryMessageIds.length === 0 || entryMessageIds.some((messageId) => !exposed.has(messageId))) continue; - - const changed = this.deps.queue.markQueuedAwakened( - input.threadId, - input.userId, - candidate.id, - input.catId, - input.invocationId, - input.awakenedAt, - ); - if (!changed) continue; - const persistedEntry = this.deps.queue.getEntrySnapshot(input.threadId, input.userId, candidate.id); - if (persistedEntry && this.deps.queueCustodyCoordinator) { - await this.deps.queueCustodyCoordinator.persistEntry(persistedEntry); + const attempts = this.deps.queue.findAdmittedEntriesForMessages( + input.threadId, + input.messageIds, + input.userId, + input.catId, + ); + for (const entry of attempts) { + if ( + !(await this.deps.queue.markProcessingAwakened( + input.threadId, + input.userId, + entry.id, + input.catId, + input.invocationId, + input.awakenedAt, + )) + ) { + throw new Error(`Queue awakened evidence changed before commit: ${entry.id}`); } - receiptChanged = true; - } - - if (receiptChanged) { - await emitQueueUpdated( - this.deps.socketManager, - input.userId, - input.threadId, - this.deps.queue.list(input.threadId, input.userId), - this.deps.messageStore, - 'queued_awakened', - ); } } - /** F254 D1.2b: clear stale retry read evidence before reusing an InvocationRecord id. */ - clearQueuedSeenInvocationForCats(threadId: string, catIds: readonly string[], invocationId: string): number { - return this.deps.queue.clearQueuedSeenInvocationForCats(threadId, catIds, invocationId); - } - - /** F185 Phase B: thin enqueue wrapper for deferred A2A entries from retry/invocations path. */ - enqueueRaw(input: Parameters[0]) { - return this.deps.queue.enqueue(input); - } - /** A2A dedup: check if a specific cat already has a queued or processing entry for this thread. */ hasQueuedAgentForCat(threadId: string, catId: string): boolean { return this.deps.queue.hasQueuedAgentForCat(threadId, catId); @@ -1510,6 +2091,11 @@ export class QueueProcessor { return this.deps.queue.hasPendingForCat(threadId, catId, { userId }); } + /** Process-local proof that an execute coroutine still owns this cat slot. */ + hasProcessingSlotReservation(threadId: string, catId: string): boolean { + return this.processingSlots.has(QueueProcessor.slotKey(threadId, catId)); + } + /** #555: Cat-specific busy check — covers processingSlots + queue entries for this cat. */ isCatBusy(threadId: string, catId: string): boolean { const reservation = this.processingSlots.get(QueueProcessor.slotKey(threadId, catId)); @@ -1517,18 +2103,6 @@ export class QueueProcessor { return this.deps.queue.hasQueuedOrProcessingForCat(threadId, catId); } - /** - * Admission ownership truth for one target slot. Queued leftovers are work, - * not a dequeue trigger; only a live tracker owner or a pre-start processor - * reservation can justify putting newly authored work behind that target. - */ - hasActiveExecutionForCat(threadId: string, catId: string): boolean { - return ( - this.deps.invocationTracker.has(threadId, catId) || - this.processingSlots.has(QueueProcessor.slotKey(threadId, catId)) - ); - } - /** * QueueProcessor slots are keyed only by thread + cat, while both tracker and queue entry * ownership include userId. Terminal recovery may release the non-user-scoped slot only when @@ -1540,7 +2114,7 @@ export class QueueProcessor { if (trackerUserId !== requestUserId) return false; } const processingEntry = this.deps.queue.findProcessingByCat(threadId, catId); - return !processingEntry || processingEntry.userId === requestUserId; + return !processingEntry || queueEntryOwnerId(processingEntry) === requestUserId; } async enqueueContinuation(input: { @@ -1605,18 +2179,18 @@ export class QueueProcessor { return { outcome: 'skipped_existing_entry' }; } - const result = this.deps.queue.enqueue({ + const result = await this.deps.queue.enqueueDurable({ + from: { kind: 'agent', catId }, threadId, userId, + kind: 'private_input', ownerAuthProvenance, content: formatContinuationPrompt(capsule), - source: 'agent', sourceCategory: 'continuation', - continuationKey, + sourceId: continuationKey, targetCats: [catId], intent: 'execute', autoExecute: true, - callerCatId: catId, priority: 'urgent', }); if (result.outcome === 'full' || !result.entry) { @@ -1652,110 +2226,52 @@ export class QueueProcessor { this.continuationWindows.set(key, recent); } - private async persistQueueEntry(entry: QueueEntry | null | undefined): Promise { - if (!entry || !this.deps.queueCustodyCoordinator) return; - await this.deps.queueCustodyCoordinator.persistEntry(entry); - } - - /** Gate 2: the sole attempt-terminal writer for one operational Queue row. */ - private async settleAttemptQueueEntry( - attempted: QueueEntry, - finalStatus: InvocationFinalStatus, - custody: 'durable' | 'legacy_unbound' | 'absent', - durableTerminalOwner: QueueEntryDurableTerminalOwner = { kind: 'none' }, - deferredForBusyTarget = false, - ): Promise<{ requeued: boolean }> { - const current = this.deps.queue.getEntrySnapshot(attempted.threadId, attempted.userId, attempted.id); - const terminalReason = - finalStatus === 'canceled_by_user' - ? 'user_cancel' - : current === null - ? 'superseded' - : finalStatus === 'succeeded' - ? 'succeeded' - : 'system_failure'; - const disposition = resolveQueueEntrySettlement({ - terminalReason, - replacement: { kind: 'none' }, - actionFence: attempted.actionSuccessorFence - ? { - kind: 'action_successor', - leaseId: attempted.actionSuccessorFence.leaseId, - generation: attempted.actionSuccessorFence.generation, - } - : { kind: 'none' }, - durableTerminalOwner, - custody, - }); - - if (deferredForBusyTarget) { - const requeued = this.deps.queue.rollbackProcessing(attempted.threadId, attempted.id); - if (requeued && custody === 'durable') { - await this.persistQueueEntry( - this.deps.queue.getEntrySnapshot(attempted.threadId, attempted.userId, attempted.id), - ); - } - return { requeued }; - } - - if (disposition === 'retain' || disposition === 'transfer') return { requeued: false }; - - // Exact F264 success evidence consumes durable targets immediately after - // executeEntry returns. Restore the same identity only as staging for that - // existing CAS; a failed/missing exact witness leaves it safely queued. - if (disposition === 'consume' && finalStatus === 'succeeded' && custody === 'durable') { - const requeued = this.deps.queue.rollbackProcessing(attempted.threadId, attempted.id); - if (requeued) - await this.persistQueueEntry( - this.deps.queue.getEntrySnapshot(attempted.threadId, attempted.userId, attempted.id), - ); - return { requeued }; + /** Publish the durable source before constructing its response receiver. */ + private async admitQueueEntriesForProvider(entries: readonly QueueEntry[]): Promise { + const primary = entries[0]; + if (!primary) throw new Error('Queue admission requires at least one claimed row'); + const admittedAt = Math.max(Date.now(), ...entries.map((entry) => entry.enqueuedAt)); + const allMessageIds = [...new Set(entries.flatMap((entry) => queueEntryMessageIds(entry)))]; + const newlyPublished = await this.markDeliveredAndEmit( + queueEntryOwnerId(primary), + primary.threadId, + allMessageIds, + admittedAt, + ); + if (newlyPublished.failedIds.length > 0) { + throw new Error(`Queue admission failed to publish History sources: ${newlyPublished.failedIds.join(',')}`); } - - if (disposition === 'rollback') { - const requeued = this.deps.queue.rollbackProcessing(attempted.threadId, attempted.id); - if (requeued && custody === 'durable') { - await this.persistQueueEntry( - this.deps.queue.getEntrySnapshot(attempted.threadId, attempted.userId, attempted.id), - ); + for (const messageId of allMessageIds) { + const message = await this.deps.messageStore.getById(messageId); + if (!message || !isTimelinePublished(message)) { + throw new Error(`Queue admission source did not enter History: ${messageId}`); } - return { requeued }; } + } - const removed = this.deps.queue.removeProcessedAcrossUsers(attempted.threadId, attempted.id); - const settlementEntry = removed ?? (custody === 'durable' ? attempted : null); - if (!settlementEntry || custody !== 'durable' || finalStatus === 'succeeded') return { requeued: false }; - - try { - if (!this.deps.queueCustodyCoordinator) { - throw new Error(`Queue custody coordinator unavailable while consuming ${attempted.id}`); - } - await this.deps.queueCustodyCoordinator.withdrawEntry(settlementEntry); - return { requeued: false }; - } catch (error) { - if (removed) { - const restored: QueueEntry = { ...removed, status: 'queued', processingStartedAt: undefined }; - this.deps.queue.restoreDurableEntry(restored); - try { - await this.persistQueueEntry(restored); - } catch (persistError) { - this.deps.log.error( - { err: persistError, threadId: attempted.threadId, queueEntryId: attempted.id }, - '[QueueProcessor] failed to restore Queue custody after terminal settlement error', - ); - } - } - throw error; - } + /** Forget one admitted process-local attempt after its response reaches terminal. */ + private async settleAttemptQueueEntry(attempted: QueueEntry, finalStatus: InvocationFinalStatus): Promise { + const terminal = + finalStatus === 'succeeded' + ? ({ outcome: 'handled' } as const) + : finalStatus === 'failed' + ? ({ outcome: 'failed', reason: 'invocation_failed' } as const) + : finalStatus === 'canceled_by_user' + ? ({ outcome: 'cancelled', reason: 'invocation_cancelled' } as const) + : ({ outcome: 'interrupted', reason: 'invocation_cancelled' } as const); + const removed = await this.deps.queue.removeProcessedAcrossUsersDurable( + attempted.threadId, + attempted.id, + terminal.outcome, + 'reason' in terminal ? terminal.reason : undefined, + ); + if (!removed) throw new Error(`admitted attempt cleanup lost source entry ${attempted.id}`); } - /** Upgrade old queued messages before provider publication, or classify why that is impossible. */ - private async ensureAttemptMessageCustody(attempted: QueueEntry): Promise<'durable' | 'legacy_unbound' | 'absent'> { - const messageIds = this.queueEntryMessageIds(attempted); + /** Provider admission accepts only exact durable source custody or a source-less internal carrier. */ + private async ensureAttemptMessageCustody(attempted: QueueEntry): Promise<'durable' | 'absent'> { + const messageIds = queueEntryMessageIds(attempted); if (messageIds.length === 0) return 'absent'; - let managed = 0; - let absent = 0; - let legacyUnbound = 0; for (const messageId of messageIds) { let message; @@ -1764,910 +2280,60 @@ export class QueueProcessor { } catch (error) { this.deps.log.warn( { err: error, threadId: attempted.threadId, queueEntryId: attempted.id, messageId }, - '[QueueProcessor] queued source custody lookup failed; treating durable projection as unavailable', + '[QueueProcessor] queued source custody lookup failed; refusing provider admission', ); - absent += 1; - continue; + throw new Error(`queued source custody lookup failed for ${messageId}`, { cause: error }); } if (!message) { - absent += 1; - continue; - } - if (!message.queueCustody && message.deliveryStatus === 'queued') { - const initialized = await this.deps.messageStore.initializeQueueCustody( - messageId, - createInitialQueuedMessageCustody(attempted), - ); - if (initialized.kind === 'initialized' || initialized.kind === 'existing') { - message = initialized.message; - } - } - if (message.queueCustody) { - const targetCarriers = message.queueCustody.carrierByTargetCatId; - const ownsEntry = targetCarriers - ? attempted.targetCats.every((catId) => targetCarriers[catId]?.entryId === attempted.id) - : message.queueCustody.entryId === attempted.id; - if (!ownsEntry || message.queueCustody.status === 'terminal') { - throw new Error(`queued message custody entry mismatch for ${messageId}`); - } - managed += 1; - } else { - legacyUnbound += 1; + throw new Error(`queued source is missing for ${messageId}`); } + if (message.deliveryStatus === 'canceled') throw new Error(`queued source was canceled: ${messageId}`); } - - if (managed > 0 && (absent > 0 || legacyUnbound > 0)) { - throw new Error(`partial queue custody binding for entry ${attempted.id}`); - } - if (managed > 0) return 'durable'; - return legacyUnbound > 0 ? 'legacy_unbound' : 'absent'; - } - - private queueEntryMessageIds(entry: Pick): string[] { - return [entry.messageId ?? '', ...entry.mergedMessageIds].filter(Boolean); + return 'durable'; } - private fullyExposedCandidateMessageIds( - candidate: QueueEntry, + /** + * A crash can leave a queued/claimed target behind after its History receiver + * was committed. Reconcile the whole exact claim before installing a provider + * reservation: stale members retire, while unaffected siblings are restored. + */ + private async reconcileClaimedGroupWithExistingReceiver( + entries: readonly QueueEntry[], targetCatId: string, - exposed: ReadonlySet, - ): string[] | null { - if (candidate.status !== 'queued' && candidate.status !== 'processing') return null; - if (!candidate.targetCats.includes(targetCatId)) return null; - const messageIds = this.queueEntryMessageIds(candidate); - return messageIds.length > 0 && messageIds.every((messageId) => exposed.has(messageId)) ? messageIds : null; - } - - private async persistPromptCandidateSeen( - candidate: QueueEntry, - input: PromptMessagesExposedInput, - ): Promise<{ receiptChanged: boolean; witnessMessageIds: string[] }> { - const before = this.deps.queue.getEntrySnapshot(input.threadId, input.userId, candidate.id); - if (candidate.status === 'queued') { - this.deps.queue.markQueuedSeen( - input.threadId, - input.userId, - candidate.id, - input.catId, - input.invocationId, - input.seenAt, - ); - } else { - this.deps.queue.markProcessingSeen( - input.threadId, - input.userId, - candidate.id, - [input.catId], - input.invocationId, - input.seenAt, - ); - } - const persisted = this.deps.queue.getEntrySnapshot(input.threadId, input.userId, candidate.id); - const newlySeen = - !(before?.queuedSeenByCatIds ?? []).includes(input.catId) && - (persisted?.queuedSeenByCatIds ?? []).includes(input.catId); - const exposureChanged = - !(before?.queuedBodyExposures ?? []).some( - (exposure) => exposure.targetCatId === input.catId && exposure.invocationId === input.invocationId, - ) && - (persisted?.queuedBodyExposures ?? []).some( - (exposure) => exposure.targetCatId === input.catId && exposure.invocationId === input.invocationId, - ); - const evidenceChanged = - before?.queuedSeenInvocationIdByCatId?.[input.catId] !== persisted?.queuedSeenInvocationIdByCatId?.[input.catId]; - let reminderSeen = false; - let witnessMessageIds: string[] = []; - if (persisted && this.deps.queueCustodyCoordinator) { - witnessMessageIds = await this.deps.queueCustodyCoordinator.persistEntry(persisted); - reminderSeen = await this.deps.queueCustodyCoordinator.markReminderSeen( - persisted, - input.catId, - input.invocationId, - ); - } - if (newlySeen) recordQueuedSeenTelemetry(); - return { - receiptChanged: newlySeen || evidenceChanged || exposureChanged || reminderSeen, - witnessMessageIds, - }; - } - - private async hasDurableMessageCustody(entry: QueueEntry): Promise { - let managed = 0; - let unmanaged = 0; - for (const messageId of this.queueEntryMessageIds(entry)) { - let message; - try { - message = await this.deps.messageStore.getById(messageId); - } catch (err) { - if (this.deps.queueCustodyCoordinator) throw err; - unmanaged += 1; - continue; - } - if (!message?.queueCustody) { - unmanaged += 1; - continue; - } - const targetCarriers = message.queueCustody.carrierByTargetCatId; - if (targetCarriers) { - if (entry.targetCats.some((catId) => targetCarriers[catId]?.entryId !== entry.id)) { - throw new Error(`queued message per-target carrier mismatch for ${messageId}`); - } - } else if (message.queueCustody.entryId !== entry.id) { - throw new Error(`queued message custody entry mismatch for ${messageId}`); - } - if (message.queueCustody.status === 'terminal') { - throw new Error(`terminal queue custody still has a live Queue entry: ${entry.id}`); - } - managed += 1; - } - if (managed > 0 && unmanaged > 0) { - throw new Error(`partial queue custody binding for entry ${entry.id}`); - } - return managed > 0; - } - - private async resolveQueueTargetOutcome( - threadId: string, - handled: QueuedHandledResult, - catId: string, - invocationId: string, - handledAt: number, - consumption?: QueueTerminalConsumptionWitness, - ): Promise { - let disposition: QueueTargetOutcome['disposition'] = - consumption?.kind === 'managed_hold_continued' ? 'managed_hold_disposition' : 'completed_with_turn'; - let hasPersistedLineage = false; - try { - const getByThreadAfter = this.deps.messageStore.getByThreadAfter?.bind(this.deps.messageStore); - if (getByThreadAfter) { - const messages = await getByThreadAfter(threadId, undefined, undefined, handled.userId); - const handledMessageIds = new Set(handled.messageIds); - hasPersistedLineage = messages.some((message) => ownsPersistedInvocationLineage(message, invocationId)); - const hasExplicitReply = messages.some((message) => { - const sameInvocation = ownsPersistedInvocationLineage(message, invocationId); - return ( - message.catId === catId && sameInvocation && !!message.replyTo && handledMessageIds.has(message.replyTo) - ); - }); - if (hasExplicitReply && consumption?.kind !== 'managed_hold_continued') disposition = 'responded'; - } - } catch (err) { - this.deps.log.warn( - { err, threadId, catId, invocationId, queueEntryId: handled.entryId }, - '[F264] visible lineage evidence scan failed; using terminal TurnExecution truth', - ); - } - return { - invocationId, - disposition, - evidenceRef: { kind: hasPersistedLineage ? 'invocation_lineage' : 'turn_execution', invocationId }, - handledAt, - ...(consumption ? { consumption } : {}), - }; - } - - private async closeReminderAttemptsOnInvocationTerminal( - threadId: string, - catId: string, - invocationId: string, - ): Promise { - const coordinator = this.deps.queueCustodyCoordinator; - if (!coordinator) return; - for (const userId of this.deps.queue.listUsersForThread(threadId)) { - let changed = false; - for (const entry of this.deps.queue.list(threadId, userId)) { - if (!entry.targetCats.includes(catId)) continue; - try { - changed = (await coordinator.markReminderMissed(entry, invocationId)) || changed; - } catch (err) { - this.deps.log.warn( - { err, threadId, catId, invocationId, queueEntryId: entry.id }, - '[F264] failed to terminalize reminder attempt', - ); - } - } - if (changed) { - await emitQueueUpdated( - this.deps.socketManager, - userId, - threadId, - this.deps.queue.list(threadId, userId), - this.deps.messageStore, - 'reminder_missed', - ); - } - } - } - - private collectBoundQueueExecutions(threadId: string, catId: string): BoundQueueExecution[] { - const userIdsByInvocationId = new Map>(); - for (const userId of this.deps.queue.listUsersForThread(threadId)) { - for (const entry of this.deps.queue.list(threadId, userId)) { - if (entry.status !== 'queued' && entry.status !== 'processing') continue; - if (!entry.targetCats.includes(catId)) continue; - const invocationId = entry.queuedSeenInvocationIdByCatId?.[catId]; - if (!invocationId) continue; - const userIds = userIdsByInvocationId.get(invocationId) ?? new Set(); - userIds.add(userId); - userIdsByInvocationId.set(invocationId, userIds); - } - } - return [...userIdsByInvocationId].map(([invocationId, userIds]) => ({ invocationId, userIds })); - } - - private collectBoundQueueCatIds(threadId: string): string[] { - const catIds = new Set(); - for (const userId of this.deps.queue.listUsersForThread(threadId)) { - for (const entry of this.deps.queue.list(threadId, userId)) { - if (entry.status !== 'queued' && entry.status !== 'processing') continue; - for (const catId of Object.keys(entry.queuedSeenInvocationIdByCatId ?? {})) { - if (entry.targetCats.includes(catId)) catIds.add(catId); - } - } - } - return [...catIds]; - } - - private async resolveBoundQueueExecutionsForParent(input: { - threadId: string; - catId: string; - parentInvocationId?: string; - preferredInvocationId?: string; - fallbackStatus: 'succeeded' | 'failed'; - }): Promise { - const bound = this.collectBoundQueueExecutions(input.threadId, input.catId); - if (bound.length === 0) return { records: [], unresolvedInvocationIds: [] }; - - if (!this.deps.turnExecutionStore) { - const exactInvocationId = input.preferredInvocationId ?? input.parentInvocationId; - return { - records: exactInvocationId - ? bound - .filter((candidate) => candidate.invocationId === exactInvocationId) - .map((candidate) => ({ invocationId: candidate.invocationId, status: input.fallbackStatus })) - : [], - unresolvedInvocationIds: [], - }; - } - - if (!input.parentInvocationId) { - return { records: [], unresolvedInvocationIds: bound.map((candidate) => candidate.invocationId) }; - } - - const records: ResolvedBoundQueueExecutions['records'] = []; - const unresolvedInvocationIds: string[] = []; - for (const candidate of bound) { - try { - const record = await this.deps.turnExecutionStore.get(candidate.invocationId); - if (!record) { - unresolvedInvocationIds.push(candidate.invocationId); - continue; - } - if (record.parentInvocationId !== input.parentInvocationId) continue; - if ( - record.threadId !== input.threadId || - record.catId !== input.catId || - candidate.userIds.size !== 1 || - !candidate.userIds.has(record.userId) - ) { - unresolvedInvocationIds.push(candidate.invocationId); - continue; - } - records.push({ invocationId: record.invocationId, status: record.status }); - } catch (err) { - this.deps.log.error( - { - err, - threadId: input.threadId, - catId: input.catId, - parentInvocationId: input.parentInvocationId, - childInvocationId: candidate.invocationId, - }, - '[QueueProcessor] exact Queue receipt ledger resolution failed', - ); - unresolvedInvocationIds.push(candidate.invocationId); - } - } - return { records, unresolvedInvocationIds }; - } - - private async settleBoundQueueTargetsByChildTruth(input: { - threadId: string; - catId: string; - parentInvocationId?: string; - preferredInvocationId?: string; - terminalConsumptionByInvocationId?: Readonly>; - terminalReason?: 'invocation_failed' | 'invocation_cancelled'; - fallbackStatus: 'succeeded' | 'failed'; - }): Promise { - const resolved = await this.resolveBoundQueueExecutionsForParent(input); - let blocked = resolved.unresolvedInvocationIds.length > 0; - - for (const record of resolved.records) { - if (record.status === 'succeeded') { - await this.markQueuedHandledOnSuccess( - input.threadId, - input.catId, - record.invocationId, - input.terminalConsumptionByInvocationId?.[record.invocationId], - ); - continue; - } - blocked = true; - if (record.status !== 'running') { - await this.markQueuedFailedOnFailure( - input.threadId, - input.catId, - record.invocationId, - [], - input.terminalReason, - ); - } - } + ): Promise { + const hasExistingReceiver = await this.claimGroupHasExistingReceiver(entries, targetCatId); + if (!hasExistingReceiver) return false; - const resolvedIds = new Set(resolved.records.map((record) => record.invocationId)); + const entryIds = entries.map((entry) => entry.id); if ( - this.collectBoundQueueExecutions(input.threadId, input.catId).some((candidate) => - resolvedIds.has(candidate.invocationId), - ) + !(await this.deps.queue.reconcileClaimedLifecycleTargets(entries[0]!.threadId, entryIds, this.deps.messageStore)) ) { - blocked = true; - } - return blocked; - } - - private async reconcileQueueCarrierAfterSourceSettlement(entry: QueueEntry): Promise { - for (let attempt = 0; attempt < 3; attempt += 1) { - const current = this.deps.queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - if (!current) return true; - const messages = await readQueueCarrierMessages(current, this.deps.messageStore); - const projected = projectUnconsumedQueueCarrier(current, messages); - const changed = - projected === null - ? this.deps.queue.removeEntrySnapshotIfUnchanged(current) - : this.deps.queue.restoreEntrySnapshotIfUnchanged(current, projected); - if (changed) return true; - } - return false; - } - - /** - * A later failed/canceled child cannot resurrect Queue sources which that - * exact child already answered durably. Only explicit per-source reply/causal - * refs qualify; all ambiguous exposed sources remain Queue-owned. - */ - private async settleExactSourceResponsesBeforeFailure(input: { - threadId: string; - catId: string; - invocationId: string; - }): Promise { - const coordinator = this.deps.queueCustodyCoordinator; - if (!coordinator) return; - for (const userId of this.deps.queue.listUsersForThread(input.threadId)) { - let changed = false; - for (const entry of this.deps.queue.list(input.threadId, userId)) { - if ( - entry.status !== 'queued' || - !entry.targetCats.includes(input.catId) || - entry.queuedSeenInvocationIdByCatId?.[input.catId] !== input.invocationId - ) { - continue; - } - const sourceMessageIds = this.queueEntryMessageIds(entry); - try { - const evidence = await resolveQueueSourceResponseEvidence({ - messageStore: this.deps.messageStore, - threadId: input.threadId, - userId, - catId: input.catId, - invocationId: input.invocationId, - sourceMessageIds, - }); - if (evidence.length === 0) continue; - const deliveredAt = Date.now(); - const outcomeByMessageId = Object.fromEntries( - evidence.map(({ sourceMessageId, witness }) => [ - sourceMessageId, - { - [input.catId]: { - invocationId: input.invocationId, - disposition: 'responded' as const, - evidenceRef: { kind: 'invocation_lineage' as const, invocationId: input.invocationId }, - handledAt: deliveredAt, - consumption: witness, - }, - }, - ]), - ); - const settlement = await coordinator.commitSuccessfulTargetsForMessages( - entry, - evidence.map((item) => item.sourceMessageId), - [input.catId], - input.invocationId, - deliveredAt, - outcomeByMessageId, - ); - const unmatched = settlement.perMessage.filter((message) => !message.handledTargetCats.includes(input.catId)); - if (unmatched.length > 0) { - throw new Error(`source response evidence did not match exact exposure for ${unmatched[0]?.messageId}`); - } - const fullyConsumedMessageIds = settlement.perMessage - .filter((message) => message.fullyConsumed) - .map((message) => message.messageId); - if (fullyConsumedMessageIds.length > 0) { - await this.markDeliveredAndEmit( - userId, - input.threadId, - fullyConsumedMessageIds, - deliveredAt, - new Set(fullyConsumedMessageIds), - ); - } - if (!(await this.reconcileQueueCarrierAfterSourceSettlement(entry))) { - throw new Error(`Queue carrier ${entry.id} changed during source response settlement`); - } - changed = true; - for (const message of settlement.perMessage) { - await this.retireConsumedCoordinationTerminal(message.messageId); - } - } catch (err) { - this.deps.log.error( - { - err, - threadId: input.threadId, - catId: input.catId, - invocationId: input.invocationId, - queueEntryId: entry.id, - }, - '[F264] exact source response settlement failed closed', - ); - } - } - if (changed) { - await emitQueueUpdated( - this.deps.socketManager, - userId, - input.threadId, - this.deps.queue.list(input.threadId, userId), - this.deps.messageStore, - 'source_response_settled', - ); - } - } - } - - /** - * True recall removes the operational Queue carrier while an already-exposed - * child is allowed to finish. Settle that later terminal witness directly - * against the content-free source, keyed only by the exact child exposure. - */ - private async settleDetachedExactSourceOnTerminal(input: { - threadId: string; - catId: string; - invocationId: string; - status: 'succeeded' | 'failed' | 'canceled' | 'canceled_by_user'; - consumptions?: QueueTerminalConsumptionCollection; - }): Promise { - const coordinator = this.deps.queueCustodyCoordinator; - if (!coordinator) return; - let messages: StoredMessage[]; - try { - messages = await this.deps.messageStore.getByQueueExposure(input.threadId, input.catId, input.invocationId); - } catch (err) { - this.deps.log.error( - { err, threadId: input.threadId, catId: input.catId, invocationId: input.invocationId }, - '[F264] detached exact-source scan failed closed', - ); - return; - } - - let sourceResponseByMessageId: Map< - string, - ReturnType[number] - >; - let hasPersistedLineage = false; - try { - const readThread = this.deps.messageStore.getByThreadAfter?.bind(this.deps.messageStore); - const threadMessages = readThread ? await readThread(input.threadId) : []; - hasPersistedLineage = threadMessages.some((message) => - ownsPersistedInvocationLineage(message, input.invocationId), - ); - sourceResponseByMessageId = new Map( - resolveQueueSourceResponseEvidenceFromMessages({ - messages: threadMessages, - catId: input.catId, - invocationId: input.invocationId, - sourceMessageIds: messages.map((message) => message.id), - }).map((evidence) => [evidence.sourceMessageId, evidence]), - ); - } catch (err) { - this.deps.log.error( - { err, threadId: input.threadId, catId: input.catId, invocationId: input.invocationId }, - '[F264] detached exact-source response hydration failed closed', - ); - return; - } - - const targetCatId = input.catId as CatId; - for (const message of messages) { - const custody = message.queueCustody; - const exposure = custody?.bodyExposures?.find( - (candidate) => candidate.targetCatId === input.catId && candidate.invocationId === input.invocationId, - ); - if ( - !custody || - !exposure || - custody.handledByCatIds.includes(targetCatId) || - (!custody.pendingTargetCats.includes(targetCatId) && !custody.withdrawnByCatIds?.includes(targetCatId)) - ) { - continue; - } - - try { - const sourceResponse = sourceResponseByMessageId.get(message.id); - if (input.status !== 'succeeded' && !sourceResponse) continue; - - const handledAt = Math.max( - Date.now(), - exposure.seenAt + 1, - (custody.withdrawnAtByCatId?.[input.catId] ?? 0) + 1, - (message.recall?.recalledAt ?? 0) + 1, - ); - const exactTerminalConsumption = queueTerminalConsumptionForMessage( - normalizeQueueTerminalConsumptions(input.consumptions), - message.id, - ); - const outcome: QueueTargetOutcome = { - invocationId: input.invocationId, - disposition: - exactTerminalConsumption?.kind === 'managed_hold_continued' - ? 'managed_hold_disposition' - : sourceResponse - ? 'responded' - : 'completed_with_turn', - evidenceRef: { - kind: sourceResponse || hasPersistedLineage ? 'invocation_lineage' : 'turn_execution', - invocationId: input.invocationId, - }, - handledAt, - ...(sourceResponse - ? { consumption: sourceResponse.witness } - : exactTerminalConsumption - ? { consumption: exactTerminalConsumption } - : {}), - }; - const completion = await coordinator.commitSuccessfulTargetForMessage( - custody.entryId, - message.id, - input.catId, - input.invocationId, - handledAt, - outcome, - () => - !this.deps.queue - .list(input.threadId, message.userId) - .some( - (entry) => entry.id === carrierEntryId(custody, input.catId) && entry.targetCats.includes(input.catId), - ), - ); - if (completion.handledTargetCats.includes(targetCatId)) { - const physicalId = carrierEntryId(custody, input.catId); - const carrier = physicalId ? this.deps.queue.getEntrySnapshotForUserById(message.userId, physicalId) : null; - if (carrier) await this.reconcileQueueCarrierAfterSourceSettlement(carrier); - await this.retireConsumedCoordinationTerminal(message.id); - this.deps.socketManager.broadcastToRoom(`thread:${input.threadId}`, 'message_receipt_updated', { - threadId: input.threadId, - messageId: message.id, - }); - } - } catch (err) { - this.deps.log.error( - { - err, - threadId: input.threadId, - catId: input.catId, - invocationId: input.invocationId, - messageId: message.id, - }, - '[F264] detached exact-source settlement failed closed', - ); - } - } - } - - private async markQueuedHandledOnSuccess( - threadId: string, - catId: string, - invocationId?: string, - consumptionInput?: QueueTerminalConsumptionCollection, - ): Promise { - if (!invocationId) return; - const consumptions = normalizeQueueTerminalConsumptions(consumptionInput); - const handled = this.deps.queue.markQueuedHandledForCatAcrossUsers(threadId, catId, invocationId); - if (handled.length === 0) return; - - const deliveredAt = Date.now(); - const touchedUsers = new Set(); - const committedHandled: QueuedHandledResult[] = []; - const outcomeByEntryId = new Map(); - - for (const h of handled) { - const outcomeByMessageId = new Map(); - for (const messageId of h.messageIds) { - const exactConsumption = queueTerminalConsumptionForMessage(consumptions, messageId); - outcomeByMessageId.set( - messageId, - await this.resolveQueueTargetOutcome( - threadId, - { ...h, messageIds: [messageId] }, - catId, - invocationId, - deliveredAt, - exactConsumption, - ), - ); - } - const outcome = - outcomeByMessageId.values().next().value ?? - (await this.resolveQueueTargetOutcome(threadId, h, catId, invocationId, deliveredAt)); - let durablyCustodied = false; - try { - durablyCustodied = h.entrySnapshot ? await this.hasDurableMessageCustody(h.entrySnapshot) : false; - } catch (err) { - this.deps.queue.restoreQueuedHandledResult(h); - this.deps.log.error( - { err, threadId, catId, queueEntryId: h.entryId }, - '[QueueProcessor] queue custody binding invalid; restored queued entry', - ); - continue; - } - - if (durablyCustodied) { - if (!h.entrySnapshot || !this.deps.queueCustodyCoordinator) { - this.deps.queue.restoreQueuedHandledResult(h); - this.deps.log.error( - { threadId, catId, queueEntryId: h.entryId }, - '[QueueProcessor] durable Queue custody coordinator unavailable; restored queued entry', - ); - continue; - } - try { - const settlement = await this.deps.queueCustodyCoordinator.commitSuccessfulTargetsForMessages( - h.entrySnapshot, - h.messageIds, - [catId], - invocationId, - deliveredAt, - Object.fromEntries( - h.messageIds.map((messageId) => [messageId, { [catId]: outcomeByMessageId.get(messageId) ?? outcome }]), - ), - ); - const handledMessages = settlement.perMessage.filter((message) => message.handledTargetCats.includes(catId)); - if (handledMessages.length !== settlement.perMessage.length) { - this.deps.queue.restoreQueuedHandledResult(h); - await this.reconcileQueueCarrierAfterSourceSettlement(h.entrySnapshot); - this.deps.log.error( - { - threadId, - catId, - queueEntryId: h.entryId, - invocationId, - unmatchedMessageIds: settlement.perMessage - .filter((message) => !message.handledTargetCats.includes(catId)) - .map((message) => message.messageId), - }, - '[QueueProcessor] exact success evidence did not cover the complete Queue carrier; restored queued entry', - ); - continue; - } - const fullyConsumedMessageIds = handledMessages - .filter((message) => message.fullyConsumed) - .map((message) => message.messageId); - if (fullyConsumedMessageIds.length > 0) { - const delivery = await this.markDeliveredAndEmit( - h.userId, - threadId, - fullyConsumedMessageIds, - deliveredAt, - new Set(fullyConsumedMessageIds), - ); - if (delivery.failedIds.length > 0) { - this.deps.log.error( - { threadId, catId, queueEntryId: h.entryId, failedIds: delivery.failedIds }, - '[QueueProcessor] terminal custody committed but delivery socket hydration failed', - ); - } - } - } catch (err) { - this.deps.queue.restoreQueuedHandledResult(h); - // A multi-source commit may have succeeded partially, or a later - // publication may have failed. Never restore already-consumed bodies. - try { - await this.reconcileQueueCarrierAfterSourceSettlement(h.entrySnapshot); - } catch (reconcileErr) { - this.deps.log.error( - { err: reconcileErr, threadId, catId, queueEntryId: h.entryId }, - '[QueueProcessor] failed to reconcile carrier from committed source outcomes', - ); - } - this.deps.log.error( - { err, threadId, catId, queueEntryId: h.entryId }, - '[QueueProcessor] Queue custody success commit failed; restored queued entry', - ); - continue; - } - } else if (h.fullyConsumed && h.messageIds.length > 0) { - const delivery = await this.markDeliveredAndEmit(h.userId, threadId, h.messageIds, deliveredAt); - if (delivery.failedIds.length > 0) { - this.deps.queue.restoreQueuedHandledResult(h); - this.deps.log.warn( - { threadId, catId, queueEntryId: h.entryId, failedIds: delivery.failedIds }, - '[QueueProcessor] F254 D1.2b delivery persistence failed; restored queued entry', - ); - continue; - } - } - - committedHandled.push(h); - outcomeByEntryId.set(h.entryId, outcome); - // Ball retirement is downstream of message truth. Its failure cannot - // veto the receipt or reintroduce a provider-executable Queue carrier. - if (durablyCustodied) { - for (const messageId of h.messageIds) { - await this.retireConsumedCoordinationTerminal(messageId); - } - } - } - - if (committedHandled.length === 0) return; - - for (const h of committedHandled) { - const reservationId = h.entrySnapshot?.exactSteerBatch?.reservationId; - if (reservationId) this.deps.queue.pruneExactUserBatchReservation(reservationId); - } - - for (const h of committedHandled) { - recordQueuedHandledTelemetry({ fullyConsumed: h.fullyConsumed }); - touchedUsers.add(h.userId); - - if (this.deps.freshnessEventLog) { - try { - const outcome = outcomeByEntryId.get(h.entryId); - const terminalOutcome = outcome ?? { - disposition: 'completed_with_turn' as const, - evidenceRef: { kind: 'turn_execution' as const, invocationId }, - }; - await this.deps.freshnessEventLog.append( - { - kind: 'queued_handled', - threadId, - catId: catId as CatId, - invocationId, - timestamp: Date.now(), - queueEntryId: h.entryId, - messageIds: h.messageIds, - disposition: terminalOutcome.disposition, - evidenceRef: terminalOutcome.evidenceRef, - remainingTargetCats: h.remainingTargetCats, - }, - { ownerUserId: h.userId }, - ); - await this.deps.freshnessEventLog.markProviderNoticesHandled({ - ownerUserId: h.userId, - invocationId, - catId: catId as CatId, - queueEntryId: h.entryId, - messageIds: h.messageIds, - evidenceRef: terminalOutcome.evidenceRef, - }); - } catch (err) { - this.deps.log.warn( - { err, threadId, catId, queueEntryId: h.entryId }, - '[QueueProcessor] F254 D1.2b queued_handled event append failed', - ); - } - } - } - - for (const userId of touchedUsers) { - try { - await emitQueueUpdated( - this.deps.socketManager, - userId, - threadId, - this.deps.queue.list(threadId, userId), - this.deps.messageStore, - 'queued_handled', - ); - } catch (err) { - this.deps.log.warn({ err, threadId, userId, catId }, '[QueueProcessor] F254 D1.2b queue update failed'); - } - } - } - - private async retireConsumedCoordinationTerminal(messageId: string): Promise { - try { - await retireConsumedCoordinationTerminal( - messageId, - this.deps.messageStore, - this.deps.a2aDispatchDispositionService, - ); - } catch (err) { - this.deps.log.warn( - { err, messageId }, - '[QueueProcessor] consumed coordination retirement deferred; source outcome remains authoritative', - ); - } - } - - private async markQueuedFailedOnFailure( - threadId: string, - catId: string, - invocationId: string, - attemptedEntryIds: readonly string[] = [], - terminalReason: 'invocation_failed' | 'invocation_cancelled' = 'invocation_failed', - ): Promise { - const failed = this.deps.queue.markQueuedFailedForCatAcrossUsers( - threadId, - catId, - invocationId, - new Set(attemptedEntryIds), - terminalReason, - ); - if (failed.length === 0) return; - for (const entry of failed) { - try { - await this.persistQueueEntry(this.deps.queue.getEntrySnapshot(threadId, entry.userId, entry.entryId)); - } catch (err) { - this.deps.log.error( - { err, threadId, catId, queueEntryId: entry.entryId }, - '[QueueProcessor] queued_failed custody persistence failed', - ); - } - } - for (const userId of new Set(failed.map((entry) => entry.userId))) { - try { - await emitQueueUpdated( - this.deps.socketManager, - userId, - threadId, - this.deps.queue.list(threadId, userId), - this.deps.messageStore, - 'queued_failed', - ); - } catch (err) { - this.deps.log.warn({ err, threadId, userId, catId }, '[QueueProcessor] queued_failed update failed'); - } + throw new Error(`stale Queue lifecycle reconciliation did not converge: ${entryIds.join(',')}`); } + return true; } - private async fallbackAuthorIntentsForTerminalParent( - threadId: string, - catId: string, - parentInvocationId: string | undefined, - ): Promise { - if (!parentInvocationId) return; - const changed = this.deps.queue.fallbackAuthorIntentsForParentAcrossUsers( - threadId, - catId, - parentInvocationId, - Date.now(), - ); - if (changed.length === 0) return; - for (const entry of changed) { - await this.persistQueueEntry(this.deps.queue.getEntrySnapshot(threadId, entry.userId, entry.entryId)); + private async claimGroupHasExistingReceiver(entries: readonly QueueEntry[], targetCatId: string): Promise { + for (const entry of entries) { + for (const messageId of queueEntryMessageIds(entry)) { + const source = await this.deps.messageStore.getById(messageId); + if (source?.lifecycle?.kind !== 'input' && source?.lifecycle?.kind !== 'response') continue; + if (source.lifecycle.dispatchRefs?.some((ref) => ref.targetId === targetCatId)) { + return true; + } + } } - for (const userId of new Set(changed.map((entry) => entry.userId))) { - await emitQueueUpdated( - this.deps.socketManager, - userId, - threadId, - this.deps.queue.list(threadId, userId), - this.deps.messageStore, - 'author_intent_fallback', - ); + return false; + } + + private async firstTargetWithExistingReceiver( + entries: readonly QueueEntry[], + targetCatIds: readonly string[], + ): Promise { + for (const targetCatId of targetCatIds) { + if (await this.claimGroupHasExistingReceiver(entries, targetCatId)) return targetCatId; } + return undefined; } private async markDeliveredAndEmit( @@ -2682,6 +2348,7 @@ export class QueueProcessor { const deliveredMessages: Array<{ id: string; content: string; + lifecycle?: import('@cat-cafe/shared').LifecycleStoredMessageMetadata; catId: string | null; timestamp: number; timelineOrderAt?: number; @@ -2718,13 +2385,12 @@ export class QueueProcessor { /* best-effort: preview failure must not drop the delivered message */ } } - const projectedExtra = { - ...(result.extra ?? {}), - ...(result.queueCustody ? { queueReceipt: projectQueueReceipt(result.queueCustody) } : {}), - }; + const projectedExtra = result.extra ?? {}; deliveredMessages.push({ id: result.id, + ...(result.from ? { from: result.from } : {}), content: result.content, + ...(result.lifecycle ? { lifecycle: result.lifecycle } : {}), catId: result.catId, timestamp: result.timestamp, ...(result.timelineOrderAt !== undefined ? { timelineOrderAt: result.timelineOrderAt } : {}), @@ -2753,6 +2419,27 @@ export class QueueProcessor { return { transitionedIds: deliveredIds, failedIds }; } + /** Publish one exact same-id lifecycle snapshot; clients upsert without inventing state. */ + private emitLifecycleMessageUpdated(userId: string, message: StoredMessage): void { + if (!message.lifecycle) return; + this.deps.socketManager.emitToUser(userId, 'message_lifecycle_updated', { + threadId: message.threadId, + message: { + id: message.id, + ...(message.from ? { from: message.from } : {}), + catId: message.catId, + content: message.content, + lifecycle: message.lifecycle, + timestamp: message.timestamp, + ...(message.timelineOrderAt !== undefined ? { timelineOrderAt: message.timelineOrderAt } : {}), + ...(message.contentBlocks ? { contentBlocks: message.contentBlocks } : {}), + ...(message.extra ? { extra: message.extra } : {}), + ...(message.origin ? { origin: message.origin } : {}), + ...(message.replyTo ? { replyTo: message.replyTo } : {}), + }, + }); + } + private async cancelMessageIds(messageIds: readonly string[], log: LoggerLike, reason: string): Promise { for (const messageId of new Set(messageIds.filter(Boolean))) { try { @@ -2778,16 +2465,6 @@ export class QueueProcessor { return false; } - /** Active execution only; queued leftovers are not enough to keep new broadcasts in queue mode. */ - hasActiveExecution(threadId: string): boolean { - if (this.deps.invocationTracker.has(threadId)) return true; - for (const [key, reservation] of this.processingSlots) { - if (!QueueProcessor.slotMatchesThread(key, threadId)) continue; - if (reservation) return true; - } - return false; - } - /** F151: Signal streaming adapters that delivery is done for this thread invocation. * Fires on both success AND failure — failed invocations must close the task * immediately instead of waiting for TASK_TIMEOUT_MS (P2-1 review fix). */ @@ -2799,54 +2476,6 @@ export class QueueProcessor { }); } - /** Returns pause reason when paused; otherwise undefined. */ - getPauseReason(threadId: string, catId?: string): 'canceled' | 'failed' | undefined { - if (!this.isPaused(threadId, catId)) return undefined; - if (catId) { - return this.pausedSlots.get(QueueProcessor.slotKey(threadId, catId)); - } - // Backward compat: return first paused slot's reason - for (const [key, reason] of this.pausedSlots.entries()) { - if (QueueProcessor.slotMatchesThread(key, threadId)) return reason; - } - return undefined; - } - - /** #595: auto-recovery delay for failed/canceled slots (ms) */ - private static readonly PAUSE_RECOVERY_DELAY_MS = 10_000; - - private schedulePausedSlotRecovery( - threadId: string, - catId: string, - status: 'canceled' | 'failed', - epoch: number, - invocationId: string | undefined, - delayMs = QueueProcessor.PAUSE_RECOVERY_DELAY_MS, - ): void { - const sk = QueueProcessor.slotKey(threadId, catId); - setTimeout(() => { - if (this.pauseEpoch.get(sk) !== epoch || this.pausedSlots.get(sk) !== status) return; - if (invocationId !== undefined && this.hasReplacementExecutionOwner(threadId, catId, invocationId)) return; - - const suppressionRemainingMs = this.autoResumeSuppressionRemainingMs(sk); - if (suppressionRemainingMs > 0) { - this.schedulePausedSlotRecovery(threadId, catId, status, epoch, invocationId, suppressionRemainingMs); - return; - } - - this.pausedSlots.delete(sk); - this.deps.log.info( - { threadId, catId, status }, - '[QueueProcessor] Auto-recovering paused slot after timeout (#595)', - ); - if (this.hasDispatchableQueuedForThread(threadId)) { - void this.tryExecuteNextAcrossUsers(threadId, catId, { onlyTargetCat: true }).catch((err) => { - this.deps.log.error({ err, threadId, catId }, '[QueueProcessor] Auto-recovery dequeue failed'); - }); - } - }, delayMs); - } - /** * Retire only projections owned by the exact parent execution. This is the shared * fence for explicit reaping and terminal cleanup; a replacement on one cat never @@ -2948,240 +2577,48 @@ export class QueueProcessor { * System-level entry: called when an invocation completes. * F108: Now slot-aware — catId identifies which slot completed. * - succeeded → auto-dequeue oldest across users - * - canceled/failed → pause slot, notify users, auto-recover after delay + * - canceled/failed → settle exact attempt evidence, then drain any remaining work */ async onInvocationComplete( threadId: string, catId: string, status: 'succeeded' | 'failed' | 'canceled' | 'canceled_by_user', - invocationId: string | undefined, - completedCatIds: readonly string[], - primaryEntryRequeued = false, - terminalInvocationIdByCatId: Readonly> = {}, - attemptedQueueEntryIds: readonly string[] = [], - terminalConsumptionByInvocationId: Readonly> = {}, - suppressAutomaticFollowUp = false, + invocationId?: string, + _completedCatIds: readonly string[] = [], + options: { + suppressAutomaticDrain?: boolean; + attemptedQueueEntryIds?: readonly string[]; + suppressAutomaticFollowUp?: boolean; + } = {}, ): Promise { + const { suppressAutomaticDrain = false, attemptedQueueEntryIds = [], suppressAutomaticFollowUp = false } = options; const sk = QueueProcessor.slotKey(threadId, catId); const isSuperseded = (candidateCatId: string): boolean => invocationId !== undefined && this.hasReplacementExecutionOwner(threadId, candidateCatId, invocationId); - if (invocationId) { - for (const completedCatId of new Set(completedCatIds.length > 0 ? completedCatIds : [catId])) { - await this.closeReminderAttemptsOnInvocationTerminal( - threadId, - completedCatId, - terminalInvocationIdByCatId[completedCatId] ?? invocationId, - ); - } - } - if (status !== 'succeeded' && invocationId) { - const failedCatIds = new Set([catId, ...completedCatIds, ...this.collectBoundQueueCatIds(threadId)]); - for (const failedCatId of failedCatIds) { - if (isSuperseded(failedCatId)) continue; - const preferredInvocationId = terminalInvocationIdByCatId[failedCatId] ?? invocationId; - await this.settleExactSourceResponsesBeforeFailure({ - threadId, - catId: failedCatId, - invocationId: preferredInvocationId, - }); - await this.settleDetachedExactSourceOnTerminal({ - threadId, - catId: failedCatId, - invocationId: preferredInvocationId, - status, - }); - await this.settleBoundQueueTargetsByChildTruth({ - threadId, - catId: failedCatId, - parentInvocationId: invocationId, - preferredInvocationId, - terminalConsumptionByInvocationId, - terminalReason: status === 'failed' ? 'invocation_failed' : 'invocation_cancelled', - fallbackStatus: 'failed', - }); - await this.fallbackAuthorIntentsForTerminalParent(threadId, failedCatId, invocationId); - await this.markQueuedFailedOnFailure( - threadId, - failedCatId, - preferredInvocationId, - attemptedQueueEntryIds, - status === 'failed' ? 'invocation_failed' : 'invocation_cancelled', - ); - } - } if ( (status === 'canceled_by_user' || status === 'canceled') && this.consumeAutoResumeSuppression(sk, invocationId) ) { - this.supersedePausedTerminalEffects(threadId, catId); this.deps.log.info( { threadId, catId, status, invocationId }, 'Auto-resume suppressed (cancelAll) — queued entries preserved but not started', ); return; } - if (status === 'succeeded' || status === 'canceled_by_user') { - this.pausedSlots.delete(sk); - if (status === 'succeeded') { - const successfulCatIds = new Set(completedCatIds); - let exactReceiptSettlementBlocked = false; - for (const handledCatId of successfulCatIds) { - const exactInvocationId = terminalInvocationIdByCatId[handledCatId] ?? invocationId; - exactReceiptSettlementBlocked = - (await this.settleBoundQueueTargetsByChildTruth({ - threadId, - catId: handledCatId, - parentInvocationId: invocationId, - preferredInvocationId: exactInvocationId, - terminalConsumptionByInvocationId, - fallbackStatus: 'succeeded', - })) || exactReceiptSettlementBlocked; - if (exactInvocationId) { - await this.settleDetachedExactSourceOnTerminal({ - threadId, - catId: handledCatId, - invocationId: exactInvocationId, - status, - consumptions: terminalConsumptionByInvocationId[exactInvocationId], - }); - } - } - for (const failedCatId of this.collectBoundQueueCatIds(threadId)) { - if (successfulCatIds.has(failedCatId)) continue; - exactReceiptSettlementBlocked = - (await this.settleBoundQueueTargetsByChildTruth({ - threadId, - catId: failedCatId, - parentInvocationId: invocationId, - preferredInvocationId: terminalInvocationIdByCatId[failedCatId] ?? invocationId, - fallbackStatus: 'failed', - })) || exactReceiptSettlementBlocked; - } - for (const completedCatId of successfulCatIds) { - await this.fallbackAuthorIntentsForTerminalParent(threadId, completedCatId, invocationId); - } - if (exactReceiptSettlementBlocked) { - this.deps.log.error( - { threadId, invocationId, completedCatIds }, - '[QueueProcessor] aggregate success could not prove every exact Queue body exposure succeeded; immediate retry suppressed', - ); - return; - } - const attemptedEntryIds = new Set(attemptedQueueEntryIds); - const missingExposureCatIds = [...new Set(completedCatIds)].filter((completedCatId) => - this.deps.queue.hasAttemptedQueuedTargetAcrossUsers(threadId, completedCatId, attemptedEntryIds), - ); - if (missingExposureCatIds.length > 0) { - // A real route binds prompt exposure after durable child creation and - // before provider start. Reaching success without that tuple is an - // anomalous transport gap: retain Queue ownership, but never hot-loop - // the same body through another provider invocation. - this.deps.log.error( - { threadId, invocationId, attemptedQueueEntryIds, missingExposureCatIds }, - '[QueueProcessor] succeeded execution lacked exact Queue body exposure; immediate retry suppressed', - ); - return; - } - } - if (status === 'canceled_by_user' && primaryEntryRequeued) { - this.deps.log.info( - { threadId, catId }, - 'Canceled primary queue entry was restored; skipping blind cleanup-time restart', - ); - // Not restarting is deliberate: an interrupt must not be silently undone. - // But skipping the pause bookkeeping too made the slot report idle while - // the restored entry sat in Queue, so the queue looked empty, nothing - // advanced, and manual steer was the only way out. Park it visibly. - await parkRequeuedEntryVisibly( - { threadId, catId, status, invocationId }, - { - hasDispatchableQueued: () => this.hasDispatchableQueuedForThread(threadId), - isSupersededByReplacement: (id: string) => this.hasReplacementExecutionOwner(threadId, catId, id), - prepareNotifications: () => this.preparePausedQueueNotifications(threadId), - commitPause: (reason: 'canceled' | 'failed', notifications: readonly PausedQueueNotification[]) => { - const sk = QueueProcessor.slotKey(threadId, catId); - this.pauseEpoch.set(sk, (this.pauseEpoch.get(sk) ?? 0) + 1); - this.pausedSlots.set(sk, reason); - this.emitPreparedPausedNotifications(threadId, reason, notifications); - }, - }, - ); - return; - } - if (suppressAutomaticFollowUp) return; - if (this.hasDispatchableQueuedForThread(threadId)) { - await this.tryExecuteNextAcrossUsers(threadId, catId); - await this.tryAutoExecute(threadId); - if (status === 'canceled_by_user') { - this.deps.log.info({ threadId, catId }, 'Auto-resumed queued entry after user cancel'); - } - } - } else { - if (isSuperseded(catId)) return; - const requeuedAttemptEntryIds = primaryEntryRequeued ? new Set(attemptedQueueEntryIds) : undefined; - if ( - (!requeuedAttemptEntryIds || requeuedAttemptEntryIds.size > 0) && - this.hasQueuedAutoContinuationForThreadCat(threadId, catId, requeuedAttemptEntryIds) - ) { - this.pausedSlots.delete(sk); - await this.tryAutoExecute(threadId, { - onlyContinuation: true, - bypassNonAgentGate: true, - onlyTargetCat: catId, - excludeEntryIds: requeuedAttemptEntryIds, - }); - return; - } - if (primaryEntryRequeued && !this.deps.queue.hasOrdinaryEligibleQueuedForThread(threadId)) { - this.pausedSlots.delete(sk); - return; - } - // canceled or failed → pause ONLY if there are queued entries to manage. - if (!this.hasDispatchableQueuedForThread(threadId)) { - this.pausedSlots.delete(sk); - return; - } - const notifications = await this.preparePausedQueueNotifications(threadId); - if (isSuperseded(catId)) return; - if (!this.hasDispatchableQueuedForThread(threadId)) { - this.pausedSlots.delete(sk); - return; - } - const epoch = (this.pauseEpoch.get(sk) ?? 0) + 1; - this.pauseEpoch.set(sk, epoch); - this.pausedSlots.set(sk, status); - this.emitPreparedPausedNotifications(threadId, status, notifications); - - if (suppressAutomaticFollowUp) return; - - // The failed primary entry has already been put back in Queue. Keep it - // visible/retryable, but do not spin a blind 10-second retry loop against - // the same provider failure. Manual continue or a later successful turn - // can naturally dispatch it. - if (primaryEntryRequeued) return; - - // #595: auto-recover paused slot after delay. A live force-reset fence - // defers this same epoch until its current TTL expires; it never creates a - // second recovery owner. - this.schedulePausedSlotRecovery(threadId, catId, status, epoch, invocationId); + if (isSuperseded(catId) || suppressAutomaticFollowUp) return; + if (suppressAutomaticDrain) { + this.deps.log.error( + { threadId, catId, status, invocationId, attemptedQueueEntryIds }, + '[QueueProcessor] Queue settlement needs recovery; refusing blind same-attempt drain', + ); + return; } - } - - /** - * Preemptively clear paused state for a slot. - * Used by force-send: the old invocation's async cleanup will call - * onInvocationComplete('canceled'/'failed') which pauses the slot, - * but force-send already starts a new invocation — the pause is stale. - */ - clearPause(threadId: string, catId?: string): void { - if (catId) { - this.supersedePausedSlot(QueueProcessor.slotKey(threadId, catId)); - } else { - for (const key of [...this.pausedSlots.keys()]) { - if (QueueProcessor.slotMatchesThread(key, threadId)) { - this.supersedePausedSlot(key); - } - } + if (this.hasDispatchableQueuedForThread(threadId)) { + const bypassSuppressionEpoch = this.autoResumeSuppressionEpochForDifferentExecution(sk, invocationId); + await this.requestDrain( + threadId, + bypassSuppressionEpoch === undefined ? undefined : { bypassSuppressionForCatId: catId, bypassSuppressionEpoch }, + ); } } @@ -3213,6 +2650,7 @@ export class QueueProcessor { for (const executionId of executionIds) mergedExecutionIds.add(executionId); this.suppressedAutoResume.set(sk, { setAt: now, + epoch: ++this.nextAutoResumeSuppressionEpoch, executionIds: mergedExecutionIds, hasAnonymousFence: (existingIsLive && existing.hasAnonymousFence) || executionIds.length === 0, }); @@ -3258,339 +2696,171 @@ export class QueueProcessor { return true; } - /** - * @deprecated Use releaseSlot(threadId, catId) instead. Kept for backward compat during migration. - */ - releaseThread(threadId: string): void { - for (const key of [...this.processingSlots.keys()]) { - if (QueueProcessor.slotMatchesThread(key, threadId)) this.processingSlots.delete(key); - } + private autoResumeSuppressionEpochForDifferentExecution( + slotKey: string, + invocationId: string | undefined, + ): number | undefined { + if (!invocationId || this.autoResumeSuppressionRemainingMs(slotKey) === 0) return undefined; + const suppression = this.suppressedAutoResume.get(slotKey); + return suppression && !suppression.hasAnonymousFence && !suppression.executionIds.has(invocationId) + ? suppression.epoch + : undefined; } /** * User-level entry: co-creator manually triggers processing their next entry. */ async processNext(threadId: string, userId: string): Promise<{ started: boolean; entry?: QueueEntry }> { - // Clear all paused slots for this thread (manual resume clears all) - this.clearPause(threadId); return this.tryExecuteNextForUser(threadId, userId); } - /** Producer recovery confirms custody; it is never a user request to clear pause or override queue order. */ + /** + * Progress a producer-owned durable carrier through the ordinary Queue + * comparator. Recovery is not authority to skip older sources, clear a + * cancellation fence, or start a second invocation for an admitted row. + */ async progressOwnedCarrier(entry: QueueEntry, targetCatId: string): Promise { - const current = this.deps.queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - if (!current) throw new Error('Owned Queue carrier disappeared before progress'); - if (current.status === 'processing') return 'already_processing'; + const userId = queueEntryOwnerId(entry); + const current = this.deps.queue.getEntrySnapshot(entry.threadId, userId, entry.id); + if (!current) { + return this.deps.queue.findAdmittedEntriesForMessages( + entry.threadId, + queueEntryMessageIds(entry), + userId, + targetCatId, + ).length > 0 + ? 'already_processing' + : 'terminal_owned'; + } + if (current.status === 'claimed' || current.status === 'processing') return 'already_processing'; + if (current.status === 'terminal') return 'terminal_owned'; if (this.isAutoResumeSuppressed(entry.threadId, targetCatId)) return 'owned_deferred_suppressed'; - if (this.isPaused(entry.threadId, targetCatId)) return 'owned_deferred_paused'; const slotKey = QueueProcessor.slotKey(entry.threadId, targetCatId); if (this.processingSlots.has(slotKey) || this.deps.invocationTracker.has(entry.threadId, targetCatId)) { return 'owned_deferred_busy'; } - const result = await this.tryExecuteNextAcrossUsers(entry.threadId, targetCatId, { onlyTargetCat: true }); - return result.started && result.entry?.id === entry.id ? 'started' : 'owned_deferred_busy'; - } - - /** - * Claim one Steer reservation by its unforgeable process-local identity. - * The route calls this immediately after preemption; generic queue mutations - * cannot claim the fenced entry by id or comparator position. - */ - async processExactSteerReservation( - threadId: string, - userId: string, - entryId: string, - reservationId: string, - ): Promise<{ started: boolean; entry?: QueueEntry }> { - const current = this.deps.queue.getEntrySnapshot(threadId, userId, entryId); - if (!current || current.status !== 'queued') return { started: false }; - const entryCat = exactSteerReservationTarget(current, reservationId); - if (!entryCat) return { started: false }; - const slotKey = QueueProcessor.slotKey(threadId, entryCat); - this.clearPause(threadId, entryCat); - if (this.processingSlots.has(slotKey) || this.deps.invocationTracker.has(threadId, entryCat)) { - return { started: false }; - } - const entry = this.deps.queue.claimExactSteerReservation(threadId, userId, entryId, reservationId); - if (!entry) return { started: false }; - if (!(await this.startReservedEntry(entry, slotKey, entryCat, [entryCat]))) return { started: false }; - return { started: true, entry }; - } - - /** - * User-initiated recovery for one exact failed receipt target. The durable - * attempt fence is committed before the entry is allowed back into normal - * queue scheduling, so duplicate clicks cannot re-run the same attempt. - */ - async retryFailedTarget( - threadId: string, - userId: string, - entryId: string, - catId: string, - expectedAttemptId: string, - commitAuthority: RetryAuthorityCommit, - ): Promise< - | { outcome: 'retried'; attemptId: string } - | { outcome: 'not_retryable' | 'unavailable' } - | { outcome: 'authority_stale'; reason: RetryAuthorityFailureReason } - > { - const coordinator = this.deps.queueCustodyCoordinator; - if (!coordinator) return { outcome: 'unavailable' }; - const entry = this.deps.queue.getEntrySnapshot(threadId, userId, entryId); - if ( - !entry || - entry.status !== 'queued' || - !entry.targetCats.includes(catId) || - !entry.queuedFailedByCatIds?.includes(catId) - ) { - return { outcome: 'not_retryable' }; - } - - const custodyRetry = await coordinator.retryFailedTarget(entry, catId, expectedAttemptId, commitAuthority); - if (custodyRetry.outcome !== 'retried') return custodyRetry; - - const attempt = custodyRetry.attempt; - const retry = this.deps.queue.retryFailedTarget(threadId, userId, entryId, catId, attempt.id); - if (!retry) { - this.deps.log.warn( - { threadId, entryId, catId, attemptId: attempt.id }, - '[QueueProcessor] durable retry attempt awaits startup recovery after Queue carrier changed', - ); - return { outcome: 'retried', attemptId: attempt.id }; - } - await emitQueueUpdated( - this.deps.socketManager, - userId, - threadId, - this.deps.queue.list(threadId, userId), - this.deps.messageStore, - 'queued_retry', - ); - void this.executeRetryTarget(threadId, userId, entryId, catId).catch((err) => { - this.deps.log.warn({ err, threadId, entryId, catId }, '[QueueProcessor] retry queue dispatch failed'); - }); - return { outcome: 'retried', attemptId: attempt.id }; - } - /** - * Exact recovery for process-local A2A carriers that intentionally have no - * stored message custody. Queue failure facts provide the CAS fence; no - * second ledger is created. Message-backed entries must use durable custody. - */ - async retryFailedTargetWithoutCustody( - threadId: string, - userId: string, - entryId: string, - catId: string, - expectedRecoveryActionId: string, - ): Promise<{ outcome: 'retried'; attemptId: string } | { outcome: 'not_retryable' }> { - const entry = this.deps.queue.getEntrySnapshot(threadId, userId, entryId); + await this.requestDrain(entry.threadId); if ( - !entry || - entry.messageId !== null || - entry.mergedMessageIds.length > 0 || - entry.source !== 'agent' || - failedQueueRecoveryActionId(entry, catId) !== expectedRecoveryActionId + this.deps.queue.findAdmittedEntriesForMessages(entry.threadId, queueEntryMessageIds(entry), userId, targetCatId) + .length > 0 ) { - return { outcome: 'not_retryable' }; + return 'started'; } - const attemptId = `${expectedRecoveryActionId}:attempt`; - const retry = this.deps.queue.retryFailedTarget(threadId, userId, entryId, catId, attemptId); - if (!retry) return { outcome: 'not_retryable' }; - await emitQueueUpdated( - this.deps.socketManager, - userId, - threadId, - this.deps.queue.list(threadId, userId), - this.deps.messageStore, - 'queued_retry', - ); - void this.executeRetryTarget(threadId, userId, entryId, catId).catch((err) => { - this.deps.log.warn({ err, threadId, entryId, catId }, '[QueueProcessor] message-less retry dispatch failed'); - }); - return { outcome: 'retried', attemptId }; + const durable = await this.deps.queue.getDurableEntry(entry.threadId, entry.id); + if (!durable || durable.status === 'terminal') return 'terminal_owned'; + if (durable.status === 'claimed' || durable.status === 'processing') return 'already_processing'; + return 'owned_deferred_busy'; } /** - * F122B: Try to auto-execute any queued autoExecute entries whose target cat slot is free. - * Called immediately after enqueuing an agent entry. - * Scans all entries and starts every one whose cat slot is free (parallel multi-cat). - * Per-cat slot mutex (processingSlots + invocationTracker) prevents conflicts. + * Signal the single per-thread admission coordinator. Repeated signals while + * it is running only set dirty; the current owner must observe that bit before + * it can retire, so enqueue/terminal/reorder races cannot lose the last wake. */ - async tryAutoExecute( + requestDrain( threadId: string, - opts: { - onlyContinuation?: boolean; - bypassNonAgentGate?: boolean; - onlyTargetCat?: string; - onlyEntryId?: string; - excludeEntryIds?: ReadonlySet; - } = {}, + options?: { bypassSuppressionForCatId?: string; bypassSuppressionEpoch?: number }, ): Promise { - if (!opts.bypassNonAgentGate && this.hasDispatchableNonAgentQueued(threadId)) { - await this.tryExecuteNextAcrossUsers(threadId, 'fairness-gate', { onlyNonAgent: true }); - return; - } - const entries = (this.deps.queue.listAutoExecute?.(threadId) ?? []) - .filter((entry) => !opts.onlyContinuation || entry.sourceCategory === 'continuation') - .filter( - (entry) => - !opts.onlyTargetCat || - (entry.targetCats[0] === opts.onlyTargetCat && isOrdinaryQueueTargetEligible(entry, opts.onlyTargetCat)), - ) - .filter((entry) => !opts.onlyEntryId || entry.id === opts.onlyEntryId) - .filter((entry) => !opts.excludeEntryIds?.has(entry.id)) - .sort((a, b) => a.createdAt - b.createdAt); - if (entries.length > 0) { - const now = Date.now(); - this.deps.log.info( - { - threadId, - entryCount: entries.length, - entries: entries.map((entry) => ({ - id: entry.id, - targetCat: entry.targetCats[0] ?? 'unknown', - createdAt: entry.createdAt, - ageMs: now - entry.createdAt, - })), - }, - '[DIAG/a2a] tryAutoExecute candidate scan', - ); - } - - for (const entry of entries) { - const eligibleTargetCats = entry.targetCats.filter((catId) => isOrdinaryQueueTargetEligible(entry, catId)); - const entryCat = eligibleTargetCats[0]; - if (!entryCat) continue; - const sk = QueueProcessor.slotKey(threadId, entryCat); - // Skip if slot is busy (mutex or tracker) - if (this.processingSlots.has(sk)) continue; - if (this.deps.invocationTracker.has(threadId, entryCat)) continue; - - // Guard: markProcessingById may fail if entry was consumed between snapshot and now - if (!this.deps.queue.markProcessingById(threadId, entry.id, entryCat)) continue; - const processingEntry = this.deps.queue.getEntrySnapshot(threadId, entry.userId, entry.id); - if (!(await this.startReservedEntry(processingEntry ?? entry, sk, entryCat, eligibleTargetCats))) continue; - // Continue scanning — start all entries with free cat slots (parallel dispatch) + const state = this.threadDrains.get(threadId) ?? { dirty: false }; + this.threadDrains.set(threadId, state); + state.dirty = true; + if (options?.bypassSuppressionForCatId && options.bypassSuppressionEpoch !== undefined) { + if (!state.bypassSuppressionEpochByCatId) state.bypassSuppressionEpochByCatId = new Map(); + state.bypassSuppressionEpochByCatId.set(options.bypassSuppressionForCatId, options.bypassSuppressionEpoch); } - } - - // ── Internal ── - - private hasDispatchableQueuedForThread(threadId: string): boolean { - return this.deps.queue.hasDispatchableQueuedForThread(threadId); - } - - private hasDispatchableNonAgentQueued(threadId: string): boolean { - if (!this.deps.queue.hasQueuedNonAgentForThread?.(threadId)) return false; - for (const userId of this.deps.queue.listUsersForThread(threadId)) { - for (const entry of this.deps.queue.list(threadId, userId)) { - if (entry.source === 'agent' || entry.status !== 'queued') continue; - if (entry.targetCats.some((catId) => this.isAutomaticOrdinaryTargetEligible(threadId, entry, catId))) { - return true; - } - } + if (!state.owner) { + const owner = this.runDrain(threadId, state).finally(() => { + if (state.owner === owner) state.owner = undefined; + if (state.dirty) void this.requestDrain(threadId); + else this.threadDrains.delete(threadId); + }); + state.owner = owner; } - return false; - } - - private isAutomaticOrdinaryTargetEligible(threadId: string, entry: QueueEntry, catId: string): boolean { - return ( - isOrdinaryQueueTargetEligible(entry, catId) && !this.pausedSlots.has(QueueProcessor.slotKey(threadId, catId)) - ); + return state.owner; } - private pausedTargetCats(threadId: string): Set { - const catIds = new Set(); - for (const key of this.pausedSlots.keys()) { - const slot = QueueProcessor.parseSlotKey(key); - if (slot?.threadId === threadId) catIds.add(slot.catId); + private async runDrain(threadId: string, state: ThreadDrainState): Promise { + while (true) { + state.dirty = false; + const bypassSuppressionEpochByCatId = new Map(state.bypassSuppressionEpochByCatId); + state.bypassSuppressionEpochByCatId?.clear(); + while (true) { + const result = await this.tryExecuteNextAcrossUsers(threadId, bypassSuppressionEpochByCatId); + // One drain owns one source entry. Its exact target set fans out as one + // admission unit; the durable handoff callback signals the next drain. + // This preserves source comparator order without waiting for providers + // to finish their work. + if (result.started || !result.progressed) break; + } + if (!state.dirty) return; } - return catIds; } - private hasQueuedAutoContinuationForThreadCat( + /** Start the exact ledger rows already claimed by a Steer request. */ + async processClaimedSteerEntries( threadId: string, - catId: string, - excludeEntryIds: ReadonlySet = new Set(), - ): boolean { - return (this.deps.queue.listAutoExecute?.(threadId) ?? []).some( - (entry) => - !excludeEntryIds.has(entry.id) && - entry.source === 'agent' && - entry.sourceCategory === 'continuation' && - entry.targetCats[0] === catId && - isOrdinaryQueueTargetEligible(entry, catId), - ); - } - - private async executeRetryTarget(threadId: string, userId: string, entryId: string, catId: string): Promise { - this.clearPause(threadId, catId); - const current = this.deps.queue.getEntrySnapshot(threadId, userId, entryId); - if (!current || current.status !== 'queued' || !current.targetCats.includes(catId)) return false; - - const slotKey = QueueProcessor.slotKey(threadId, catId); - if (this.processingSlots.has(slotKey) || this.deps.invocationTracker.has(threadId, catId)) return false; - if (!this.deps.queue.markProcessingById(threadId, entryId, catId)) return false; - - const processing = this.deps.queue.getEntrySnapshot(threadId, userId, entryId); - return this.startReservedEntry(processing ?? current, slotKey, catId, [catId], true); - } - - private async startReservedEntry( - entry: QueueEntry, - slotKey: string, - catId: string, - executionTargetCats?: readonly string[], - suppressAutomaticFollowUp = false, - ): Promise { - const exactReservationId = entry.exactSteerBatch?.reservationId; - const exactBatchMembers = entry.exactSteerBatch ? this.deps.queue.collectExactSteerBatchMembers(entry) : []; - if (entry.exactSteerBatch && !exactBatchMembers) { - this.deps.queue.rollbackExactSteerBatchProcessing(entry.threadId, entry.exactSteerBatch.reservationId); - this.deps.log.warn( - { - threadId: entry.threadId, - queueEntryId: entry.id, - reservationId: entry.exactSteerBatch.reservationId, - }, - '[QueueProcessor] exact Batch Steer reservation changed before start', - ); - return false; + userId: string, + entryIds: readonly string[], + targetCatId: string, + ): Promise<{ started: boolean; entry?: QueueEntry }> { + const entries = entryIds + .map((entryId) => this.deps.queue.getEntrySnapshot(threadId, userId, entryId)) + .filter((entry): entry is QueueEntry => !!entry); + if ( + entries.length !== entryIds.length || + entries.some( + (entry, index) => + entry.id !== entryIds[index] || + entry.status !== 'claimed' || + entry.claimedTargetIds?.length !== 1 || + entry.claimedTargetIds[0] !== targetCatId, + ) + ) { + return { started: false }; } - - const reservation = this.reserveProcessingSlot(slotKey, entry.id, entry.userId); - try { - for (const selected of [entry, ...(exactBatchMembers ?? [])]) { - await this.persistQueueEntry(selected); - } - } catch (err) { - this.releaseProcessingSlot(slotKey, reservation); - const restored = entry.exactSteerBatch - ? this.deps.queue.rollbackExactSteerBatchProcessing(entry.threadId, entry.exactSteerBatch.reservationId) - : this.deps.queue.rollbackProcessing(entry.threadId, entry.id) - ? [this.deps.queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id)].filter( - (candidate): candidate is QueueEntry => !!candidate, - ) - : []; - for (const restoredEntry of restored) { - try { - await this.persistQueueEntry(restoredEntry); - } catch (restoreErr) { - this.deps.log.error( - { restoreErr, threadId: entry.threadId, queueEntryId: restoredEntry.id }, - '[QueueProcessor] failed to persist processing reservation rollback', - ); - } - } - this.deps.log.error( - { err, threadId: entry.threadId, queueEntryId: entry.id }, - '[QueueProcessor] processing custody persistence failed; entry restored', - ); - return false; + const slotKey = QueueProcessor.slotKey(threadId, targetCatId); + if (this.processingSlots.has(slotKey) || this.deps.invocationTracker.has(threadId, targetCatId)) { + await this.deps.queue.restoreClaimedEntries(threadId, entryIds); + return { started: false }; + } + const [entry, ...batchMembers] = entries; + if (!entry) return { started: false }; + if (await this.reconcileClaimedGroupWithExistingReceiver(entries, targetCatId)) { + return { started: false }; + } + if (!(await this.startReservedEntry(entry, slotKey, targetCatId, [targetCatId], false, undefined, batchMembers))) { + return { started: false }; } + return { started: true, entry }; + } + + // ── Internal ── + + private hasDispatchableQueuedForThread(threadId: string): boolean { + return this.deps.queue.hasDispatchableQueuedForThread(threadId); + } + + private async startReservedEntry( + entry: QueueEntry, + slotKey: string, + catId: string, + executionTargetCats?: readonly string[], + suppressAutomaticFollowUp = false, + conversationBatchResolution?: ConversationBatchResolution, + claimedBatchMembers: readonly QueueEntry[] = [], + ): Promise { + const attemptedQueueEntryIds = [entry.id, ...claimedBatchMembers.map((candidate) => candidate.id)]; + + const reservation = this.reserveProcessingSlot(slotKey, entry.id, queueEntryOwnerId(entry)); - void this.executeEntry(entry, reservation, executionTargetCats, exactBatchMembers ?? []).then( + void this.executeEntry( + entry, + reservation, + executionTargetCats, + [...claimedBatchMembers], + conversationBatchResolution, + ).then( (result) => { if (!this.releaseProcessingSlot(slotKey, reservation)) { this.deps.log.info( @@ -3600,35 +2870,11 @@ export class QueueProcessor { this.signalDeliveryBatchDone(entry.threadId, result.status); return; } - if (result.deferredForBusyTarget) { - void emitQueueUpdated( - this.deps.socketManager, - entry.userId, - entry.threadId, - this.deps.queue.list(entry.threadId, entry.userId), - this.deps.messageStore, - 'target_busy', - ); - this.signalDeliveryBatchDone(entry.threadId, result.status); - return; - } - const completion = this.onInvocationComplete( - entry.threadId, - catId, - result.status, - result.invocationId, - result.status === 'succeeded' ? result.successfulCatIds : (executionTargetCats ?? entry.targetCats), - result.primaryEntryRequeued, - result.terminalInvocationIdByCatId, - result.attemptedQueueEntryIds, - result.terminalConsumptionByInvocationId, + void this.onInvocationComplete(entry.threadId, catId, result.status, result.invocationId, [], { + suppressAutomaticDrain: result.primarySettlementIncomplete, + attemptedQueueEntryIds: result.attemptedQueueEntryIds, suppressAutomaticFollowUp, - ).catch(() => {}); - if (exactReservationId) { - void completion.finally(() => { - this.deps.queue.pruneExactUserBatchReservation(exactReservationId); - }); - } + }).catch(() => {}); this.signalDeliveryBatchDone(entry.threadId, result.status); }, () => { @@ -3641,25 +2887,13 @@ export class QueueProcessor { return; } const requeued = this.deps.queue - .list(entry.threadId, entry.userId) + .list(entry.threadId, queueEntryOwnerId(entry)) .some((candidate) => candidate.id === entry.id && candidate.status === 'queued'); - const completion = this.onInvocationComplete( - entry.threadId, - catId, - 'failed', - undefined, - [], - requeued, - {}, - [entry.id], - {}, + void this.onInvocationComplete(entry.threadId, catId, 'failed', undefined, [], { + suppressAutomaticDrain: requeued, + attemptedQueueEntryIds, suppressAutomaticFollowUp, - ).catch(() => {}); - if (exactReservationId) { - void completion.finally(() => { - this.deps.queue.pruneExactUserBatchReservation(exactReservationId); - }); - } + }).catch(() => {}); this.signalDeliveryBatchDone(entry.threadId, 'failed'); }, ); @@ -3668,72 +2902,121 @@ export class QueueProcessor { private async tryExecuteNextAcrossUsers( threadId: string, - catId: string, - opts: { onlyTargetCat?: boolean; onlyNonAgent?: boolean } = {}, - ): Promise<{ started: boolean; entry?: QueueEntry }> { - // F175: scan by comparator order, skip entries whose target slot is busy - const busyCats = new Set(); - let deferredForBusySlot = 0; - for (;;) { - const exact = this.deps.queue.peekActivatedExactSteerReservation( + bypassSuppressionEpochByCatId: ReadonlyMap = new Map(), + ): Promise { + const comparatorHead = this.deps.queue.peekOldestAcrossUsers(threadId); + if (!comparatorHead) { + this.emitContinuationDiagnostic(threadId, 'unknown', classifyContinuationOutcome(0), 0); + return { started: false }; + } + + let resolvedTargetCats = queueEntryTargetCats(comparatorHead).filter((targetCatId) => + isOrdinaryQueueTargetEligible(comparatorHead, targetCatId), + ); + let conversationBatchResolution: ConversationBatchResolution | undefined; + if (comparatorHead.kind === 'conversation_input') { + const routingClass = queueEntryTargetCats(comparatorHead).length === 0 ? 'targetless' : 'explicit'; + if (routingClass === 'targetless' && this.deps.invocationTracker.has(threadId)) { + this.emitContinuationDiagnostic(threadId, 'targetless', 'all_candidate_slots_busy', 1, comparatorHead.id); + return { started: false }; + } + resolvedTargetCats = await this.deps.router.resolveConversationTargetsAtAdmission( + queueEntryTargetCats(comparatorHead), threadId, - undefined, - busyCats, - opts.onlyTargetCat ? catId : undefined, - opts.onlyNonAgent, ); - if (exact) { - const exactCat = exactSteerReservationTarget(exact.entry, exact.reservationId); - if (!exactCat) return { started: false }; - const exactSlotKey = QueueProcessor.slotKey(threadId, exactCat); - if (this.processingSlots.has(exactSlotKey) || this.deps.invocationTracker.has(threadId, exactCat)) { - busyCats.add(exactCat); - deferredForBusySlot += 1; - continue; - } - } - const entry = exact - ? this.deps.queue.claimExactSteerReservation(threadId, exact.entry.userId, exact.entry.id, exact.reservationId) - : this.deps.queue.markProcessingAcrossUsers( - threadId, - new Set([...busyCats, ...this.pausedTargetCats(threadId)]), - opts.onlyTargetCat ? catId : undefined, - opts.onlyNonAgent, - ); - if (!entry) { - if (exact) continue; - this.emitContinuationDiagnostic( - threadId, - catId, - classifyContinuationOutcome(deferredForBusySlot), - deferredForBusySlot, - ); - return { started: false }; + if (resolvedTargetCats.length === 0) { + const terminalized = await this.terminalizeUnavailableConversationHead(comparatorHead, routingClass); + return { started: false, progressed: terminalized !== null, ...(terminalized ? { entry: terminalized } : {}) }; + } + conversationBatchResolution = { + routingClass, + requestedTargets: [...queueEntryTargetCats(comparatorHead)], + resolvedTargets: [...resolvedTargetCats], + }; + } else { + resolvedTargetCats = await this.deps.router.resolveExplicitTargets( + queueEntryTargetCats(comparatorHead), + threadId, + ); + if (resolvedTargetCats.length === 0) { + const terminalized = + comparatorHead.kind === 'private_input' + ? await this.terminalizeUnavailablePrivateHead(comparatorHead) + : await this.terminalizeUnavailableConversationHead(comparatorHead, 'explicit'); + return { started: false, progressed: terminalized !== null, ...(terminalized ? { entry: terminalized } : {}) }; } + } - const eligibleTargetCats = entry.targetCats.filter((targetCatId) => - exact - ? isOrdinaryQueueTargetEligible(entry, targetCatId) - : this.isAutomaticOrdinaryTargetEligible(threadId, entry, targetCatId), + const idleTargetCats = resolvedTargetCats.filter((targetCatId) => { + const slotKey = QueueProcessor.slotKey(threadId, targetCatId); + const remainingMs = this.autoResumeSuppressionRemainingMs(slotKey); + const suppression = remainingMs > 0 ? this.suppressedAutoResume.get(slotKey) : undefined; + const bypassEpoch = bypassSuppressionEpochByCatId.get(targetCatId); + const bypassesExactSuppression = bypassEpoch !== undefined && suppression?.epoch === bypassEpoch; + return ( + (bypassesExactSuppression || remainingMs === 0) && + !this.processingSlots.has(slotKey) && + !this.deps.invocationTracker.has(threadId, targetCatId) ); - const entryCat = exact ? exactSteerReservationTarget(entry, exact.reservationId) : eligibleTargetCats[0]; - if (!entryCat) return { started: false }; - const entrySk = QueueProcessor.slotKey(threadId, entryCat); - - if (this.processingSlots.has(entrySk) || this.deps.invocationTracker.has(threadId, entryCat)) { - this.deps.queue.rollbackProcessing(threadId, entry.id); - busyCats.add(entryCat); - deferredForBusySlot += 1; - continue; - } + }); + if (idleTargetCats.length !== resolvedTargetCats.length) { + this.emitContinuationDiagnostic( + threadId, + resolvedTargetCats[0] ?? 'unknown', + 'all_candidate_slots_busy', + resolvedTargetCats.length, + comparatorHead.id, + ); + return { started: false }; + } + const staleReceiverTarget = + idleTargetCats.length > 1 + ? await this.firstTargetWithExistingReceiver([comparatorHead], idleTargetCats) + : undefined; + const selectedTargetCats = staleReceiverTarget ? [staleReceiverTarget] : idleTargetCats; - if (!(await this.startReservedEntry(entry, entrySk, entryCat, exact ? [entryCat] : eligibleTargetCats))) { - this.emitContinuationDiagnostic(threadId, catId, 'start_rejected', deferredForBusySlot, entry.id); - return { started: false }; + const claimedGroup = await this.deps.queue.markProcessingGroupAcrossUsersDurable( + threadId, + { entryId: comparatorHead.id, targetCats: selectedTargetCats }, + [comparatorHead.id], + ); + const entry = claimedGroup?.entry; + if (!entry) { + this.emitContinuationDiagnostic(threadId, resolvedTargetCats[0] ?? 'unknown', classifyContinuationOutcome(0), 0); + return { started: false }; + } + + for (const targetCatId of selectedTargetCats) { + if (await this.reconcileClaimedGroupWithExistingReceiver([entry, ...claimedGroup.members], targetCatId)) { + return { started: false, progressed: true, entry }; } + } + + const entryCat = selectedTargetCats[0]!; + const entrySk = QueueProcessor.slotKey(threadId, entryCat); - return { started: true, entry }; + if (this.processingSlots.has(entrySk) || this.deps.invocationTracker.has(threadId, entryCat)) { + await this.deps.queue.rollbackProcessingDurable(threadId, entry.id); + this.emitContinuationDiagnostic(threadId, entryCat, 'all_candidate_slots_busy', 1, entry.id); + return { started: false }; + } + + if ( + !(await this.startReservedEntry( + entry, + entrySk, + entryCat, + selectedTargetCats, + false, + conversationBatchResolution, + [], + )) + ) { + this.emitContinuationDiagnostic(threadId, entryCat, 'start_rejected', 0, entry.id); + return { started: false }; } + + return { started: true, entry }; } /** @@ -3741,7 +3024,7 @@ export class QueueProcessor { * * This path returned `started: false` from three different places without a * trace, so a message sitting queued for minutes left no evidence at all: the - * only nearby log (`tryAutoExecute`) fires just when it has candidates, and it + * only nearby drain log fires just when it has candidates, and it * only ever considers `autoExecute` entries — user messages are not in that * set. Absence of that log was therefore indistinguishable between "never ran" * and "ran and found nothing", which is exactly the question worth answering. @@ -3766,53 +3049,213 @@ export class QueueProcessor { if (diagnostic) this.deps.log.info(diagnostic.payload, diagnostic.message); } - private async tryExecuteNextForUser( - threadId: string, - userId: string, - ): Promise<{ started: boolean; entry?: QueueEntry }> { - // F108 P1-3 fix: peek at next entry's target cat to check slot mutex BEFORE marking processing. - // This prevents entries from getting stuck as 'processing' when the slot is busy. - const exact = this.deps.queue.peekActivatedExactSteerReservation(threadId, userId); - const nextEntry = exact?.entry ?? this.deps.queue.peekNextQueued(threadId, userId); + private async tryExecuteNextForUser(threadId: string, userId: string): Promise { + const nextEntry = this.deps.queue.peekNextQueued(threadId, userId); if (!nextEntry) return { started: false }; - const eligibleTargetCats = nextEntry.targetCats.filter((catId) => isOrdinaryQueueTargetEligible(nextEntry, catId)); - const entryCat = exact ? exactSteerReservationTarget(nextEntry, exact.reservationId) : eligibleTargetCats[0]; - if (!entryCat) return { started: false }; - const sk = QueueProcessor.slotKey(threadId, entryCat); - - // Mutex check — per-slot (before mutating queue state) - if (this.processingSlots.has(sk)) { - // 2026-06-02: observability — this silent !started is the source of a QUEUE_BUSY that gives - // no clue why. Log the busy source so future "can't steer/dequeue" is diagnosable from logs. - this.deps.log.info( - { event: 'queue_not_started', threadId, entryCat, reason: 'processing_slot_busy' }, - '[QueueProcessor] processNext skipped: processingSlot busy', + let resolvedTargetCats = queueEntryTargetCats(nextEntry).filter((catId) => + isOrdinaryQueueTargetEligible(nextEntry, catId), + ); + let conversationBatchResolution: ConversationBatchResolution | undefined; + if (nextEntry.kind === 'conversation_input') { + const routingClass = queueEntryTargetCats(nextEntry).length === 0 ? 'targetless' : 'explicit'; + if (routingClass === 'targetless' && this.deps.invocationTracker.has(threadId)) { + this.deps.log.info( + { event: 'queue_not_started', threadId, entryId: nextEntry.id, reason: 'thread_active' }, + '[QueueProcessor] processNext skipped: targetless admission waits for idle thread', + ); + return { started: false }; + } + resolvedTargetCats = await this.deps.router.resolveConversationTargetsAtAdmission( + queueEntryTargetCats(nextEntry), + threadId, ); - return { started: false }; + if (resolvedTargetCats.length === 0) { + const terminalized = await this.terminalizeUnavailableConversationHead(nextEntry, routingClass); + return { started: false, progressed: terminalized !== null, ...(terminalized ? { entry: terminalized } : {}) }; + } + conversationBatchResolution = { + routingClass, + requestedTargets: [...queueEntryTargetCats(nextEntry)], + resolvedTargets: [...resolvedTargetCats], + }; + } else { + resolvedTargetCats = await this.deps.router.resolveExplicitTargets(queueEntryTargetCats(nextEntry), threadId); + if (resolvedTargetCats.length === 0) { + const terminalized = + nextEntry.kind === 'private_input' + ? await this.terminalizeUnavailablePrivateHead(nextEntry) + : await this.terminalizeUnavailableConversationHead(nextEntry, 'explicit'); + return { started: false, progressed: terminalized !== null, ...(terminalized ? { entry: terminalized } : {}) }; + } } - // Fix: skip if cat already has an active invocation via CLI/messages.ts (same guard as above) - if (this.deps.invocationTracker.has(threadId, entryCat)) { + + const idleTargetCats = resolvedTargetCats.filter( + (catId) => + !this.processingSlots.has(QueueProcessor.slotKey(threadId, catId)) && + !this.deps.invocationTracker.has(threadId, catId), + ); + if (idleTargetCats.length !== resolvedTargetCats.length) { this.deps.log.info( - { event: 'queue_not_started', threadId, entryCat, reason: 'tracker_active' }, - '[QueueProcessor] processNext skipped: invocationTracker active', + { event: 'queue_not_started', threadId, entryCat: resolvedTargetCats[0], reason: 'target_busy' }, + '[QueueProcessor] processNext skipped: target slot busy', ); return { started: false }; } + const staleReceiverTarget = + idleTargetCats.length > 1 ? await this.firstTargetWithExistingReceiver([nextEntry], idleTargetCats) : undefined; + const selectedTargetCats = staleReceiverTarget ? [staleReceiverTarget] : idleTargetCats; + + const entryCat = selectedTargetCats[0]!; + const sk = QueueProcessor.slotKey(threadId, entryCat); - // Now safe to mark processing — slot is available - const entry = exact - ? this.deps.queue.claimExactSteerReservation(threadId, userId, exact.entry.id, exact.reservationId) - : this.deps.queue.markProcessing(threadId, userId); + const claimedGroup = await this.deps.queue.markProcessingGroupDurable( + threadId, + userId, + { entryId: nextEntry.id, targetCats: selectedTargetCats }, + [nextEntry.id], + ); + const entry = claimedGroup?.entry; if (!entry) return { started: false }; + for (const targetCatId of selectedTargetCats) { + if (await this.reconcileClaimedGroupWithExistingReceiver([entry, ...claimedGroup.members], targetCatId)) { + return { started: false, progressed: true, entry }; + } + } + // Fire-and-forget execution — exact reservation cleanup owns completion side effects. - if (!(await this.startReservedEntry(entry, sk, entryCat, exact ? [entryCat] : eligibleTargetCats))) + if ( + !(await this.startReservedEntry(entry, sk, entryCat, selectedTargetCats, false, conversationBatchResolution, [])) + ) { return { started: false }; + } return { started: true, entry }; } + /** + * Close an exact public Queue head that cannot legally form an invocation. + * The process-local claim fences comparator ownership; MessageStore then + * publishes the input and adjacent failure in one durable CAS transaction. + */ + private async terminalizeUnavailableConversationHead( + expected: QueueEntry, + routingClass: ConversationBatchResolution['routingClass'], + ): Promise { + const claimed = await this.deps.queue.claimPreAdmissionFailureAcrossUsersDurable(expected.threadId, expected.id); + if (!claimed) return null; + try { + if (!claimed.payload.messageId) { + throw new Error(`public Queue head has no durable source message: ${claimed.id}`); + } + const source = await this.deps.messageStore.getById(claimed.payload.messageId); + if (!source) { + throw new Error(`public Queue head source message is missing: ${claimed.id}`); + } + const reason = routingClass === 'targetless' ? 'no_available_target' : 'invalid_explicit_target'; + const failedTargets = [...queueEntryTargetCats(claimed)]; + const wakeTargetLabel = failedTargets.length > 0 ? failedTargets.join('、') : '处理成员'; + const content = + reason === 'no_available_target' + ? `唤起${wakeTargetLabel}失败:当前没有可用的接收对象。` + : `唤起${wakeTargetLabel}失败:指定的接收对象当前无效。`; + const result = await this.deps.messageStore.commitLifecyclePreAdmissionFailure({ + sourceMessageId: source.id, + expectedEntryId: claimed.id, + requestedTargets: failedTargets, + reason, + content, + failedAt: Math.max(Date.now(), source.timestamp), + }); + if (result.kind !== 'applied' && result.kind !== 'replayed') { + throw new Error( + `pre-admission failure transaction rejected ${claimed.id}: ${result.kind}:${ + 'reason' in result ? result.reason : 'missing' + }`, + ); + } + this.callerDispatchObservations.registerPersistedSource(result.inputMessage, failedTargets); + const removed = await this.deps.queue.removeProcessedAcrossUsersDurable( + claimed.threadId, + claimed.id, + 'failed', + 'invocation_failed', + ); + if (!removed) { + throw new Error(`pre-admission failure transaction lost claimed Queue entry: ${claimed.id}`); + } + this.emitLifecycleMessageUpdated(queueEntryOwnerId(claimed), result.inputMessage); + this.emitLifecycleMessageUpdated(queueEntryOwnerId(claimed), result.failureMessage); + await emitQueueUpdated( + this.deps.socketManager, + queueEntryOwnerId(claimed), + claimed.threadId, + this.deps.queue.list(claimed.threadId, queueEntryOwnerId(claimed)), + this.deps.messageStore, + 'pre_admission_failed', + ); + this.deps.log.warn( + { threadId: claimed.threadId, entryId: claimed.id, routingClass, reason }, + '[QueueProcessor] public Queue head terminalized before admission', + ); + return removed; + } catch (error) { + await this.deps.queue.rollbackProcessingDurable(claimed.threadId, claimed.id); + throw error; + } + } + + /** + * Close an exact private Queue head without publishing its body or a History result. + * The structured internal diagnostic is the only lifecycle projection owned here; + * any typed source owner remains responsible for its own terminal disposition. + */ + private async terminalizeUnavailablePrivateHead(expected: QueueEntry): Promise { + const claimed = await this.deps.queue.claimPreAdmissionFailureAcrossUsersDurable(expected.threadId, expected.id); + if (!claimed) return null; + if (claimed.kind !== 'private_input') { + await this.deps.queue.rollbackProcessingDurable(claimed.threadId, claimed.id); + throw new Error(`private pre-admission terminalization received ${claimed.kind}: ${claimed.id}`); + } + const removed = await this.deps.queue.removeProcessedAcrossUsersDurable( + claimed.threadId, + claimed.id, + 'failed', + 'invocation_failed', + ); + if (!removed) { + await this.deps.queue.rollbackProcessingDurable(claimed.threadId, claimed.id); + throw new Error(`private pre-admission terminalization lost claimed Queue entry: ${claimed.id}`); + } + this.deps.log.warn( + { + event: 'private_input_pre_admission_failed', + threadId: claimed.threadId, + entryId: claimed.id, + requestedTargets: [...queueEntryTargetCats(claimed)], + reason: 'invalid_explicit_target', + }, + '[QueueProcessor] private Queue head terminalized before admission', + ); + try { + await emitQueueUpdated( + this.deps.socketManager, + queueEntryOwnerId(claimed), + claimed.threadId, + this.deps.queue.list(claimed.threadId, queueEntryOwnerId(claimed)), + this.deps.messageStore, + 'pre_admission_failed', + ); + } catch (error) { + this.deps.log.error( + { error, threadId: claimed.threadId, entryId: claimed.id }, + '[QueueProcessor] private pre-admission terminal notification failed after exact removal', + ); + } + return removed; + } + /** * Execute a queue entry — mirrors messages.ts background invocation pipeline. * Creates InvocationRecord → tracker.start → route execution → complete → cleanup. @@ -3823,21 +3266,19 @@ export class QueueProcessor { processingReservation?: ProcessingSlotReservation, executionTargetCats?: readonly string[], exactBatchEntries: readonly QueueEntry[] = [], + conversationBatchResolution?: ConversationBatchResolution, ): Promise { const { queue, invocationTracker, invocationRecordStore, router, socketManager, messageStore, log } = this.deps; - const { threadId, userId, intent, messageId } = entry; - const targetCats = [...(executionTargetCats ?? entry.targetCats)]; + const threadId = entry.threadId; + const userId = queueEntryOwnerId(entry); + const intent = entry.execution.intent; + const messageId = entry.payload.messageId; + const targetCats = [...(executionTargetCats ?? queueEntryTargetCats(entry))]; const primaryCat = targetCats[0] ?? 'unknown'; - const exactA2ATargetCat = targetCats.length === 1 ? targetCats[0] : undefined; const batchedEntryIds: string[] = exactBatchEntries.map((candidate) => candidate.id); - const batchedMessageIds: string[] = exactBatchEntries.flatMap((candidate) => [ - ...(candidate.messageId ? [candidate.messageId] : []), - ...candidate.mergedMessageIds, - ]); - const custodyEntryIds = new Set(); - const legacyUnboundEntryIds = new Set(); - let content = [entry.content, ...exactBatchEntries.map((candidate) => candidate.content)].join('\n'); + const batchedMessageIds: string[] = exactBatchEntries.flatMap(queueEntryMessageIds); + let content = entry.payload.content; let controller: AbortController | undefined; let invocationId: string | undefined; @@ -3845,33 +3286,45 @@ export class QueueProcessor { let finalStatus: InvocationFinalStatus = 'failed'; let replayClaimLost = false; let processingReservationReplaced = false; - let deferredForBusyTarget = false; + let prestartClaimRestored = false; + let lifecycleTransferStarted = false; + let lifecycleReceiverPersisted = false; + let lifecycleQueueTargetsRetired = false; + let lifecycleClaimRestorePromise: Promise | undefined; const terminalDispositions = new PerCatTerminalDispositionCollector({ targetCatIds: targetCats, isCanceled: (catId) => invocationTracker.getSlotState?.(threadId, catId) === 'canceled', }); - const observedChildInvocationIdByCatId = new Map(); let responseText = ''; + // F122B B6: completion hooks are registered before drain; keep their + // target-partitioned response buffer alive through finally settlement. + const entryCompleteHooks = (this.entryCompleteHooks.get(entry.id) ?? []).filter( + (registration) => !registration.targetCatId || targetCats.includes(registration.targetCatId), + ); + const hookResponseTextByTarget = new Map(); const cursorBoundaries = new Map(); const continuationCapsules = new Map(); // Cloud Codex P2: track consumed continuation so we can re-store on failure/cancel. let consumedContinuation: ConsumedContinuationToken | undefined; - // Cloud Codex P2: defer A2A consumption to success path — entries stay in queue - // until the batch actually succeeds. The invocationTracker prevents double-pickup. - let deferredA2AConsume = new Set(); // R4 fix: hoist streamStartPromise above try so the catch block can await it // before calling onStreamEnd → cleanupPlaceholders (the correct failure cleanup // sequence per messages.ts cleanupStreamingOnFailure). let streamStartPromise: Promise | undefined; + let heartbeatInterval: ReturnType | undefined; let executionError: unknown; let freshnessSupplementOriginalMessageId: string | undefined; let freshnessSupplementRequiredMessageIds: string[] = []; - let supplementToolExecutionPolicy = entry.readOnlyToolPolicy; + let supplementToolExecutionPolicy = entry.execution.readOnlyToolPolicy; const bufferedSupplementMessages: unknown[] = []; let actionFencePreflightRejected = false; let actionFenceAggregateSucceeded = false; const actionFenceCommittedHolderCatIds = new Set(); const actionFenceOutputValidatedHolderCatIds = new Set(); + const lifecycleInputMessageIds = [ + ...(entry.payload.messageId ? [entry.payload.messageId] : []), + ...batchedMessageIds, + ]; + const lifecycleResponseMessageIds = new Set(); let returnedExecutionResult: QueueExecutionResult | undefined; const executionResult = (status: InvocationFinalStatus): QueueExecutionResult => { // Keep finally cleanup and the caller-visible completion status on one @@ -3884,31 +3337,56 @@ export class QueueProcessor { // entry. A bodyless routing guard can own the terminal stream event, but // it must never replace the ordinary child that actually read the Queue // body as the receipt witness. - const currentEntry = queue.getEntrySnapshot(threadId, userId, entry.id); const result: QueueExecutionResult = { status, - ...(deferredForBusyTarget ? { deferredForBusyTarget: true } : {}), ...(invocationId ? { invocationId } : {}), - successfulCatIds: status === 'succeeded' ? terminalDispositions.getSuccessfulCatIds() : [], attemptedQueueEntryIds: [entry.id, ...batchedEntryIds], - terminalInvocationIdByCatId: Object.fromEntries( - targetCats.flatMap((catId) => { - const exactInvocationId = - currentEntry?.queuedSeenInvocationIdByCatId?.[catId] ?? observedChildInvocationIdByCatId.get(catId); - return exactInvocationId ? [[catId, exactInvocationId]] : []; - }), - ), - terminalConsumptionByInvocationId: terminalDispositions.getTerminalConsumptionByInvocationId(), }; returnedExecutionResult = result; return result; }; + const cancelPrestartTargetSetConflict = async (message: string): Promise => { + if (!invocationId) throw new Error('pre-start target-set conflict requires an invocation record'); + finalStatus = 'canceled'; + const stillOwnsPrimaryReservation = Boolean( + processingReservation && + this.ownsProcessingSlotInvocation( + QueueProcessor.slotKey(threadId, primaryCat), + processingReservation, + invocationId, + ), + ); + if (stillOwnsPrimaryReservation) { + if (!(await queue.restoreClaimedEntries(threadId, [entry.id, ...batchedEntryIds]))) { + throw new Error('pre-start target-set conflict could not restore the exact Queue claim'); + } + prestartClaimRestored = true; + } else { + processingReservationReplaced = true; + } + log.info( + { + threadId, + entryId: entry.id, + invocationId, + reason: stillOwnsPrimaryReservation ? 'target_became_busy' : 'reservation_replaced', + }, + message, + ); + await invocationRecordStore.update(invocationId, { + status: 'canceled', + error: stillOwnsPrimaryReservation ? 'queue_target_became_busy' : 'queue_processing_reservation_replaced', + }); + const result = executionResult('canceled'); + if (stillOwnsPrimaryReservation) result.primarySettlementIncomplete = true; + return result; + }; const finalizeActionFenceOutcome = async ( outcome: 'failed' | 'canceled', hasResponse: boolean, catIds: readonly string[] = [primaryCat], ): Promise => { - const fence = entry.actionSuccessorFence; + const fence = entry.execution.actionSuccessorFence; if (!fence) return true; const leaseStore = this.deps.actionSuccessorLeaseStore; try { @@ -3957,7 +3435,7 @@ export class QueueProcessor { } }; const revalidateActionFenceForOutput = async (catId: string): Promise => { - const fence = entry.actionSuccessorFence; + const fence = entry.execution.actionSuccessorFence; if (!fence) return true; if (actionFenceOutputValidatedHolderCatIds.has(catId)) return true; const leaseStore = this.deps.actionSuccessorLeaseStore; @@ -4009,260 +3487,97 @@ export class QueueProcessor { actionFencePreflightRejected = true; log.info( { - threadId, - entryId: entry.id, - leaseId: fence.leaseId, - generation: fence.generation, - reason: preflight.reason, - }, - '[F167-S.1] action successor output preflight rejected', - ); - return false; - } catch (err) { - actionFencePreflightRejected = true; - log.error( - { err, threadId, entryId: entry.id, leaseId: fence.leaseId }, - '[F167-S.1] action successor output preflight failed; suppressing carrier response', - ); - return false; - } - }; - - try { - // Pre-start retirement is shared Queue truth, not an A2A convention. - // Inspect the complete reserved batch before any provider side effect so - // an old coroutine cannot re-enter after durable retirement completed. - const replayFenceCoordinator = this.deps.queueCustodyCoordinator; - const retirementFenceEntries = [entry, ...exactBatchEntries]; - if (replayFenceCoordinator?.inspectCarrierRetirementFence) { - let retirementFence: Awaited>; - try { - retirementFence = await replayFenceCoordinator.inspectCarrierRetirementFence({ - entries: retirementFenceEntries, - }); - } catch (err) { - log.error( - { err, threadId, entryIds: retirementFenceEntries.map((candidate) => candidate.id) }, - '[QueueProcessor] Queue retirement no-reentry preflight unavailable; refusing provider re-entry', - ); - return executionResult('failed'); - } - if (retirementFence.disposition === 'terminalized') { - const custodyChanges: Array<{ entryId: string; changed: boolean }> = []; - try { - for (const retiredEntry of retirementFenceEntries) { - custodyChanges.push({ - entryId: retiredEntry.id, - changed: await replayFenceCoordinator.withdrawEntry(retiredEntry), - }); - } - } catch (err) { - log.error( - { err, threadId, entryIds: retirementFenceEntries.map((candidate) => candidate.id) }, - '[QueueProcessor] Queue terminal retirement write failed; refusing provider re-entry', - ); - return executionResult('failed'); - } - log.info( - { - threadId, - entryIds: retirementFenceEntries.map((candidate) => candidate.id), - targetCats, - sourceMessageIds: retirementFence.sourceMessageIds, - custodyChanged: custodyChanges.some((outcome) => outcome.changed), - custodyChanges, - }, - '[QueueProcessor] reconciled terminalized Queue carrier at no-reentry preflight', - ); - return executionResult('succeeded'); - } - } - - // A terminal response may have committed only part of a coalesced carrier - // before persistence failed. Once any exact source has durable target - // terminal truth, re-running the provider would duplicate side effects. - // Retire this target mechanically; no prompt/intent classifier participates. - if (entry.sourceCategory === 'a2a' && exactA2ATargetCat && replayFenceCoordinator?.inspectTargetReplayFence) { - let replayFence: Awaited>; - try { - replayFence = await replayFenceCoordinator.inspectTargetReplayFence({ - entry, - targetCatId: exactA2ATargetCat, - }); - } catch (err) { - log.error( - { err, threadId, entryId: entry.id, catId: exactA2ATargetCat }, - '[QueueProcessor] A2A no-reentry preflight unavailable; refusing provider re-entry', - ); - return executionResult('failed'); - } - if (replayFence.disposition === 'terminalized') { - let custodyChanged: boolean; - try { - custodyChanged = await replayFenceCoordinator.withdrawEntry({ - ...entry, - targetCats: [exactA2ATargetCat], - }); - } catch (err) { - log.error( - { err, threadId, entryId: entry.id, catId: exactA2ATargetCat }, - '[QueueProcessor] A2A terminal retirement write failed; refusing provider re-entry', - ); - return executionResult('failed'); - } - log.info( - { - threadId, - entryId: entry.id, - catId: exactA2ATargetCat, - sourceMessageIds: replayFence.sourceMessageIds, - priorInvocationId: replayFence.invocationId, - custodyChanged, - custodyChanges: [{ entryId: entry.id, changed: custodyChanged }], - }, - '[QueueProcessor] reconciled terminalized Queue carrier at no-reentry preflight', - ); - return executionResult('succeeded'); - } - } - - // F167: Queue FIFO can outlive its exact A2A handoff. Reuse the same - // source/event fence as callback completion so a structurally replaced - // carrier never starts a provider invocation merely to fail at stop-gate. - const a2aDispositionService = this.deps.a2aDispatchDispositionService; - if (entry.sourceCategory === 'a2a' && entry.a2aTriggerMessageId && exactA2ATargetCat && a2aDispositionService) { - const sourceMessageIds = exactA2ASourceMessageIds(entry); - let inspections: Array>> | undefined; - try { - inspections = await Promise.all( - sourceMessageIds.map((sourceMessageId) => - a2aDispositionService.inspectHandoff({ - threadId, - catId: exactA2ATargetCat, - sourceMessageId, - }), - ), - ); - } catch (err) { - log.warn( - { err, threadId, entryId: entry.id, sourceMessageIds }, - '[F167] A2A replacement preflight unavailable; preserving existing callback fence', - ); - } - if (inspections?.every((inspection) => inspection.outcome === 'replaced')) { - try { - const custody = await this.ensureAttemptMessageCustody(entry); - if (custody === 'durable') { - if (!this.deps.queueCustodyCoordinator) { - throw new Error('queue custody coordinator unavailable for replaced A2A carrier'); - } - if (!(await this.deps.queueCustodyCoordinator.withdrawEntry(entry))) { - throw new Error('replaced A2A carrier custody did not transition'); - } - } - log.info( - { - threadId, - entryId: entry.id, - sourceMessageIds, - replacements: inspections.map((inspection) => - inspection.outcome === 'replaced' ? inspection.replacement : undefined, - ), - }, - '[F167] retired replaced A2A carrier at queue preflight', - ); - return executionResult('succeeded'); - } catch (err) { - log.error( - { err, threadId, entryId: entry.id, sourceMessageIds }, - '[F167] failed to retire replaced A2A carrier; retaining Queue custody', - ); - return executionResult('failed'); - } - } + threadId, + entryId: entry.id, + leaseId: fence.leaseId, + generation: fence.generation, + reason: preflight.reason, + }, + '[F167-S.1] action successor output preflight rejected', + ); + return false; + } catch (err) { + actionFencePreflightRejected = true; + log.error( + { err, threadId, entryId: entry.id, leaseId: fence.leaseId }, + '[F167-S.1] action successor output preflight failed; suppressing carrier response', + ); + return false; } + }; + try { // F167 Phase S: a queue row is only a carrier. The durable action lease owns // successor cardinality; fail closed before creating an invocation when its // generation was replaced or the external subject reached terminal truth. - if (entry.actionSuccessorFence) { + if (entry.execution.actionSuccessorFence) { const leaseStore = this.deps.actionSuccessorLeaseStore; if (!leaseStore) { log.error( - { threadId, entryId: entry.id, leaseId: entry.actionSuccessorFence.leaseId }, + { threadId, entryId: entry.id, leaseId: entry.execution.actionSuccessorFence.leaseId }, '[F167-S] action successor lease store unavailable; canceling fenced queue entry', ); actionFencePreflightRejected = true; finalStatus = 'canceled'; - await this.cancelMessageIds( - [entry.messageId ?? '', ...(entry.mergedMessageIds ?? [])], - log, - 'start_preflight_store_unavailable', - ); + await this.cancelMessageIds(queueEntryMessageIds(entry), log, 'start_preflight_store_unavailable'); return executionResult('canceled'); } try { const preflight = await leaseStore.preflight( - entry.actionSuccessorFence.leaseId, - entry.actionSuccessorFence.generation, - entry.actionSuccessorFence.terminalPredicateDigest, + entry.execution.actionSuccessorFence.leaseId, + entry.execution.actionSuccessorFence.generation, + entry.execution.actionSuccessorFence.terminalPredicateDigest, ); if (!preflight.ok) { log.info( { threadId, entryId: entry.id, - leaseId: entry.actionSuccessorFence.leaseId, - generation: entry.actionSuccessorFence.generation, + leaseId: entry.execution.actionSuccessorFence.leaseId, + generation: entry.execution.actionSuccessorFence.generation, reason: preflight.reason, }, '[F167-S] action successor canceled at queue preflight', ); actionFencePreflightRejected = true; finalStatus = 'canceled'; - await this.cancelMessageIds( - [entry.messageId ?? '', ...(entry.mergedMessageIds ?? [])], - log, - 'start_preflight_rejected', - ); + await this.cancelMessageIds(queueEntryMessageIds(entry), log, 'start_preflight_rejected'); return executionResult('canceled'); } } catch (err) { log.error( - { err, threadId, entryId: entry.id, leaseId: entry.actionSuccessorFence.leaseId }, + { err, threadId, entryId: entry.id, leaseId: entry.execution.actionSuccessorFence.leaseId }, '[F167-S] action successor preflight failed; canceling fenced queue entry', ); actionFencePreflightRejected = true; finalStatus = 'canceled'; - await this.cancelMessageIds( - [entry.messageId ?? '', ...(entry.mergedMessageIds ?? [])], - log, - 'start_preflight_error', - ); + await this.cancelMessageIds(queueEntryMessageIds(entry), log, 'start_preflight_error'); return executionResult('canceled'); } } // 1. Create InvocationRecord (before batching — avoid claiming entries on duplicate) - // Connector-sourced entries use connector-${messageId} to match the direct-execution - // idempotency path, so retries after queue processing are also caught persistently. - const retryAttemptId = targetCats.length === 1 ? entry.queuedAttemptIdByCatId?.[targetCats[0]!] : undefined; + // Invocation identity is source × target: one source row can dispatch its + // pending targets independently without treating a sibling as a replay. + const source = queueEntrySource(entry); + const connectorReplayCarrier = source === 'connector' || entry.sourceCategory === 'scheduled'; + const actionSuccessorKey = + entry.execution.actionSuccessorFence && entry.payload.sourceRecordId + ? actionSuccessorInvocationIdempotencyKey(entry.payload.sourceRecordId) + : undefined; const idempotencyKey = - retryAttemptId ?? - (entry.exactSteerBatch - ? `queue-exact-steer-${entry.exactSteerBatch.reservationId}` - : entry.source === 'connector' && messageId - ? `connector-${messageId}` - : entry.actionSuccessorFence && entry.idempotencyKey - ? actionSuccessorInvocationIdempotencyKey(entry.idempotencyKey) - : entry.requiresExactCloudDispatchProvenance && entry.idempotencyKey - ? `cloud-dispatch:${entry.idempotencyKey}` - : `queue-${entry.id}-${entry.processingStartedAt ?? entry.createdAt}`); - const actionLeaseCarrier: InvocationActionLeaseCarrier = entry.actionSuccessorFence + connectorReplayCarrier && messageId + ? `connector-${messageId}:${primaryCat}` + : actionSuccessorKey + ? actionSuccessorKey.endsWith(`:${primaryCat}`) + ? actionSuccessorKey + : `${actionSuccessorKey}:${primaryCat}` + : `queue-${entry.id}:${primaryCat}`; + const actionLeaseCarrier: InvocationActionLeaseCarrier = entry.execution.actionSuccessorFence ? { kind: 'action_successor', - leaseId: entry.actionSuccessorFence.leaseId, - generation: entry.actionSuccessorFence.generation, + leaseId: entry.execution.actionSuccessorFence.leaseId, + generation: entry.execution.actionSuccessorFence.generation, } : { kind: 'none' }; const createResult = await invocationRecordStore.create({ @@ -4272,16 +3587,15 @@ export class QueueProcessor { intent, idempotencyKey, actionLeaseCarrier, - ...(entry.waitContinuationCarrier ? { waitContinuationCarrier: entry.waitContinuationCarrier } : {}), + ...(entry.execution.waitContinuationCarrier + ? { waitContinuationCarrier: entry.execution.waitContinuationCarrier } + : {}), }); invocationId = createResult.invocationId; if (createResult.outcome === 'duplicate') { const replayEligible = - Boolean(retryAttemptId) || - (entry.source === 'connector' && Boolean(messageId)) || - Boolean(entry.actionSuccessorFence) || - Boolean(entry.requiresExactCloudDispatchProvenance); + (connectorReplayCarrier && Boolean(messageId)) || Boolean(entry.execution.actionSuccessorFence); const existing = replayEligible && invocationRecordStore.get ? await invocationRecordStore.get(invocationId) : null; if ( @@ -4292,12 +3606,16 @@ export class QueueProcessor { intent, idempotencyKey, actionLeaseCarrier, - ...(entry.waitContinuationCarrier ? { waitContinuationCarrier: entry.waitContinuationCarrier } : {}), + ...(entry.execution.waitContinuationCarrier + ? { waitContinuationCarrier: entry.execution.waitContinuationCarrier } + : {}), }) ) { log.warn({ threadId, entryId: entry.id }, '[QueueProcessor] Duplicate invocation, skipping'); - if (entry.freshnessSupplementId && this.deps.freshnessClosureStore) { - const supplement = await this.deps.freshnessClosureStore.getSupplement(entry.freshnessSupplementId); + if (entry.execution.freshnessSupplementId && this.deps.freshnessClosureStore) { + const supplement = await this.deps.freshnessClosureStore.getSupplement( + entry.execution.freshnessSupplementId, + ); if (supplement?.status === 'pending') { const failed = await this.deps.freshnessClosureStore.failSupplement(supplement.id, { reason: 'infrastructure', @@ -4306,6 +3624,11 @@ export class QueueProcessor { this.broadcastFreshnessSupplement(failed); } } + // This attempt did not create, replay, or run the duplicate invocation. + // Never forward another owner's invocationId into onInvocationComplete: + // exact queued_seen evidence may belong to that invocation and would be + // falsely settled by this carrier-only retirement. + invocationId = undefined; finalStatus = 'succeeded'; return executionResult('succeeded'); } @@ -4324,22 +3647,14 @@ export class QueueProcessor { invocationId, ) ) { - processingReservationReplaced = true; - log.info( - { threadId, entryId: entry.id, invocationId }, - '[QueueProcessor] canceled pre-start execution after processing reservation was replaced', + return cancelPrestartTargetSetConflict( + '[QueueProcessor] canceled pre-start execution after processing reservation changed', ); - await invocationRecordStore.update(invocationId, { - status: 'canceled', - error: 'queue_processing_reservation_replaced', - }); - finalStatus = 'canceled'; - return executionResult('canceled'); } // ADR-042: a supplement queue row is only a carrier projection. Resolve and // claim the exact durable sequence before launching any model. - if (entry.freshnessSupplementId) { + if (entry.execution.freshnessSupplementId) { const supplementStore = this.deps.freshnessClosureStore; if (!supplementStore) { await invocationRecordStore.update(invocationId, { @@ -4349,7 +3664,7 @@ export class QueueProcessor { finalStatus = 'failed'; return executionResult('failed'); } - if (entry.freshnessClosureId) { + if (entry.execution.freshnessClosureId) { await invocationRecordStore.update(invocationId, { status: 'failed', error: 'queue entry cannot carry both freshness closure and supplement identities', @@ -4357,35 +3672,26 @@ export class QueueProcessor { finalStatus = 'failed'; return executionResult('failed'); } - const supplement = await supplementStore.getSupplement(entry.freshnessSupplementId); + const supplement = await supplementStore.getSupplement(entry.execution.freshnessSupplementId); // External replacement retires the exact Queue reservation synchronously, but // `getSupplement()` can still be awaiting durable state when that happens. Fence // immediately after the await, before interpreting or mutating supplement truth; // otherwise this stale coroutine can report a generic carrier cancellation (or // race a claim) instead of closing its own InvocationRecord as superseded. - const startBlocker = processingReservation - ? this.reservedTargetStartBlocker(threadId, targetCats, primaryCat, processingReservation, invocationId) - : undefined; - if (startBlocker) { - processingReservationReplaced = startBlocker === 'reservation_replaced'; - deferredForBusyTarget = startBlocker === 'target_busy'; - log.info( - { threadId, entryId: entry.id, invocationId, startBlocker }, - '[QueueProcessor] deferred pre-start execution at target ownership fence', + if ( + processingReservation && + !this.canStartReservedTargetSet(threadId, targetCats, primaryCat, processingReservation, invocationId) + ) { + return cancelPrestartTargetSetConflict( + '[QueueProcessor] canceled supplement preflight after its target-set fence changed', ); - await invocationRecordStore.update(invocationId, { - status: 'canceled', - error: processingReservationReplaced ? 'queue_processing_reservation_replaced' : 'queue_target_busy', - }); - finalStatus = 'canceled'; - return executionResult('canceled'); } if (!supplement || supplement.status !== 'pending') { log.info( { threadId, entryId: entry.id, - supplementId: entry.freshnessSupplementId, + supplementId: entry.execution.freshnessSupplementId, status: supplement?.status ?? 'missing', }, '[F254] supplement carrier canceled at preflight', @@ -4399,8 +3705,8 @@ export class QueueProcessor { supplement.threadId !== threadId || supplement.catId !== primaryCat || targetCats.length !== 1 || - entry.freshnessSupplementLineageId !== supplement.lineageId || - entry.freshnessSupplementSeq !== supplement.seq; + entry.execution.freshnessSupplementLineageId !== supplement.lineageId || + entry.execution.freshnessSupplementSeq !== supplement.seq; if (carrierMismatch) { const failed = await supplementStore.failSupplement(supplement.id, { reason: 'infrastructure', @@ -4416,7 +3722,7 @@ export class QueueProcessor { finalStatus = 'failed'; return executionResult('failed'); } - if (entry.readOnlyToolPolicy?.mode !== 'read_only') { + if (entry.execution.readOnlyToolPolicy?.mode !== 'read_only') { const failed = await supplementStore.failSupplement(supplement.id, { reason: 'read_only_policy_unavailable', now: Date.now(), @@ -4552,7 +3858,7 @@ export class QueueProcessor { // F254 Phase E: a queue row is only scheduling coverage. Before model execution, // atomically adopt the persistent closure and rebuild the prompt from current truth. - if (entry.freshnessClosureId) { + if (entry.execution.freshnessClosureId) { const closureStore = this.deps.freshnessClosureStore; if (!closureStore) { await invocationRecordStore.update(invocationId, { @@ -4562,11 +3868,16 @@ export class QueueProcessor { finalStatus = 'failed'; return executionResult('failed'); } - const closure = await closureStore.get(entry.freshnessClosureId); + const closure = await closureStore.get(entry.execution.freshnessClosureId); if (!closure || closure.status !== 'pending') { recordFreshnessSuccessorPreflightCanceled(closure?.status ?? 'missing'); log.info( - { threadId, entryId: entry.id, closureId: entry.freshnessClosureId, status: closure?.status ?? 'missing' }, + { + threadId, + entryId: entry.id, + closureId: entry.execution.freshnessClosureId, + status: closure?.status ?? 'missing', + }, '[F254-E] closure successor canceled at preflight', ); await invocationRecordStore.update(invocationId, { status: 'canceled' }); @@ -4765,56 +4076,19 @@ export class QueueProcessor { } } - // F175: user-message batching — collect adjacent matching entries - // Placed after idempotency check so batched entries aren't dropped on duplicate - if ( - entry.source === 'user' && - !entry.queuedFailedByCatIds?.length && - !entry.exactSteerBatch && - !entry.steerRequestedByCatIds?.length - ) { - const batch = queue.collectUserBatch(threadId, userId); - const sortedTargets = [...entry.targetCats].sort(); - const matching = batch.filter( - (e) => - e.source === 'user' && - !e.queuedFailedByCatIds?.length && - !e.steerRequestedByCatIds?.length && - e.intent === entry.intent && - e.ownerAuthProvenance === entry.ownerAuthProvenance && - e.targetCats.length === sortedTargets.length && - [...e.targetCats].sort().every((t, i) => t === sortedTargets[i]), - ); - for (const be of matching) { - if (!queue.markProcessingById(threadId, be.id)) continue; - batchedEntryIds.push(be.id); - if (be.messageId) batchedMessageIds.push(be.messageId); - content = content + '\n' + be.content; - } - } - // F194 R7: freshness/action carrier preflight can await after the reservation // binds. Re-fence the complete target set immediately before tracker // registration: the primary must still own its exact reservation, and every // secondary target must still be free. The first attempt is synchronous; if a // session-seal CAS rejects admission, the retry path below re-fences the exact // processing reservation after waiting for the guard to release. - const startBlocker = processingReservation - ? this.reservedTargetStartBlocker(threadId, targetCats, primaryCat, processingReservation, invocationId) - : undefined; - if (startBlocker) { - processingReservationReplaced = startBlocker === 'reservation_replaced'; - deferredForBusyTarget = startBlocker === 'target_busy'; - log.info( - { threadId, entryId: entry.id, invocationId, startBlocker }, - '[QueueProcessor] deferred pre-start execution at target ownership fence', + if ( + processingReservation && + !this.canStartReservedTargetSet(threadId, targetCats, primaryCat, processingReservation, invocationId) + ) { + return cancelPrestartTargetSetConflict( + '[QueueProcessor] canceled pre-start execution after its target-set fence changed', ); - await invocationRecordStore.update(invocationId, { - status: 'canceled', - error: processingReservationReplaced ? 'queue_processing_reservation_replaced' : 'queue_target_busy', - }); - finalStatus = 'canceled'; - return executionResult('canceled'); } // 2. Start tracking ALL target cats (shared controller for F5/reconnect recovery) @@ -4825,22 +4099,13 @@ export class QueueProcessor { '[QueueProcessor] queued admission parked behind session-seal CAS', ); await invocationTracker.waitForSessionSealRelease(threadId, targetCats); - const startBlocker = processingReservation - ? this.reservedTargetStartBlocker(threadId, targetCats, primaryCat, processingReservation, invocationId) - : undefined; - if (startBlocker) { - processingReservationReplaced = startBlocker === 'reservation_replaced'; - deferredForBusyTarget = startBlocker === 'target_busy'; - log.info( - { threadId, entryId: entry.id, invocationId, startBlocker }, - '[QueueProcessor] deferred pre-start execution at target ownership fence', + if ( + processingReservation && + !this.canStartReservedTargetSet(threadId, targetCats, primaryCat, processingReservation, invocationId) + ) { + return cancelPrestartTargetSetConflict( + '[QueueProcessor] canceled parked execution after its target-set fence changed', ); - await invocationRecordStore.update(invocationId, { - status: 'canceled', - error: processingReservationReplaced ? 'queue_processing_reservation_replaced' : 'queue_target_busy', - }); - finalStatus = 'canceled'; - return executionResult('canceled'); } controller = invocationTracker.startAll(threadId, targetCats, userId, invocationId) ?? undefined; } @@ -4852,7 +4117,7 @@ export class QueueProcessor { // routeExecution — the follow-up is already queued and will run after this returns. // // Status: 'canceled_by_user' (not plain 'canceled') so onInvocationComplete normally - // takes the immediate-restart branch (tryAutoExecute) rather than the 10s pause + // takes the immediate-restart branch (requestDrain) rather than the 10s delay // branch. If cancelAll/force-reset currently owns the slot, its suppression wins // and the follow-up remains queued; otherwise it restarts after slot release. if (!queue.list(threadId, userId).some((e) => e.id === entry.id)) { @@ -4892,9 +4157,8 @@ export class QueueProcessor { } this.routeChainTracker.start(invocationId); - // F220 Phase 1: queued execution needs the same earliest liveness signal - // as direct /api/messages execution. intent_mode stays deferred until the - // first CLI event (#768); spawn_started is only "process is being spawned". + // F220 Phase 1: intent_mode stays deferred until the first CLI event (#768); + // spawn_started is only "process is being spawned". if (!controller.signal.aborted) { socketManager.broadcastToRoom(`thread:${threadId}`, 'spawn_started', { threadId, @@ -4906,81 +4170,14 @@ export class QueueProcessor { // 5. intent_mode deferred to first CLI event (#768: avoid "replying" when CLI never starts) let intentModeBroadcast = false; - // 6. Emit queue_updated (processing) - await emitQueueUpdated(socketManager, userId, threadId, queue.list(threadId, userId), messageStore, 'processing'); - - // F098-D: Mark queued messages as delivered (set deliveredAt = now) - // F117: Collect full message objects for frontend bubble rendering - const allMessageIds: string[] = [messageId ?? '', ...(entry.mergedMessageIds ?? []), ...batchedMessageIds].filter( - Boolean, - ); - const currentContextMessageIds = new Set(allMessageIds); - const deliveredNow = Date.now(); for (const queueEntryId of [entry.id, ...batchedEntryIds]) { const queueEntry = queue.getEntrySnapshot(threadId, userId, queueEntryId) ?? (queueEntryId === entry.id ? entry : null); if (queueEntry) { - const custody = await this.ensureAttemptMessageCustody(queueEntry); - if (custody === 'durable') custodyEntryIds.add(queueEntryId); - if (custody === 'legacy_unbound') legacyUnboundEntryIds.add(queueEntryId); - } - } - await this.markDeliveredAndEmit(userId, threadId, allMessageIds, deliveredNow); - - // 6b. #815: Consume redundant A2A trigger entries — if target cats are - // already being processed in this batch, queued A2A entries for those cats - // are pure triggers whose source messages are already visible in context. - // Two-step: find candidates, then async-filter by message delivery status. - // Text-scan A2A entries reference persisted agent messages (deliveryStatus - // undefined/delivered → safe to consume). Callback A2A entries reference - // messages with deliveryStatus:'queued' → NOT safe (message not yet delivered). - const activeCatSet = new Set(targetCats); - const a2aCandidates = queue.findSubsumedA2ACandidates(threadId, userId, activeCatSet); - if (a2aCandidates.length > 0) { - const safeToConsume = new Set(); - for (const candidate of a2aCandidates) { - if (!candidate.messageId) continue; // no message ref → conservative, skip - const candidateMessageIds = [candidate.messageId, ...(candidate.mergedMessageIds ?? [])]; - if (!candidateMessageIds.every((mid) => currentContextMessageIds.has(mid))) { - continue; // delivered historical trigger, but not part of this invocation context - } - const msg = await messageStore.getById(candidate.messageId); - if (!msg) continue; // message not found → skip - if (msg.deliveryStatus === 'queued') continue; // not yet delivered → don't consume - // Cloud Codex P2: also check mergedMessageIds — coalesced entries can - // have additional trigger messages that are still queued (e.g. a callback - // post_message coalesced into a text-scan A2A entry). If ANY merged - // trigger is still queued, don't consume the entry. - let mergedSafe = true; - if (candidate.mergedMessageIds?.length) { - for (const mid of candidate.mergedMessageIds) { - const mergedMsg = await messageStore.getById(mid); - if (mergedMsg?.deliveryStatus === 'queued') { - mergedSafe = false; - break; - } - } - } - if (!mergedSafe) continue; - safeToConsume.add(candidate.id); - } - if (safeToConsume.size > 0) { - // Cloud Codex P2: defer actual removal to the success path in `finally`. - // If the batch fails/cancels, entries stay in queue for retry. - // invocationTracker prevents double-pickup during execution. - deferredA2AConsume = safeToConsume; - log.info( - { - threadId, - deferredCount: safeToConsume.size, - deferredIds: [...safeToConsume], - }, - '[QueueProcessor] #815: identified subsumed A2A entries (deferred to success)', - ); + await this.ensureAttemptMessageCustody(queueEntry); } } - - // 6c. F224: single-cat continuation lifecycle is owned by + // 6b. F224: single-cat continuation lifecycle is owned by // SessionContinuationCoordinator. Multi-target still skips prepare because // content is shared across cats; a cat-specific continuation prompt would leak. if (this.sessionContinuationCoordinator && targetCats.length === 1) { @@ -5028,7 +4225,7 @@ export class QueueProcessor { const capsule = prepared.consumedContinuation.capsule; const sameQueuedContinuation = entry.sourceCategory === 'continuation' && - entry.continuationKey === QueueProcessor.continuationKey(capsule); + entry.payload.sourceRecordId === QueueProcessor.continuationKey(capsule); if (sameQueuedContinuation) { content = originalContent; } @@ -5071,10 +4268,9 @@ export class QueueProcessor { let currentTurnCatId: string | undefined; // F039 remaining: queued image messages must be visible to cats. - // Aggregate contentBlocks from the stored user messages (messageId + merged). - const messageIds = [ - ...new Set([messageId ?? '', ...(entry.mergedMessageIds ?? []), ...batchedMessageIds].filter(Boolean)), - ]; + // Preserve every ledger row as a separate persisted prompt message; never + // concatenate independent authors or message identities into one body. + const messageIds = [...new Set([messageId ?? '', ...batchedMessageIds].filter(Boolean))]; const contentBlocks: MessageContent[] = []; const persistedPromptMessages: PersistedPromptMessage[] = []; const asrPersonMemoryScenes: Array< @@ -5143,17 +4339,10 @@ export class QueueProcessor { ); } } - if (persistedPromptMessages.length > 0) { - content = persistedPromptMessages.map((message) => message.content).join('\n'); - } - - // F122B B6: Collect response text for completion hook (multi-mention aggregation). - const hook = this.entryCompleteHooks.get(entry.id); - // F088 fix: start streaming placeholder on external platforms - if (this.deps.streamingHook && !entry.actionSuccessorFence && !entry.freshnessSupplementId) { + if (this.deps.streamingHook && !entry.execution.actionSuccessorFence && !entry.execution.freshnessSupplementId) { streamStartPromise = this.deps.streamingHook - .onStreamStart(threadId, primaryCat, invocationId, entry.senderMeta) + .onStreamStart(threadId, primaryCat, invocationId, queueEntrySenderMeta(entry)) .catch((err) => { log.warn({ err, threadId }, '[QueueProcessor] StreamingHook.onStreamStart failed'); }); @@ -5182,38 +4371,76 @@ export class QueueProcessor { let memoryCueOpportunitySeeds: MemoryCueOpportunitySeed[] = []; try { memoryCueOpportunitySeeds = await readTrustedConnectorMemoryCueSeeds({ - entrySource: entry.source, - messageId, + entrySource: queueEntrySource(entry), + messageId: messageId ?? null, expectedThreadId: threadId, expectedUserId: userId, - expectedTargetCatIds: targetCats, messageStore, }); } catch (err) { log.warn({ err, threadId, entryId: entry.id }, '[F287] connector Cue carrier read failed closed'); } - // Keep primary-trigger receipt presentation hidden. Exact body exposure - // is bound later by invokeSingleCat, after its durable child exists and - // immediately before provider startup. - if (entry.source !== 'agent') { - await this.deps.queueCustodyCoordinator?.markPrimaryTrigger(entry); + const callerDispatchProjectionByCat = new Map(); + const callerDispatchProcessStartPromptCats = new Set(); + const callerDispatchPromptByCat: Record = {}; + for (const catId of targetCats) { + const scope = { + ownerId: userId, + threadId, + callerCatId: catId, + }; + const processStart = this.callerDispatchProcessStart + ? this.callerDispatchObservations.projectProcessStartNotice( + scope, + this.callerDispatchProcessStart.processGenerationId, + ) + : undefined; + const projection = await this.callerDispatchObservations.project(messageStore, scope); + callerDispatchProjectionByCat.set(catId, projection); + if (processStart?.prompt) callerDispatchProcessStartPromptCats.add(catId); + const prompt = [processStart?.prompt, projection.prompt].filter(Boolean).join('\n\n'); + if (prompt) callerDispatchPromptByCat[catId] = prompt; } + const admissionEntries = [entry.id, ...batchedEntryIds].map((entryId) => { + const current = queue.getEntrySnapshot(threadId, userId, entryId); + if (!current || current.status !== 'claimed') { + throw new Error(`Queue admission requires one exact claimed owner: ${entryId}`); + } + return current; + }); + lifecycleTransferStarted = true; + await this.admitQueueEntriesForProvider(admissionEntries); + + const HEARTBEAT_INTERVAL_MS = 30_000; + heartbeatInterval = setInterval(() => { + socketManager.broadcastToRoom(`thread:${threadId}`, 'heartbeat', { + threadId, + timestamp: Date.now(), + }); + }, HEARTBEAT_INTERVAL_MS); + heartbeatInterval.unref(); + for await (const msg of router.routeExecution( userId, content, threadId, - entry.cloudDispatchProvenance?.sourceMessageId ?? messageId, + entry.execution.cloudDispatchProvenance?.sourceMessageId ?? messageId ?? null, targetCats, - { intent, ...(entry.suggestedSkill ? { promptTags: [`skill:${entry.suggestedSkill}`] } : {}) }, { - ownerAuthProvenance: entry.ownerAuthProvenance, - ...(entry.executionScope ? { executionScope: entry.executionScope } : {}), + intent, + ...(entry.execution.suggestedSkill ? { promptTags: [`skill:${entry.execution.suggestedSkill}`] } : {}), + }, + { + ownerAuthProvenance: entry.execution.ownerAuthProvenance, humanDispositionInvocationOrigin: 'queue_replay', - routingQueueSource: entry.source, + routingQueueSource: source, ...(memoryCueOpportunitySeeds.length > 0 ? { memoryCueOpportunitySeeds } : {}), ...(asrPersonMemoryScenes.length > 0 ? { asrPersonMemoryScenes } : {}), + ...(Object.keys(callerDispatchPromptByCat).length > 0 + ? { modeSystemPromptByCat: callerDispatchPromptByCat } + : {}), turnCustodyWakeForCat: (catId: string) => retargetTurnCustodyWake(turnCustodyWake, catId), ...(contentBlocks.length > 0 ? { contentBlocks } : {}), ...(controller.signal ? { signal: controller.signal } : {}), @@ -5227,22 +4454,52 @@ export class QueueProcessor { // abort (cancelAll / force / thread-delete), never on single-cat cancel — the sibling // keeps streaming. (See InvocationTracker.startAll returning a fresh batchController.) signalForCat: (catId: string) => invocationTracker.getController?.(threadId, catId)?.signal, - queueHasQueuedMessages: (tid: string) => queue.hasQueuedNonAgentForThread(tid), getQueuedFreshnessMessagesForCat: (tid: string, uid: string, catId: string, parentInvocationId?: string) => queue.getQueuedFreshnessMessagesForCat(tid, uid, catId, { excludeEntryId: entry.id, parentInvocationId }), - deferA2AEnqueue: (e: Parameters>[0]) => - queue.enqueue({ ...e, ownerAuthProvenance: entry.ownerAuthProvenance }), + commitCompletedA2AWake: (input: Parameters>[0]) => + commitCompletedResponseAndEnqueueA2ATargets( + { + socketManager: this.deps.socketManager, + invocationTracker: this.deps.invocationTracker, + ...(this.deps.deliveryCursorStore ? { deliveryCursorStore: this.deps.deliveryCursorStore } : {}), + queueProcessor: this, + messageStore, + invocationQueue: queue, + log, + }, + input, + ), + commitFailedA2AReport: (input: Parameters>[0]) => + commitFailedResponseAndEnqueueA2ACaller( + { + socketManager: this.deps.socketManager, + invocationTracker: this.deps.invocationTracker, + ...(this.deps.deliveryCursorStore ? { deliveryCursorStore: this.deps.deliveryCursorStore } : {}), + queueProcessor: this, + messageStore, + invocationQueue: queue, + log, + }, + input, + ), + ...(entry.sourceCategory === 'a2a_failure' ? { a2aFailureReport: true } : {}), // F254 B3: freshness re-invoke enqueue — strips freshnessContext before queueing // (queue only stores standard QueueEntry fields; context is for event-log correlation). freshnessReinvokeEnqueue: (e: any) => { const { freshnessContext: _ctx, ...queueFields } = e; - return queue.enqueue({ ...queueFields, ownerAuthProvenance: entry.ownerAuthProvenance }); + return queue.enqueueDurable({ + ...queueFields, + kind: 'private_input', + ownerAuthProvenance: entry.execution.ownerAuthProvenance, + sourceId: + queueFields.idempotencyKey ?? + queueFields.freshnessSupplementId ?? + queueFields.freshnessClosureId ?? + `freshness-reinvoke:${queueFields.threadId}:${queueFields.from.catId}:${queueFields.freshnessContext?.sourceNoticeIds?.join(',') ?? ''}`, + }); }, - hasQueuedOrActiveAgentForCat: (tid: string, catId: string) => - queue.hasActiveOrQueuedAgentForCat(tid, catId, { excludeEntryId: entry.id }), hasPendingForCat: (tid: string, uid: string, catId: string) => queue.hasPendingForCat(tid, catId, { excludeEntryId: entry.id, userId: uid }), - ...createA2ASlotTrackingBridge(invocationTracker, controller, invocationId), cursorBoundaries, persistenceContext, ...(invocationId ? { parentInvocationId: invocationId } : {}), @@ -5251,39 +4508,274 @@ export class QueueProcessor { // an explicit empty/partial array prevents the aggregate raw batch text // from becoming either a prompt fallback or durable exposure evidence. persistedPromptMessages, + targetDispatchMode: targetCats.length > 1 ? 'parallel' : 'serial', + onLifecycleInvocationStarted: async ( + input: Parameters>[0], + ) => { + let interruptLifecycleResponse: ((reason: string) => Promise) | undefined; + let lifecycleResponseInterrupted = false; + let lifecycleTargetRetired = false; + try { + const lifecycleInputMessages = ( + await Promise.all(messageIds.map((inputMessageId) => messageStore.getById(inputMessageId))) + ).filter((message): message is StoredMessage => + Boolean( + message && + isTimelinePublished(message) && + message.visibility !== 'whisper' && + !message.recall && + !message._tombstone, + ), + ); + const lifecycleReplyToCandidate = entry.execution.a2aTriggerMessageId ?? messageId; + const lifecycleReplyTo = lifecycleInputMessages.some( + (message) => message.id === lifecycleReplyToCandidate, + ) + ? lifecycleReplyToCandidate + : undefined; + const observed = await messageStore.appendAndObservePriorFrontier({ + from: { kind: 'agent', catId: input.catId }, + userId: input.userId, + content: '', + mentions: [], + origin: 'stream', + timestamp: input.startedAt, + threadId: input.threadId, + ...(lifecycleReplyTo ? { replyTo: lifecycleReplyTo } : {}), + idempotencyKey: `message-lifecycle-response:${input.invocationId}`, + extra: { + stream: { + invocationId: input.parentInvocationId, + turnInvocationId: input.invocationId, + }, + }, + lifecycle: { + kind: 'response', + orderKey: `${input.startedAt}:${input.invocationId}`, + invocationId: input.invocationId, + targetId: input.catId, + inputEntryIds: admissionEntries.map((candidate) => candidate.id), + inputMessageIds: lifecycleInputMessages.map((message) => message.id), + status: 'processing', + startedAt: input.startedAt, + }, + }); + if ( + observed.message.lifecycle?.kind !== 'response' || + observed.message.lifecycle.invocationId !== input.invocationId || + observed.message.lifecycle.status !== 'processing' + ) { + throw new Error(`Lifecycle response admission conflict: ${input.invocationId}`); + } + lifecycleResponseMessageIds.add(observed.message.id); + lifecycleReceiverPersisted = true; + interruptLifecycleResponse = async (reason: string): Promise => { + const terminal = await messageStore.commitLifecycleResponseTerminal(observed.message.id, { + invocationId: input.invocationId, + status: 'interrupted', + completedAt: Date.now(), + reason, + content: '', + extra: observed.message.extra, + mentions: [], + origin: 'stream', + }); + if (terminal.kind !== 'applied' && terminal.kind !== 'replayed') { + throw new Error( + `Lifecycle response interruption conflict: ${input.invocationId}:${terminal.kind}:${'reason' in terminal ? terminal.reason : 'missing'}`, + ); + } + await settleLifecycleResponseInputs(messageStore, terminal.message, observed.message.id); + lifecycleResponseInterrupted = true; + }; + const lifecycleInputSnapshots: StoredMessage[] = []; + for (const inputMessage of lifecycleInputMessages) { + const transition = await messageStore.advanceLifecycleInputDispatch(inputMessage.id, { + ...lifecycleInputIdentityForStoredMessage(inputMessage), + targetId: input.catId, + phase: 'dispatched', + statusMessageId: observed.message.id, + dispatchedAt: input.startedAt, + }); + if (transition.kind !== 'applied' && transition.kind !== 'replayed') { + await interruptLifecycleResponse('input_dispatch_projection_conflict'); + throw new Error( + `Lifecycle input dispatch conflict: ${inputMessage.id}:${transition.kind}:${'reason' in transition ? transition.reason : 'missing'}`, + ); + } + lifecycleInputSnapshots.push(transition.message); + this.callerDispatchObservations.registerPersistedSource(transition.message, [input.catId]); + } + // ADR-043 D4: the durable response + dispatchRef is this target's + // cutover boundary. Retire only this target while the source claim + // continues to protect siblings that have not reached admission. + const retirements = []; + for (const admissionEntry of admissionEntries) { + const retirement = await queue.retireClaimedLifecycleTarget( + input.threadId, + admissionEntry.id, + input.catId, + Date.now(), + ); + if (retirement.outcome !== 'retired') { + throw new Error( + `Lifecycle receiver persisted but Queue target retirement changed: ${input.invocationId}`, + ); + } + retirements.push(retirement); + } + lifecycleTargetRetired = true; + if (retirements.every((retirement) => retirement.rowStatus === 'absent')) { + lifecycleQueueTargetsRetired = true; + } + if (retirements.some((retirement) => retirement.rowStatus !== 'claimed')) { + // A closed or restored claim makes the next source eligible for + // a normal try-drain. The coordinator coalesces concurrent calls. + void this.requestDrain(threadId); + } + const activeRun: LifecycleActiveRun = { + threadId: input.threadId, + targetId: input.catId, + invocationId: input.invocationId, + responseMessageId: observed.message.id, + inputEntryIds: admissionEntries.map((candidate) => candidate.id), + inputMessageIds: lifecycleInputMessages.map((message) => message.id), + privateInputEntryIds: admissionEntries + .filter((candidate) => candidate.kind === 'private_input') + .map((candidate) => candidate.id), + startedAt: input.startedAt, + }; + if ( + invocationTracker.bindLifecycleActiveRun && + !invocationTracker.bindLifecycleActiveRun(activeRun, input.parentInvocationId) + ) { + await interruptLifecycleResponse('active_run_owner_mismatch'); + throw new Error(`Lifecycle ActiveRun owner mismatch: ${input.invocationId}`); + } + await this.markPromptMessagesAwakened({ + threadId: input.threadId, + userId: input.userId, + catId: input.catId, + invocationId: input.invocationId, + messageIds: [ + ...new Set( + admissionEntries + .map((candidate) => candidate.payload.messageId) + .filter((messageId): messageId is string => typeof messageId === 'string'), + ), + ], + awakenedAt: input.startedAt, + }); + for (const inputSnapshot of lifecycleInputSnapshots) { + this.emitLifecycleMessageUpdated(input.userId, inputSnapshot); + } + this.emitLifecycleMessageUpdated(input.userId, observed.message); + try { + await emitQueueUpdated( + socketManager, + input.userId, + input.threadId, + queue.list(input.threadId, input.userId), + messageStore, + 'processing', + ); + } catch (err) { + log.warn( + { err, threadId: input.threadId, invocationId: input.invocationId }, + '[QueueProcessor] lifecycle handoff committed but Queue projection emit failed', + ); + } + return { + responseMessageId: observed.message.id, + priorFrontierMessageId: observed.priorFrontierMessageId, + activeRun, + }; + } catch (error) { + if (!lifecycleTargetRetired) { + lifecycleClaimRestorePromise ??= (async () => { + const entryIds = admissionEntries.map((candidate) => candidate.id); + if (await queue.restoreClaimedEntries(input.threadId, entryIds)) return true; + const durableRows = await Promise.all( + entryIds.map((entryId) => queue.getDurableEntry(input.threadId, entryId)), + ); + return durableRows.every((row) => !row || row.status === 'queued'); + })(); + if (await lifecycleClaimRestorePromise) { + void this.requestDrain(input.threadId); + } else { + log.error( + { threadId: input.threadId, invocationId: input.invocationId }, + '[QueueProcessor] target admission failed and Queue claim did not restore', + ); + } + } + if (interruptLifecycleResponse && !lifecycleResponseInterrupted) { + try { + await interruptLifecycleResponse('queue_target_retirement_pending'); + } catch (interruptError) { + log.warn( + { interruptError, threadId: input.threadId, invocationId: input.invocationId }, + '[QueueProcessor] failed to interrupt lifecycle response after target admission failure', + ); + } + } + throw error; + } + }, + onAgentClientActiveRunReady: ( + input: Parameters>[0], + ) => { + const { catId, dispatcher } = input; + const release = invocationTracker.bindAgentClientActiveRunDispatcher?.( + threadId, + catId, + dispatcher, + invocationId, + ); + if (!release) { + throw new Error(`Agent Client ActiveRun dispatcher owner mismatch: ${dispatcher.invocationId}`); + } + return release; + }, onPromptMessagesExposed: (input: PromptMessagesExposedInput) => this.markPromptMessagesSeen(input), ...(freshnessSupplementOriginalMessageId ? { a2aTriggerMessageId: freshnessSupplementOriginalMessageId } - : entry.a2aTriggerMessageId - ? { a2aTriggerMessageId: entry.a2aTriggerMessageId } + : entry.execution.a2aTriggerMessageId + ? { a2aTriggerMessageId: entry.execution.a2aTriggerMessageId } : {}), - ...((freshnessSupplementOriginalMessageId || entry.a2aTriggerMessageId) && entry.callerCatId - ? { a2aCallerCatId: entry.callerCatId } + ...((freshnessSupplementOriginalMessageId || entry.execution.a2aTriggerMessageId) && + queueEntryCallerCatId(entry) + ? { a2aCallerCatId: queueEntryCallerCatId(entry) } + : {}), + ...(entry.execution.callerTraceContext ? { callerTraceContext: entry.execution.callerTraceContext } : {}), + ...(entry.execution.cloudDispatchProvenance + ? { cloudDispatchProvenance: entry.execution.cloudDispatchProvenance } : {}), - ...(entry.callerTraceContext ? { callerTraceContext: entry.callerTraceContext } : {}), - ...(entry.cloudDispatchProvenance ? { cloudDispatchProvenance: entry.cloudDispatchProvenance } : {}), - ...(entry.requiresExactCloudDispatchProvenance ? { requiresExactCloudDispatchProvenance: true } : {}), - ...(entry.freshnessClosureId + ...(entry.execution.requiresExactCloudDispatchProvenance + ? { requiresExactCloudDispatchProvenance: true } + : {}), + ...(entry.execution.freshnessClosureId ? { - freshnessClosureId: entry.freshnessClosureId, + freshnessClosureId: entry.execution.freshnessClosureId, freshnessClosureRequiredMessageIds: - (await this.deps.freshnessClosureStore?.get(entry.freshnessClosureId))?.requiredMessageIds ?? [], + (await this.deps.freshnessClosureStore?.get(entry.execution.freshnessClosureId)) + ?.requiredMessageIds ?? [], } : {}), - ...(entry.freshnessSupplementId + ...(entry.execution.freshnessSupplementId ? { - freshnessSupplementId: entry.freshnessSupplementId, + freshnessSupplementId: entry.execution.freshnessSupplementId, freshnessSupplementRequiredMessageIds, toolExecutionPolicy: supplementToolExecutionPolicy, } : {}), // F222 P1: Only user-originated queue entries trigger frustration detection. // Whitelist (not blacklist) — agent + connector sources both suppressed. - frustrationAutoIssueEligible: entry.source === 'user', - // #949 P1-1: Connector-sourced queue entries have no ball-pass expectation. - // A2A/agent entries still get the verdict-pass handoff guard. - verdictPassWarningEnabled: entry.source !== 'connector', - ...(entry.actionSuccessorFence + frustrationAutoIssueEligible: entry.from.kind === 'user', + // User and A2A turns own conversational ball-pass expectations. Connector + // wakes and private system computations do not. + verdictPassWarningEnabled: entry.from.kind === 'user' || entry.from.kind === 'agent', + ...(entry.execution.actionSuccessorFence ? { beforeOutputCommit: async (catId: CatId) => revalidateActionFenceForOutput(catId), } @@ -5305,7 +4797,7 @@ export class QueueProcessor { }); } // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. - if (!intentModeBroadcast && !entry.actionSuccessorFence) { + if (!intentModeBroadcast && !entry.execution.actionSuccessorFence) { socketManager.broadcastToRoom(`thread:${threadId}`, 'intent_mode', { threadId, mode: intent, @@ -5314,15 +4806,20 @@ export class QueueProcessor { }); intentModeBroadcast = true; } - if (hook && msg.catId === primaryCat && msg.type === 'text' && (msg as { content?: string }).content) { + if ( + entryCompleteHooks.length > 0 && + msg.catId === primaryCat && + msg.type === 'text' && + (msg as { content?: string }).content + ) { responseText = accumulateTextAggregate( responseText, (msg as { content?: string }).content!, (msg as { textMode?: 'append' | 'replace' }).textMode, ); } else if ( - hook && - entry.requiresExactCloudDispatchProvenance && + entryCompleteHooks.length > 0 && + entry.execution.requiresExactCloudDispatchProvenance && msg.catId === primaryCat && msg.type === 'system_info' && (msg as { content?: string }).content @@ -5330,48 +4827,40 @@ export class QueueProcessor { const visibleNotice = userFacingSystemInfoNoticeContent((msg as { content?: string }).content!, primaryCat); if (visibleNotice) responseText = accumulateTextAggregate(responseText, visibleNotice, 'append'); } - const continuationCapsule = extractContinuityCapsuleFromAgentMessage(msg); - if (continuationCapsule) { - continuationCapsules.set(continuationCapsule.catId, continuationCapsule); - } - terminalDispositions.observe(msg); - const childInvocationId = (msg as { invocationId?: unknown }).invocationId; if ( - isTerminalDispositionEvent(msg) && + entryCompleteHooks.length > 0 && msg.catId && - typeof childInvocationId === 'string' && - childInvocationId.length > 0 - ) { - observedChildInvocationIdByCatId.set(msg.catId, childInvocationId); - } - const terminalConsumptions = normalizeQueueTerminalConsumptions( - msg.turnCustodyTerminalWitnesses ?? msg.turnCustodyTerminalWitness, - ); - if ( - msg.type === 'done' && - typeof childInvocationId === 'string' && - childInvocationId.length > 0 && - terminalConsumptions.length > 0 + msg.type === 'text' && + (msg as { content?: string }).content ) { - const dispatchConsumption = terminalConsumptions.find( - (candidate) => candidate.kind === 'dispatch_handled_continuation', + hookResponseTextByTarget.set( + msg.catId, + accumulateTextAggregate( + hookResponseTextByTarget.get(msg.catId) ?? '', + (msg as { content?: string }).content!, + (msg as { textMode?: 'append' | 'replace' }).textMode, + ), ); - if ( - dispatchConsumption?.kind === 'dispatch_handled_continuation' && - msg.catId && - !continuationCapsules.has(msg.catId) - ) { - continuationCapsules.set( + } else if ( + entryCompleteHooks.length > 0 && + entry.execution.requiresExactCloudDispatchProvenance && + msg.catId && + msg.type === 'system_info' && + (msg as { content?: string }).content + ) { + const visibleNotice = userFacingSystemInfoNoticeContent((msg as { content?: string }).content!, msg.catId); + if (visibleNotice) { + hookResponseTextByTarget.set( msg.catId, - buildDispatchHandledContinuationCapsule({ - threadId, - catId: msg.catId, - invocationId: childInvocationId, - dispositionAt: dispatchConsumption.dispositionAt, - }), + accumulateTextAggregate(hookResponseTextByTarget.get(msg.catId) ?? '', visibleNotice, 'append'), ); } } + const continuationCapsule = extractContinuityCapsuleFromAgentMessage(msg); + if (continuationCapsule) { + continuationCapsules.set(continuationCapsule.catId, continuationCapsule); + } + terminalDispositions.observe(msg); if (isTerminalDispositionEvent(msg) && msg.catId) { invocationTracker.completeSlot?.(threadId, msg.catId, controller); } @@ -5404,7 +4893,7 @@ export class QueueProcessor { } currentTurnCatId = undefined; // F151: Deliver completed cat's turns immediately (same fix as ConnectorInvokeTrigger) - if (this.deps.outboundHook && !entry.actionSuccessorFence) { + if (this.deps.outboundHook && !entry.execution.actionSuccessorFence) { if (threadMetaPromise) { threadMeta = await threadMetaPromise; threadMetaPromise = undefined; @@ -5453,7 +4942,11 @@ export class QueueProcessor { const turn = outboundTurns[outboundTurns.length - 1]; accumulateTextParts(turn.textParts, textContent, textMode); } - if (this.deps.streamingHook && !entry.actionSuccessorFence && !entry.freshnessSupplementId) { + if ( + this.deps.streamingHook && + !entry.execution.actionSuccessorFence && + !entry.execution.freshnessSupplementId + ) { const accumulated = outboundTurns.length > 0 ? flattenTurnTextParts(outboundTurns) : flattenTextParts(collectedTextParts); this.deps.streamingHook.onStreamChunk(threadId, accumulated, invocationId).catch((err) => { @@ -5471,9 +4964,9 @@ export class QueueProcessor { ...msg, ...(invocationId ? stampVisibleTurn(invocationId, msgInvocationId) : {}), }; - if (entry.actionSuccessorFence) { + if (entry.execution.actionSuccessorFence) { bufferedActionMessages.push(visibleMessage); - } else if (entry.freshnessSupplementId) { + } else if (entry.execution.freshnessSupplementId) { bufferedSupplementMessages.push(visibleMessage); } else { socketManager.broadcastAgentMessage(visibleMessage, threadId); @@ -5510,13 +5003,46 @@ export class QueueProcessor { // Single-cat cancel should still auto-resume the next queued entry (backward compat). // 'cancel_all' = cancelAll button; 'user_cancel' = single-cat — only cancel_all suppresses. if (batchReason === 'cancel_all') { - const entryCat = entry.targetCats[0] ?? 'unknown'; + const entryCat = queueEntryTargetCats(entry)[0] ?? 'unknown'; this.suppressAutoResume(threadId, entryCat, [invocationId]); } await this.cleanupStreamingOnFailure(threadId, invocationId, streamStartPromise, log); return executionResult(finalStatus); } + if (persistenceContext.failed) { + const errorDetail = persistenceContext.errors.map((error) => `${error.catId}: ${error.error}`).join('; '); + await invocationRecordStore.update(invocationId, { + status: 'failed', + error: `Message delivered but persistence failed: ${errorDetail}`, + }); + socketManager.broadcastAgentMessage( + { + type: 'error', + catId: primaryCat, + error: '消息已发送但未能保存,刷新后可能丢失。可点击重试。', + timestamp: Date.now(), + }, + threadId, + ); + const pushService = this.deps.getPushService?.(); + if (pushService) { + void pushService + .notifyUser(userId, { + title: '猫猫消息保存失败', + body: '消息已发送但未能保存,请检查', + tag: `cat-error-${threadId}`, + data: { threadId, url: `/?thread=${threadId}` }, + }) + .catch((pushErr) => + log.warn({ err: pushErr, threadId }, '[QueueProcessor] persistence failure push notification failed'), + ); + } + finalStatus = 'failed'; + await this.cleanupStreamingOnFailure(threadId, invocationId, streamStartPromise, log); + return executionResult('failed'); + } + if (governanceErrorCode) { await invocationRecordStore.update(invocationId, { status: 'failed', @@ -5527,13 +5053,13 @@ export class QueueProcessor { return executionResult('failed'); } - if (!entry.actionSuccessorFence && terminalDispositions.getSuccessfulCatIds().length === 0) { + if (!entry.execution.actionSuccessorFence && terminalDispositions.getSuccessfulCatIds().length === 0) { throw new Error( terminalDispositions.getPrimaryTerminalError() ?? 'all targeted cats completed without a success witness', ); } - if (entry.actionSuccessorFence) { + if (entry.execution.actionSuccessorFence) { actionFenceAggregateSucceeded = true; const successfulCatIds = terminalDispositions.getSuccessfulCatIds(); const unvalidatedSuccessfulCats = successfulCatIds.filter( @@ -5551,7 +5077,7 @@ export class QueueProcessor { { threadId, entryId: entry.id, - leaseId: entry.actionSuccessorFence.leaseId, + leaseId: entry.execution.actionSuccessorFence.leaseId, unvalidatedSuccessfulCats, }, '[F167-S.1] route completed without revalidating every action successor holder; suppressing output', @@ -5562,7 +5088,7 @@ export class QueueProcessor { { threadId, entryId: entry.id, - leaseId: entry.actionSuccessorFence.leaseId, + leaseId: entry.execution.actionSuccessorFence.leaseId, }, '[F167-S.1] route completed without a successful action successor holder; suppressing output', ); @@ -5581,7 +5107,7 @@ export class QueueProcessor { if (this.deps.streamingHook) { streamStartPromise = this.deps.streamingHook - .onStreamStart(threadId, primaryCat, invocationId, entry.senderMeta) + .onStreamStart(threadId, primaryCat, invocationId, queueEntrySenderMeta(entry)) .catch((err) => log.warn({ err, threadId }, '[QueueProcessor] StreamingHook.onStreamStart failed')); await streamStartPromise; const accumulated = @@ -5608,23 +5134,23 @@ export class QueueProcessor { // 9. Ack cursors + mark succeeded await router.ackCollectedCursors(userId, threadId, cursorBoundaries); - const adoptedClosureDecision = entry.freshnessClosureId + const adoptedClosureDecision = entry.execution.freshnessClosureId ? persistenceContext.outputCommitDecisions?.[primaryCat] : undefined; const adoptedClosureStatus = adoptedClosureDecision?.kind === 'committed_fresh' && - adoptedClosureDecision.closureId === entry.freshnessClosureId + adoptedClosureDecision.closureId === entry.execution.freshnessClosureId ? 'committed' : adoptedClosureDecision?.kind === 'superseded_positive_stale' && - adoptedClosureDecision.closureId === entry.freshnessClosureId + adoptedClosureDecision.closureId === entry.execution.freshnessClosureId ? 'pending' : adoptedClosureDecision?.kind === 'blocked_known_closure' && - adoptedClosureDecision.closureId === entry.freshnessClosureId + adoptedClosureDecision.closureId === entry.execution.freshnessClosureId ? 'blocked' : undefined; let freshnessSupplementStatus: 'committed' | 'declined' | undefined; - if (entry.freshnessSupplementId && this.deps.freshnessClosureStore) { - let supplement = await this.deps.freshnessClosureStore.getSupplement(entry.freshnessSupplementId); + if (entry.execution.freshnessSupplementId && this.deps.freshnessClosureStore) { + let supplement = await this.deps.freshnessClosureStore.getSupplement(entry.execution.freshnessSupplementId); let durableBodyFound = false; if (supplement?.status === 'running') { const recovered = await this.recoverDurableSupplementCommit(supplement, invocationId); @@ -5659,7 +5185,7 @@ export class QueueProcessor { ...(adoptedClosureStatus ? { freshnessClosureStatus: adoptedClosureStatus } : {}), ...(freshnessSupplementStatus ? { - freshnessSupplementId: entry.freshnessSupplementId, + freshnessSupplementId: entry.execution.freshnessSupplementId, freshnessSupplementStatus, } : {}), @@ -5677,6 +5203,42 @@ export class QueueProcessor { finalStatus = 'succeeded'; + if (entry.from.kind === 'user') { + const pushService = this.deps.getPushService?.(); + if (pushService) { + const pushTurns = outboundTurns.filter((turn) => + isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[turn.catId]), + ); + const assistantText = ( + outboundTurns.length > 0 + ? flattenTurnTextParts(pushTurns) + : isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[primaryCat]) + ? flattenTextParts(collectedTextParts) + : '' + ).trim(); + const hasKnownUndeliverableOutput = Object.values(persistenceContext.outputCommitDecisions ?? {}).some( + (decision) => !isConnectorDeliverable(decision), + ); + if (!hasKnownUndeliverableOutput || assistantText.length > 0) { + const needsDecision = assistantText.length > 0 && shouldMarkDecisionNotification(assistantText); + const catNames = targetCats.join(', '); + void pushService + .notifyUser(userId, { + title: needsDecision ? `${catNames} 需要你决策` : `${catNames} 回复了`, + body: (assistantText || '猫猫已处理,请打开会话查看详情').slice(0, 80), + icon: targetCats.length === 1 ? `/avatars/${targetCats[0]}.png` : '/icons/icon-192x192.png', + tag: `${needsDecision ? 'cat-decision' : 'cat-reply'}-${threadId}`, + data: { + threadId, + url: `/?thread=${threadId}`, + ...(needsDecision ? { requiresDecision: true } : {}), + }, + }) + .catch((err) => log.warn({ err, threadId }, '[QueueProcessor] push notification failed')); + } + } + } + // 10. Outbound delivery: send remaining per-turn content to bound external chats await this.deliverOutbound( threadId, @@ -5692,6 +5254,19 @@ export class QueueProcessor { threadMeta, ); + const successfulCatIds = new Set(terminalDispositions.getSuccessfulCatIds()); + for (const [catId, projection] of callerDispatchProjectionByCat) { + if (successfulCatIds.has(catId as CatId)) { + this.callerDispatchObservations.acknowledge(projection.included); + if (callerDispatchProcessStartPromptCats.has(catId) && this.callerDispatchProcessStart) { + this.callerDispatchObservations.acknowledgeProcessStartNotice( + { ownerId: userId, threadId, callerCatId: catId }, + this.callerDispatchProcessStart.processGenerationId, + ); + } + } + } + return executionResult('succeeded'); } catch (err) { executionError = err; @@ -5707,7 +5282,7 @@ export class QueueProcessor { } } const errMsg = err instanceof Error ? err.message : String(err); - const exposeFailure = entry.actionSuccessorFence + const exposeFailure = entry.execution.actionSuccessorFence ? await finalizeActionFenceOutcome('failed', false, targetCats) : true; // Best-effort: mark record failed + broadcast error @@ -5718,7 +5293,7 @@ export class QueueProcessor { error: errMsg, }); } - if (exposeFailure && !entry.freshnessSupplementId) { + if (exposeFailure && !entry.execution.freshnessSupplementId) { socketManager.broadcastAgentMessage( { type: 'error', @@ -5729,6 +5304,21 @@ export class QueueProcessor { }, threadId, ); + if (entry.from.kind === 'user') { + const pushService = this.deps.getPushService?.(); + if (pushService) { + void pushService + .notifyUser(userId, { + title: '猫猫出错了', + body: errMsg.slice(0, 100), + tag: `cat-error-${threadId}`, + data: { threadId, url: `/?thread=${threadId}` }, + }) + .catch((pushErr) => + log.warn({ err: pushErr, threadId }, '[QueueProcessor] error push notification failed'), + ); + } + } } } catch (updateErr) { log.warn( @@ -5741,14 +5331,19 @@ export class QueueProcessor { // cleanupStreamingOnFailure — onStreamEnd moves sessions from active → // pendingCleanup; cleanupPlaceholders only acts on pendingCleanup, so // calling it alone is a no-op when sessions are still active. - if (!entry.freshnessClosureId && !entry.freshnessSupplementId) { + if (!entry.execution.freshnessClosureId && !entry.execution.freshnessSupplementId) { await this.cleanupStreamingOnFailure(threadId, invocationId, streamStartPromise, log); } // R3 P2 fix (#873): Deliver error message to external IM so user sees // a reply instead of silence (mirrors ConnectorInvokeTrigger error path). // R6 fix: timeout prevents adapter hang from pinning queue slot (Cloud P1). - if (this.deps.outboundHook && !entry.freshnessClosureId && !entry.freshnessSupplementId && exposeFailure) { + if ( + this.deps.outboundHook && + !entry.execution.freshnessClosureId && + !entry.execution.freshnessSupplementId && + exposeFailure + ) { const ERROR_DELIVER_TIMEOUT_MS = this.deps.deliverTimeoutMs ?? 10_000; try { await Promise.race([ @@ -5772,6 +5367,7 @@ export class QueueProcessor { return executionResult('failed'); } finally { + if (heartbeatInterval !== undefined) clearInterval(heartbeatInterval); if (!replayClaimLost && invocationId && typeof invocationRecordStore.get === 'function') { try { await ensureTerminalStatus(invocationId, { @@ -5785,6 +5381,21 @@ export class QueueProcessor { } if (invocationId) this.routeChainTracker.release(invocationId); + // Response terminalization also settles every linked input ref in the + // store CAS. Publish those exact same-id snapshots before retiring the + // ActiveRun so clients observe terminal truth without an F5 refresh. + for (const lifecycleMessageId of [...lifecycleInputMessageIds, ...lifecycleResponseMessageIds]) { + try { + const lifecycleMessage = await messageStore.getById(lifecycleMessageId); + if (lifecycleMessage?.lifecycle) this.emitLifecycleMessageUpdated(userId, lifecycleMessage); + } catch (err) { + log.warn( + { err, threadId, lifecycleMessageId }, + '[QueueProcessor] failed to publish terminal lifecycle message snapshot', + ); + } + } + // Retire only the tracker projection owned by this queue execution. A pre-start // reservation can be superseded before this path gets a controller; blind // completeAll(..., undefined) would then delete the external replacement. @@ -5795,43 +5406,51 @@ export class QueueProcessor { invocationTracker.completeByExecutionId?.(threadId, catId, invocationId); } } - // Supplement state is the durable lifecycle; the Queue row is only its - // carrier. If terminalization fails, `none` deliberately preserves the - // ordinary rollback path instead of consuming unowned work. - const durableTerminalOwner: QueueEntryDurableTerminalOwner = - processingReservationReplaced || deferredForBusyTarget - ? { kind: 'none' } - : await this.terminalizeFreshnessSupplementCarrier(entry, invocationId, finalStatus, executionError); - if (!processingReservationReplaced) { + // Close the supplement's own lifecycle before terminalizing its Queue row. + if (!processingReservationReplaced && !prestartClaimRestored) { + await this.terminalizeFreshnessSupplementCarrier(entry, invocationId, finalStatus, executionError); + } + if (!processingReservationReplaced && !prestartClaimRestored) { try { - const settlement = await this.settleAttemptQueueEntry( - entry, - finalStatus, - custodyEntryIds.has(entry.id) - ? 'durable' - : legacyUnboundEntryIds.has(entry.id) - ? 'legacy_unbound' - : 'absent', - durableTerminalOwner, - deferredForBusyTarget, - ); - if (returnedExecutionResult && settlement.requeued) returnedExecutionResult.primaryEntryRequeued = true; + const preReceiverUserCancel = + lifecycleTransferStarted && !lifecycleReceiverPersisted && finalStatus === 'canceled_by_user'; + if (lifecycleTransferStarted && !lifecycleReceiverPersisted && !preReceiverUserCancel) { + if (!(await queue.restoreClaimedEntries(threadId, [entry.id, ...batchedEntryIds]))) { + throw new Error('pre-receiver Queue claim restoration did not converge'); + } + // The body remains pending by design. Do not immediately hot-loop + // the same failed pre-receiver transfer from onInvocationComplete. + if (returnedExecutionResult) returnedExecutionResult.primarySettlementIncomplete = true; + } else if (lifecycleReceiverPersisted && !lifecycleQueueTargetsRetired) { + if ( + !(await queue.reconcileClaimedLifecycleTargets(threadId, [entry.id, ...batchedEntryIds], messageStore)) + ) { + throw new Error('post-receiver Queue target reconciliation did not converge'); + } + } else { + await this.settleAttemptQueueEntry(entry, finalStatus); + } } catch (err) { log.error( { err, threadId, queueEntryId: entry.id, finalStatus }, - '[QueueProcessor] Queue attempt settlement failed closed; restored carrier remains authoritative', + '[QueueProcessor] Queue attempt settlement failed closed; durable nonterminal row needs recovery', ); - if (returnedExecutionResult) returnedExecutionResult.primaryEntryRequeued = true; + if (returnedExecutionResult) returnedExecutionResult.primarySettlementIncomplete = true; } - } else { + } else if (processingReservationReplaced) { log.info( { threadId, queueEntryId: entry.id, invocationId }, '[QueueProcessor] skipped stale Queue settlement after durable retirement barrier replaced the attempt', ); + } else { + log.info( + { threadId, queueEntryId: entry.id, invocationId }, + '[QueueProcessor] kept the exact Queue entry pending after a pre-start target became busy', + ); } - if (entry.freshnessClosureId && invocationId && this.deps.freshnessClosureStore) { + if (entry.execution.freshnessClosureId && invocationId && this.deps.freshnessClosureStore) { try { - const closure = await this.deps.freshnessClosureStore.get(entry.freshnessClosureId); + const closure = await this.deps.freshnessClosureStore.get(entry.execution.freshnessClosureId); if (closure?.status === 'running' && closure.activeAttempt?.invocationId === invocationId) { const reason = finalStatus === 'canceled_by_user' || finalStatus === 'canceled' @@ -5860,23 +5479,21 @@ export class QueueProcessor { } } catch (err) { log.error( - { err, threadId, entryId: entry.id, closureId: entry.freshnessClosureId }, + { err, threadId, entryId: entry.id, closureId: entry.execution.freshnessClosureId }, '[F254-E] failed to close unfinished queue attempt', ); } } // F175 batch members settle through the same per-entry decision as the primary. - for (const bid of processingReservationReplaced ? [] : batchedEntryIds) { + const restorePreReceiverBatch = + lifecycleTransferStarted && !lifecycleQueueTargetsRetired && finalStatus !== 'canceled_by_user'; + for (const bid of processingReservationReplaced || prestartClaimRestored || restorePreReceiverBatch + ? [] + : batchedEntryIds) { const batched = queue.getEntrySnapshot(threadId, userId, bid); if (!batched) continue; try { - await this.settleAttemptQueueEntry( - batched, - finalStatus, - custodyEntryIds.has(bid) ? 'durable' : legacyUnboundEntryIds.has(bid) ? 'legacy_unbound' : 'absent', - { kind: 'none' }, - deferredForBusyTarget, - ); + await this.settleAttemptQueueEntry(batched, finalStatus); } catch (err) { log.error( { err, threadId, queueEntryId: bid, finalStatus }, @@ -5884,29 +5501,6 @@ export class QueueProcessor { ); } } - if (finalStatus === 'succeeded') { - // #815 + Cloud Codex P2: now that the batch succeeded, actually consume - // the subsumed A2A entries that were deferred earlier. - if (deferredA2AConsume.size > 0) { - const consumedA2A = queue.consumeEntriesById(deferredA2AConsume); - for (const c of consumedA2A) { - this.entryCompleteHooks.delete(c.id); - } - log.info( - { threadId, consumedCount: consumedA2A.length }, - '[QueueProcessor] #815: consumed deferred A2A entries after successful batch', - ); - await emitQueueUpdated( - socketManager, - userId, - threadId, - queue.list(threadId, userId), - messageStore, - 'a2a_subsumed', - ); - } - } - // Cloud Codex P2: deferred A2A entries stay in queue on failure — no rollback needed. const producedCapsules = [...continuationCapsules.values()]; for (const continuationCapsule of producedCapsules) { if (finalStatus === 'canceled_by_user') { @@ -5916,17 +5510,17 @@ export class QueueProcessor { ); continue; } - if (!(await this.shouldEnqueueContinuation(continuationCapsule, userId))) { + if (!(await this.shouldEnqueueContinuation(continuationCapsule, userId, finalStatus))) { log.info( { threadId, catId: continuationCapsule.catId }, '[QueueProcessor] #836: reborn session — skipping continuation enqueue', ); continue; } - const result = await this.enqueueContinuation({ + await this.enqueueContinuation({ threadId, userId, - ownerAuthProvenance: entry.ownerAuthProvenance, + ownerAuthProvenance: entry.execution.ownerAuthProvenance, catId: continuationCapsule.catId, capsule: continuationCapsule, }); @@ -5948,7 +5542,7 @@ export class QueueProcessor { await emitQueueUpdated(socketManager, userId, threadId, queue.list(threadId, userId), messageStore, 'completed'); let completionHookStatus = finalStatus; let completionHookResponse = responseText; - if (entry.actionSuccessorFence && !actionFencePreflightRejected && !replayClaimLost) { + if (entry.execution.actionSuccessorFence && !actionFencePreflightRejected && !replayClaimLost) { if (actionFenceAggregateSucceeded) { const successfulHolderCatIds = new Set(terminalDispositions.getSuccessfulCatIds()); const nonSuccessfulHolderCatIds = targetCats.filter((catId) => !successfulHolderCatIds.has(catId)); @@ -5971,19 +5565,33 @@ export class QueueProcessor { } } } - if (entry.actionSuccessorFence && actionFencePreflightRejected) { + if (entry.execution.actionSuccessorFence && actionFencePreflightRejected) { completionHookStatus = 'canceled'; completionHookResponse = ''; } // F122B B6: Fire completion hook (one-shot) and clean up - const completeHook = this.entryCompleteHooks.get(entry.id); - if (completeHook) { - this.entryCompleteHooks.delete(entry.id); + const registeredCompleteHooks = this.entryCompleteHooks.get(entry.id) ?? []; + const completeHooks = registeredCompleteHooks.filter( + (registration) => !registration.targetCatId || targetCats.includes(registration.targetCatId), + ); + if (completeHooks.length > 0) { + const remainingHooks = registeredCompleteHooks.filter((registration) => !completeHooks.includes(registration)); + if (remainingHooks.length > 0) this.entryCompleteHooks.set(entry.id, remainingHooks); + else this.entryCompleteHooks.delete(entry.id); if (!replayClaimLost) { - try { - completeHook(entry.id, completionHookStatus, completionHookResponse); - } catch { - /* best-effort: hook errors must not break queue chain */ + for (const registration of completeHooks) { + const targetStatus = registration.targetCatId + ? (terminalDispositions.getTerminalStatus(registration.targetCatId) ?? + (completionHookStatus === 'succeeded' ? 'failed' : completionHookStatus)) + : completionHookStatus; + const targetResponse = registration.targetCatId + ? (hookResponseTextByTarget.get(registration.targetCatId) ?? '') + : completionHookResponse; + try { + registration.hook(entry.id, targetStatus, targetResponse); + } catch { + /* best-effort: hook errors must not break queue chain */ + } } } } @@ -6011,8 +5619,19 @@ export class QueueProcessor { } } - private async shouldEnqueueContinuation(capsule: CollaborationContinuityCapsuleV1, userId: string): Promise { - if (capsule.continuationReason === 'dispatch_handled') return true; + private async shouldEnqueueContinuation( + capsule: CollaborationContinuityCapsuleV1, + userId: string, + finalStatus: InvocationFinalStatus, + ): Promise { + // A handled dispatch continues inside the same Agent Client session. It is + // terminal evidence for this Queue attempt, never admission for another + // Queue row / InvocationRecord. + if (capsule.continuationReason === 'dispatch_handled') return false; + // runtime_replacement describes how this attempt recovered its writer. Once + // that recovered attempt succeeds, replaying the capsule creates a second, + // source-less invocation and can publish a duplicate terminal bubble. + if (capsule.continuationReason === 'runtime_replacement' && finalStatus === 'succeeded') return false; if (!this.sessionContinuationCoordinator?.resolveSessionStrategy) return true; try { return ( @@ -6272,32 +5891,4 @@ export class QueueProcessor { } } } - - /** Prepare presentation data before the final exact-owner commit fence. */ - private async preparePausedQueueNotifications(threadId: string): Promise { - const notifications: PausedQueueNotification[] = []; - const users = this.deps.queue.listUsersForThread(threadId); - for (const userId of users) { - const userQueue = this.deps.queue.list(threadId, userId); - if (!userQueue.some((e) => e.status === 'queued')) continue; - const enriched = await enrichQueueEntries(userQueue, this.deps.messageStore); - notifications.push({ userId, queue: enriched }); - } - return notifications; - } - - /** Commit queue_paused synchronously after the exact-owner fence. */ - private emitPreparedPausedNotifications( - threadId: string, - reason: 'canceled' | 'failed', - notifications: readonly PausedQueueNotification[], - ): void { - for (const { userId, queue } of notifications) { - this.deps.socketManager.emitToUser(userId, 'queue_paused', { - threadId, - reason, - queue, - }); - } - } } diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyA2ARestartPreflight.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyA2ARestartPreflight.ts deleted file mode 100644 index 9ab1a92be5..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyA2ARestartPreflight.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { CatId } from '@cat-cafe/shared'; -import type { A2ADispatchDispositionService } from '../../../../ball-custody/A2ADispatchDispositionService.js'; -import type { IMessageStore, QueuedMessageCustody, StoredMessage } from '../../stores/ports/MessageStore.js'; -import { exactA2ASourceMessageIds } from './InvocationQueue.js'; -import { createCrossThreadQueueEntryFromCustody } from './QueuedMessageCustodyCoordinator.js'; -import type { StartupCustodyLog } from './QueuedMessageCustodyStartupTypes.js'; - -interface A2AReplacementPreflight { - replacedTargetCats: Set; - /** A required source/event lookup was unavailable or inconsistent; preserve the row for retry. */ - recoveryDeferred: boolean; -} - -export async function resolveReplacedA2ATargetCats( - message: StoredMessage, - current: QueuedMessageCustody, - a2aDispatchDispositionService: Pick | undefined, - messageStore: IMessageStore, - log: StartupCustodyLog, -): Promise { - const replaced = new Set(); - const a2aTargetCats = current.pendingTargetCats.filter( - (catId) => current.carrierByTargetCatId?.[catId]?.sourceCategory === 'a2a', - ); - if (a2aTargetCats.length === 0) return { replacedTargetCats: replaced, recoveryDeferred: false }; - if (!a2aDispatchDispositionService) { - log.warn( - `[queue-custody-startup] A2A replacement preflight is unavailable for ${message.id}; ` + - 'preserving queued custody without provider recovery', - ); - return { replacedTargetCats: replaced, recoveryDeferred: true }; - } - - const readThread = messageStore.getByThreadAfter?.bind(messageStore); - if (!readThread) { - log.warn( - `[queue-custody-startup] queued source-group reader is unavailable for ${message.id}; ` + - 'preserving queued custody without provider recovery', - ); - return { replacedTargetCats: replaced, recoveryDeferred: true }; - } - let queuedThreadMessages: readonly StoredMessage[]; - try { - queuedThreadMessages = await readThread(message.threadId, undefined, undefined, message.userId, { - includeQueuedCatMessages: true, - includeQueuedUserMessages: true, - }); - } catch (error) { - log.warn( - `[queue-custody-startup] queued source-group read unavailable for ${message.id}; ` + - `preserving queued custody without provider recovery: ${error instanceof Error ? error.message : String(error)}`, - ); - return { replacedTargetCats: replaced, recoveryDeferred: true }; - } - - for (const catId of a2aTargetCats) { - const carrier = current.carrierByTargetCatId?.[catId]; - if (!carrier) continue; - try { - const groupSources = queuedThreadMessages.filter((candidate) => { - const candidateCustody = candidate.queueCustody; - return ( - candidate.deliveryStatus === 'queued' && - candidateCustody?.pendingTargetCats.includes(catId as CatId) === true && - candidateCustody.carrierByTargetCatId?.[catId]?.entryId === carrier.entryId - ); - }); - if (!groupSources.some((candidate) => candidate.id === message.id)) { - log.warn( - `[queue-custody-startup] A2A replacement source group changed for ${message.id}/${catId}; ` + - 'preserving queued custody without provider recovery', - ); - return { replacedTargetCats: replaced, recoveryDeferred: true }; - } - const sourceMessageIds = exactA2ASourceMessageIds( - createCrossThreadQueueEntryFromCustody(groupSources, carrier.entryId), - ); - const inspections = await Promise.all( - sourceMessageIds.map((sourceMessageId) => - a2aDispatchDispositionService.inspectHandoff({ - threadId: message.threadId, - catId, - sourceMessageId, - }), - ), - ); - // Match live Queue preflight: every exact coalesced source must be - // replaced before startup withdraws the target. - if (inspections.every((inspection) => inspection.outcome === 'replaced')) replaced.add(catId); - } catch (error) { - log.warn( - `[queue-custody-startup] A2A replacement preflight unavailable for ${message.id}/${catId}; ` + - `preserving queued custody without provider recovery: ${error instanceof Error ? error.message : String(error)}`, - ); - return { replacedTargetCats: replaced, recoveryDeferred: true }; - } - } - return { replacedTargetCats: replaced, recoveryDeferred: false }; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCarrierProjection.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCarrierProjection.ts deleted file mode 100644 index 4e88235402..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCarrierProjection.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { QueuedMessageCustody, StoredMessage } from '../../stores/ports/MessageStore.js'; - -export function carrierEntryId(custody: QueuedMessageCustody, catId: string): string | undefined { - return custody.carrierByTargetCatId?.[catId]?.entryId ?? custody.entryId; -} - -export function isQueuedCarrierTarget(custody: QueuedMessageCustody, catId: string): boolean { - const carrierState = custody.carrierStateByTargetCatId?.[catId]; - if (carrierState) return carrierState.status === 'queued'; - return custody.status === 'queued'; -} - -export function queuedCarrierOwnsPendingTarget(custody: QueuedMessageCustody, entryId: string): boolean { - return custody.pendingTargetCats.some( - (catId) => carrierEntryId(custody, catId) === entryId && isQueuedCarrierTarget(custody, catId), - ); -} - -export function activeCarrierEntryIds(message: StoredMessage): string[] { - const custody = message.queueCustody; - if (!custody) return []; - return [ - ...new Set( - custody.pendingTargetCats.flatMap((catId) => { - const entryId = carrierEntryId(custody, catId); - return entryId ? [entryId] : []; - }), - ), - ]; -} - -export function queuedCarrierEntryIds(message: StoredMessage): string[] { - const custody = message.queueCustody; - if (!custody) return []; - return [ - ...new Set( - custody.pendingTargetCats.flatMap((catId) => { - if (!isQueuedCarrierTarget(custody, catId)) return []; - const entryId = carrierEntryId(custody, catId); - return entryId ? [entryId] : []; - }), - ), - ]; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.ts deleted file mode 100644 index 29e8fdcf40..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.ts +++ /dev/null @@ -1,1940 +0,0 @@ -import type { CatId, QueueReminderAttempt, QueueTargetAttempt, QueueTargetOutcome } from '@cat-cafe/shared'; -import { actionSuccessorFencesMatch } from '../../../../ball-custody/ActionSuccessorAdmissionContract.js'; -import { rejectTypedWaitCustody, type TypedWaitCustodyGuard } from '../../../../ball-custody/TypedWaitCustodyGuard.js'; -import type { - RetryAuthorityCommit, - RetryCustodyTransition, -} from '../../../../ball-custody/WaitContinuationRetryCommitter.js'; -import type { RetryAuthorityFailureReason } from '../../../../ball-custody/WaitContinuationRetryPreflight.js'; -import type { - IMessageStore, - QueueCustodyAdmissionIntent, - QueuedMessageCustody, - RecallMessageToComposerDraftInput, - RecallMessageToComposerDraftResult, - StoredMessage, -} from '../../stores/ports/MessageStore.js'; -import { - cloneQueuedMessageCustody, - type QueueBodyExposure, - type QueueCustodyActionSuccessorRebindProof, - type QueueCustodyReplacementProof, - type QueueTargetCarrierBinding, - settleQueueCustodyWithdrawal, -} from '../../stores/ports/queued-message-custody.js'; -import { - markReminderAttemptDelivered, - markReminderAttemptMissed, - markReminderAttemptSeen, - requestReminderAttempt, -} from '../../stores/ports/queued-message-receipt.js'; -import type { ITaskStore } from '../../stores/ports/TaskStoreContract.js'; -import type { QueueEntry } from './InvocationQueue.js'; -import { normalizeOwnerAuthProvenance } from './owner-auth-provenance.js'; - -interface CoordinatorDeps { - messageStore: IMessageStore; - readWaitRegistration?: ITaskStore['getWaitRegistration']; - now?: () => number; -} - -export interface RetireActionSuccessorQueueCustodyResult { - changed: boolean; - messageId: string; - threadId: string; - userId: string; - entryIds: string[]; - targetCatIds: CatId[]; -} - -const QUEUE_CUSTODY_CAS_MAX_ATTEMPTS = 8; -const QUEUE_CUSTODY_CAS_BASE_DELAY_MS = 25; -const QUEUE_CUSTODY_CAS_MAX_DELAY_MS = 400; - -function queueCustodyCasBackoffDelayMs(attempt: number): number { - const ceiling = Math.min(QUEUE_CUSTODY_CAS_BASE_DELAY_MS * 2 ** attempt, QUEUE_CUSTODY_CAS_MAX_DELAY_MS); - return Math.floor(ceiling / 2 + Math.random() * (ceiling / 2)); -} - -async function waitForQueueCustodyCasRetry(attempt: number): Promise { - if (attempt + 1 >= QUEUE_CUSTODY_CAS_MAX_ATTEMPTS) return; - await new Promise((resolve) => setTimeout(resolve, queueCustodyCasBackoffDelayMs(attempt))); -} - -export function isManagedHoldWakeMessage(message: StoredMessage): boolean { - const meta = message.source?.meta; - return ( - message.source?.connector === 'hold-ball' && - meta?.wakeWhen === true && - typeof meta.taskId === 'string' && - meta.taskId.length > 0 - ); -} - -function assertExactDispatchContinuationWitness( - messageId: string, - successfulTargetCats: readonly string[], - outcomeByCatId?: Readonly>, -): void { - for (const catId of successfulTargetCats) { - const consumption = outcomeByCatId?.[catId]?.consumption; - if (consumption?.kind === 'dispatch_handled_continuation' && consumption.sourceMessageId !== messageId) { - throw new Error('dispatch handled continuation receipt requires its exact source message'); - } - } -} - -export interface QueueCustodyCompletionResult { - handledTargetCats: string[]; - pendingTargetCats: string[]; - fullyConsumed: boolean; -} - -export interface QueueCustodyMessageCompletionResult extends QueueCustodyCompletionResult { - messageId: string; -} - -export interface QueueCustodySettlementResult { - perMessage: QueueCustodyMessageCompletionResult[]; -} - -export type RetryTargetCustodyResult = - | { outcome: 'retried'; attempt: QueueTargetAttempt } - | { outcome: 'not_retryable' | 'unavailable' } - | { outcome: 'authority_stale'; reason: RetryAuthorityFailureReason }; - -export type RecallMessageToComposerDraftCoordinatorResult = - | RecallMessageToComposerDraftResult - | { kind: 'carrier_changed' }; - -function catIds(values: readonly string[] | undefined): CatId[] { - return [...(values ?? [])] as CatId[]; -} - -function targetAttemptId(entryId: string, targetCatId: string, sequence: number): string { - return `${entryId}:${targetCatId}:${sequence}`; -} - -function isTerminalTargetAttempt(attempt: QueueTargetAttempt): boolean { - return ( - attempt.state === 'failed' || - attempt.state === 'interrupted' || - attempt.state === 'cancelled' || - attempt.state === 'handled' - ); -} - -function targetReplayTerminalTruth( - custody: QueuedMessageCustody, - targetCatId: string, -): { terminalized: boolean; invocationId?: string } { - const target = targetCatId as CatId; - const outcome = custody.targetOutcomeByCatId?.[targetCatId]; - const terminalized = - Boolean(outcome) || - custody.handledByCatIds.includes(target) || - custody.withdrawnByCatIds?.includes(target) === true || - (custody.status === 'terminal' && !custody.pendingTargetCats.includes(target)); - return { - terminalized, - ...(outcome?.invocationId ? { invocationId: outcome.invocationId } : {}), - }; -} - -function initialTargetAttempt( - entryId: string, - targetCatId: string, - createdAt: number, - state: QueueTargetAttempt['state'] = 'queued', -): QueueTargetAttempt { - return { - id: targetAttemptId(entryId, targetCatId, 1), - targetCatId, - sequence: 1, - state, - createdAt, - updatedAt: createdAt, - ...(state === 'failed' ? { terminalReason: 'invocation_failed' as const } : {}), - }; -} - -function latestTargetAttempt( - attempts: readonly QueueTargetAttempt[], - targetCatId: string, -): QueueTargetAttempt | undefined { - return attempts - .filter((attempt) => attempt.targetCatId === targetCatId) - .sort((left, right) => left.sequence - right.sequence) - .at(-1); -} - -/** Pure in-memory projection; canonical attempt history remains in Message custody. */ -export function projectQueuedAttemptIds( - custody: QueuedMessageCustody, - targetCats: readonly string[] = custody.pendingTargetCats, -): Record { - const projected: Record = {}; - for (const targetCatId of targetCats) { - const attempt = latestTargetAttempt(custody.targetAttempts ?? [], targetCatId); - if ( - attempt && - attempt.sequence > 1 && - (attempt.state === 'queued' || attempt.state === 'starting' || attempt.state === 'appended') - ) { - projected[targetCatId] = attempt.id; - } - } - return projected; -} - -/** Older records gain one deterministic initial attempt on their next custody write. */ -function ensureTargetAttempts(custody: QueuedMessageCustody): QueueTargetAttempt[] { - const attempts = (custody.targetAttempts ?? []).map((attempt) => ({ ...attempt })); - for (const targetCatId of custody.allTargetCats) { - if (latestTargetAttempt(attempts, targetCatId)) continue; - const outcome = custody.targetOutcomeByCatId?.[targetCatId]; - // Legacy handled records can lack child identity entirely. Preserve that - // honest absence instead of inventing an invocation just to backfill UI. - if (custody.handledByCatIds.includes(targetCatId) && !outcome?.invocationId) continue; - const state: QueueTargetAttempt['state'] = custody.handledByCatIds.includes(targetCatId) - ? 'handled' - : custody.withdrawnByCatIds?.includes(targetCatId) - ? 'cancelled' - : custody.failedByCatIds.includes(targetCatId) - ? 'failed' - : 'queued'; - const attempt = initialTargetAttempt(custody.entryId, targetCatId, custody.createdAt, state); - if (state === 'handled') { - const exposure = custody.bodyExposures?.find( - (candidate) => candidate.targetCatId === targetCatId && candidate.invocationId === outcome?.invocationId, - ); - attempt.invocationId = outcome?.invocationId; - attempt.seenAt = exposure?.seenAt; - attempt.updatedAt = outcome?.handledAt ?? custody.updatedAt; - } - if (state === 'cancelled') { - attempt.terminalReason = 'source_withdrawn'; - attempt.updatedAt = custody.withdrawnAtByCatId?.[targetCatId] ?? custody.updatedAt; - } - if (state === 'failed') attempt.updatedAt = custody.updatedAt; - attempts.push(attempt); - } - return attempts; -} - -function updateTargetAttempt( - attempts: readonly QueueTargetAttempt[], - targetCatId: string, - update: (attempt: QueueTargetAttempt) => QueueTargetAttempt, -): QueueTargetAttempt[] { - const current = latestTargetAttempt(attempts, targetCatId); - if (!current || isTerminalTargetAttempt(current)) return attempts.map((attempt) => ({ ...attempt })); - return attempts.map((attempt) => (attempt.id === current.id ? update(attempt) : { ...attempt })); -} - -/** - * A target can receive the same durable message body in more than one provider - * turn without an author clicking Retry. Each exact body exposure is still a - * distinct delivery attempt: rewriting an earlier attempt's invocation - * identity would make the append-only custody proof reject the next write. - */ -function appendExposureAttempt( - attempts: readonly QueueTargetAttempt[], - entryId: string, - active: QueueTargetAttempt, - targetCatId: string, - exposure: QueueBodyExposure, - failureReason: QueueTargetAttempt['terminalReason'] | undefined, - failedAt: number | undefined, -): QueueTargetAttempt[] { - const attempt = initialTargetAttempt(entryId, targetCatId, exposure.seenAt); - attempt.sequence = active.sequence + 1; - attempt.id = targetAttemptId(entryId, targetCatId, attempt.sequence); - attempt.invocationId = exposure.invocationId; - attempt.seenAt = exposure.seenAt; - attempt.updatedAt = Math.max(exposure.seenAt, failedAt ?? exposure.seenAt); - if (failureReason) { - attempt.state = failureReason === 'invocation_failed' ? 'failed' : 'cancelled'; - attempt.terminalReason = failureReason; - } else { - attempt.state = 'appended'; - } - return [...attempts.map((attempt) => ({ ...attempt })), attempt]; -} - -function projectTargetAttemptsFromEntry( - current: QueuedMessageCustody, - entry: QueueEntry, - targetCats: readonly string[], - now: number, -): QueueTargetAttempt[] { - let attempts = ensureTargetAttempts(current); - for (const catId of targetCats) { - const active = latestTargetAttempt(attempts, catId); - if (!active || isTerminalTargetAttempt(active)) continue; - const failureAt = entry.queuedFailureAtByCatId?.[catId]; - const failureReason = entry.queuedFailureReasonByCatId?.[catId] ?? 'invocation_failed'; - const bodyExposure = [...(entry.queuedBodyExposures ?? [])] - .reverse() - .find((candidate) => candidate.targetCatId === catId && candidate.seenAt >= active.createdAt); - const awakenedAt = entry.queuedAwakenedAtByCatId?.[catId]; - const awakenedInvocationId = entry.queuedAwakenedInvocationIdByCatId?.[catId]; - const isFailed = - entry.queuedFailedByCatIds?.includes(catId) && (failureAt === undefined || failureAt >= active.createdAt); - if (bodyExposure && active.invocationId && active.invocationId !== bodyExposure.invocationId) { - attempts = appendExposureAttempt( - attempts, - current.entryId, - active, - catId, - bodyExposure, - isFailed ? failureReason : undefined, - failureAt, - ); - } else if (entry.queuedHandledByCatIds?.includes(catId)) { - attempts = updateTargetAttempt(attempts, catId, (attempt) => ({ - ...attempt, - state: 'handled', - updatedAt: Math.max(attempt.updatedAt, now), - ...(bodyExposure ? { invocationId: bodyExposure.invocationId, seenAt: bodyExposure.seenAt } : {}), - })); - } else if (isFailed) { - attempts = updateTargetAttempt(attempts, catId, (attempt) => ({ - ...attempt, - state: failureReason === 'invocation_cancelled' ? 'cancelled' : 'failed', - updatedAt: Math.max(attempt.updatedAt, failureAt ?? now), - terminalReason: failureReason, - })); - } else if (bodyExposure) { - attempts = updateTargetAttempt(attempts, catId, (attempt) => ({ - ...attempt, - state: 'appended', - invocationId: bodyExposure.invocationId, - seenAt: bodyExposure.seenAt, - updatedAt: Math.max(attempt.updatedAt, bodyExposure.seenAt), - })); - } else if (awakenedInvocationId && awakenedAt !== undefined && awakenedAt >= active.createdAt) { - attempts = updateTargetAttempt(attempts, catId, (attempt) => ({ - ...attempt, - state: 'starting', - invocationId: awakenedInvocationId, - updatedAt: Math.max(attempt.updatedAt, awakenedAt), - })); - } - } - return attempts; -} - -function markTargetAttemptsHandled( - current: QueuedMessageCustody, - targetCats: readonly string[], - invocationId: string, - handledAt: number, -): QueueTargetAttempt[] { - let attempts = ensureTargetAttempts(current); - for (const catId of targetCats) { - const active = latestTargetAttempt(attempts, catId); - if (!active || isTerminalTargetAttempt(active)) continue; - const exposure = current.bodyExposures?.find( - (candidate) => candidate.targetCatId === catId && candidate.invocationId === invocationId, - ); - attempts = updateTargetAttempt(attempts, catId, (attempt) => ({ - ...attempt, - state: 'handled', - invocationId, - ...(exposure ? { seenAt: exposure.seenAt } : {}), - updatedAt: Math.max(attempt.updatedAt, handledAt), - })); - } - return attempts; -} - -function markTargetAttemptsCancelled( - current: QueuedMessageCustody, - targetCats: readonly string[], - cancelledAt: number, -): QueueTargetAttempt[] { - let attempts = ensureTargetAttempts(current); - for (const catId of targetCats) { - attempts = updateTargetAttempt(attempts, catId, (attempt) => ({ - ...attempt, - state: 'cancelled', - terminalReason: 'source_withdrawn', - updatedAt: Math.max(attempt.updatedAt, cancelledAt), - })); - } - return attempts; -} - -/** - * Rehydrate one immutable cross-thread Queue carrier from its durable message - * custody. This is shared by startup recovery and exact action replay so both - * paths preserve the original entryId/message bundle instead of minting a - * second carrier. - */ -export function createCrossThreadQueueEntryFromCustody( - messages: readonly StoredMessage[], - entryId: string, - options: { queuedTargetsOnly?: boolean } = {}, -): QueueEntry { - const ordered = [...messages].sort( - (left, right) => left.timestamp - right.timestamp || left.id.localeCompare(right.id), - ); - const groupPrimary = ordered[0]; - if (!groupPrimary) { - throw new Error('active cross-thread queue custody group is missing its primary projection'); - } - const members = ordered.flatMap((message) => { - const custody = message.queueCustody; - if ( - !custody?.carrierByTargetCatId || - (custody.status !== 'queued' && custody.status !== 'processing') || - message.threadId !== groupPrimary.threadId || - message.userId !== groupPrimary.userId - ) { - throw new Error(`invalid queued message custody group for carrier ${entryId}`); - } - const ownsPendingTarget = custody.pendingTargetCats.some( - (catId) => - custody.carrierByTargetCatId?.[catId]?.entryId === entryId && - (!options.queuedTargetsOnly || custody.carrierStateByTargetCatId?.[catId]?.status !== 'processing'), - ); - return ownsPendingTarget ? [{ message, custody }] : []; - }); - const primary = members[0]?.message; - const custodies = members.map((member) => member.custody); - if (!primary || custodies.length === 0) { - throw new Error(`active queue custody group has no target for carrier ${entryId}`); - } - const allTargets = [ - ...new Set( - custodies.flatMap((custody) => - custody.allTargetCats.filter((catId) => custody.carrierByTargetCatId?.[catId]?.entryId === entryId), - ), - ), - ]; - const pendingTargets = [ - ...new Set( - custodies.flatMap((custody) => - custody.pendingTargetCats.filter( - (catId) => - custody.carrierByTargetCatId?.[catId]?.entryId === entryId && - (!options.queuedTargetsOnly || custody.carrierStateByTargetCatId?.[catId]?.status !== 'processing'), - ), - ), - ), - ]; - if (pendingTargets.length === 0) throw new Error(`active queue custody group has no target for carrier ${entryId}`); - const targetSet = new Set(allTargets); - const mergeTargets = (select: (custody: QueuedMessageCustody) => readonly CatId[]): CatId[] => - [...new Set(custodies.flatMap((custody) => select(custody).filter((catId) => targetSet.has(catId))))] as CatId[]; - const mergeMap = (select: (custody: QueuedMessageCustody) => Readonly> | undefined) => { - const merged: Record = {}; - for (const custody of custodies) { - for (const [catId, value] of Object.entries(select(custody) ?? {})) { - if (!targetSet.has(catId)) continue; - const current = merged[catId]; - if (current !== undefined && JSON.stringify(current) !== JSON.stringify(value)) { - throw new Error(`divergent Queue carrier evidence for ${entryId}/${catId}`); - } - merged[catId] = value; - } - } - return merged; - }; - let bodyExposures: QueueBodyExposure[] = []; - for (const custody of custodies) { - bodyExposures = mergeBodyExposures( - bodyExposures, - custody.bodyExposures?.filter((exposure) => targetSet.has(exposure.targetCatId)), - ); - } - const bindings = custodies.flatMap((custody) => - allTargets.flatMap((catId) => { - const binding = custody.carrierByTargetCatId?.[catId]; - return binding?.entryId === entryId ? [binding] : []; - }), - ); - const binding = bindings[0]; - if (!binding || bindings.some((candidate) => JSON.stringify(candidate) !== JSON.stringify(binding))) { - throw new Error(`divergent target bindings for Queue carrier ${entryId}`); - } - const custody = custodies[0]; - if (!custody) { - throw new Error(`active cross-thread queue custody group is empty for carrier ${entryId}`); - } - if ( - custodies.some( - (candidate) => - candidate.intent !== custody.intent || - normalizeOwnerAuthProvenance(candidate.ownerAuthProvenance) !== - normalizeOwnerAuthProvenance(custody.ownerAuthProvenance) || - candidate.priority !== custody.priority || - candidate.position !== custody.position, - ) - ) { - throw new Error(`divergent Queue carrier scheduling projection for ${entryId}`); - } - const awakenedInvocationIdByCatId = mergeMap((candidate) => candidate.awakenedInvocationIdByCatId); - const awakenedAtByCatId = mergeMap((candidate) => candidate.awakenedAtByCatId); - const seenInvocationIdByCatId = mergeMap((candidate) => candidate.seenInvocationIdByCatId); - const steeredInvocationIdByCatId = mergeMap((candidate) => candidate.steeredInvocationIdByCatId); - const soleTargetCatId = allTargets.length === 1 ? allTargets[0] : undefined; - const durableCarrierIdempotencyKey = - binding.idempotencyKey ?? - (binding.sourceCategory === 'a2a' && binding.a2aTriggerMessageId && soleTargetCatId - ? fanoutQueueCarrierIdempotencyKey(binding.a2aTriggerMessageId, soleTargetCatId) - : undefined); - return { - ownerAuthProvenance: normalizeOwnerAuthProvenance(custody.ownerAuthProvenance), - id: entryId, - ...(durableCarrierIdempotencyKey ? { idempotencyKey: durableCarrierIdempotencyKey } : {}), - ...(binding.actionSuccessorFence ? { actionSuccessorFence: { ...binding.actionSuccessorFence } } : {}), - threadId: primary.threadId, - userId: primary.userId, - content: members.map(({ message }) => message.content).join('\n'), - messageId: primary.id, - mergedMessageIds: members.slice(1).map(({ message }) => message.id), - source: binding.source, - ...(binding.sourceCategory ? { sourceCategory: binding.sourceCategory } : {}), - ...(binding.callerCatId ? { callerCatId: binding.callerCatId } : {}), - ...(binding.a2aParentInvocationId ? { a2aParentInvocationId: binding.a2aParentInvocationId } : {}), - ...(binding.a2aTriggerMessageId ? { a2aTriggerMessageId: binding.a2aTriggerMessageId } : {}), - targetCats: pendingTargets, - allTargetCats: allTargets, - intent: custody.intent, - status: 'queued', - createdAt: binding.createdAt, - autoExecute: binding.autoExecute, - priority: custody.priority, - ...(custody.position !== undefined ? { position: custody.position } : {}), - queuedNotifiedByCatIds: mergeTargets((candidate) => candidate.notifiedByCatIds), - ...(Object.keys(awakenedInvocationIdByCatId).length > 0 - ? { queuedAwakenedInvocationIdByCatId: awakenedInvocationIdByCatId } - : {}), - ...(Object.keys(awakenedAtByCatId).length > 0 ? { queuedAwakenedAtByCatId: awakenedAtByCatId } : {}), - queuedSeenByCatIds: mergeTargets((candidate) => candidate.seenByCatIds), - queuedSeenInvocationIdByCatId: seenInvocationIdByCatId, - queuedBodyExposures: bodyExposures, - queuedFailedByCatIds: mergeTargets((candidate) => candidate.failedByCatIds), - queuedHandledByCatIds: mergeTargets((candidate) => candidate.handledByCatIds), - steerRequestedByCatIds: mergeTargets((candidate) => candidate.steerRequestedByCatIds ?? []), - steeredInvocationIdByCatId, - }; -} - -/** - * Load every active durable source bound to the requested Queue carriers. - * - * Carrier membership is custody state, not a recent-thread-history heuristic. - * `getByThreadAfter` with no cursor/limit lets both Memory and Redis stores walk - * the complete retained raw timeline (including queued authoring rows) before - * we select exact `entryId` bindings. - */ -export async function readCompleteCrossThreadQueueCarrierGroups( - messageStore: Pick, - threadId: string, - userId: string, - entryIds: readonly string[], -): Promise> { - const requestedEntryIds = new Set(entryIds); - const groups = new Map(entryIds.map((entryId) => [entryId, []])); - if (requestedEntryIds.size === 0) return groups; - - const messages = await messageStore.getByThreadAfter(threadId, undefined, undefined, userId, { - includeQueuedCatMessages: true, - includeQueuedUserMessages: true, - }); - for (const message of messages) { - const custody = message.queueCustody; - if (message.deliveryStatus !== 'queued' || custody?.status !== 'queued') continue; - const matchingEntryIds = new Set( - Object.values(custody.carrierByTargetCatId ?? {}).flatMap((binding) => - requestedEntryIds.has(binding.entryId) ? [binding.entryId] : [], - ), - ); - for (const entryId of matchingEntryIds) groups.get(entryId)?.push(message); - } - return groups; -} - -export function actionSuccessorCarrierKey( - fence: NonNullable, - catId: string, -): string { - return `action:${fence.leaseId}:${fence.generation}:${catId}`; -} - -function targetBindingHasActionFence( - binding: QueueTargetCarrierBinding, - catId: string, - fence: NonNullable, -): boolean { - return ( - JSON.stringify(binding.actionSuccessorFence) === JSON.stringify(fence) && - binding.idempotencyKey === actionSuccessorCarrierKey(fence, catId) - ); -} - -function bindTargetCarrierActionFence( - binding: QueueTargetCarrierBinding, - catId: string, - fence: NonNullable, -): QueueTargetCarrierBinding { - return { - entryId: binding.entryId, - idempotencyKey: actionSuccessorCarrierKey(fence, catId), - actionSuccessorFence: { ...fence }, - source: binding.source, - sourceCategory: binding.sourceCategory, - ...(binding.callerCatId ? { callerCatId: binding.callerCatId } : {}), - ...(binding.a2aParentInvocationId ? { a2aParentInvocationId: binding.a2aParentInvocationId } : {}), - a2aTriggerMessageId: binding.a2aTriggerMessageId, - autoExecute: binding.autoExecute, - createdAt: binding.createdAt, - }; -} - -/** - * Persist a verified action generation onto every durable member of one - * carrier before restoring it process-locally. A crash between members leaves - * divergent custody fail-closed; replay idempotently completes the same CAS - * rebind before provider admission. - */ -export async function rebindCrossThreadQueueCarrierActionFence( - messageStore: IMessageStore, - messages: readonly StoredMessage[], - entryId: string, - fence: NonNullable, - now: () => number = Date.now, -): Promise { - return Promise.all( - messages.map((source) => rebindActionSuccessorCarrierSource(messageStore, source, entryId, fence, now)), - ); -} - -function buildActionSuccessorCarrierRebind( - custody: QueuedMessageCustody, - entryId: string, - targetCatIds: CatId[], - fence: NonNullable, - updatedAt: number, -): { next: QueuedMessageCustody; proof: QueueCustodyActionSuccessorRebindProof } { - const next = cloneQueuedMessageCustody(custody); - const bindings = next.carrierByTargetCatId; - if (!bindings) throw new Error(`action-successor Queue carrier lost bindings: ${entryId}`); - next.revision = custody.revision + 1; - next.updatedAt = Math.max(custody.updatedAt, updatedAt); - for (const catId of targetCatIds) { - const binding = bindings[catId]; - if (!binding) throw new Error(`action-successor Queue carrier target lost binding: ${entryId}/${catId}`); - bindings[catId] = bindTargetCarrierActionFence(binding, catId, fence); - } - return { - next, - proof: { - kind: 'verified_action_successor', - entryId, - targetCatIds, - fence: { ...fence }, - }, - }; -} - -async function rebindActionSuccessorCarrierSource( - messageStore: IMessageStore, - source: StoredMessage, - entryId: string, - fence: NonNullable, - now: () => number, -): Promise { - for (let attempt = 0; attempt < QUEUE_CUSTODY_CAS_MAX_ATTEMPTS; attempt += 1) { - const current = await messageStore.getById(source.id); - const custody = current?.queueCustody; - if (!current || !custody?.carrierByTargetCatId) { - throw new Error(`action-successor Queue carrier source lost custody: ${source.id}`); - } - const targetCatIds = custody.pendingTargetCats.filter( - (catId) => custody.carrierByTargetCatId?.[catId]?.entryId === entryId, - ); - if (targetCatIds.length === 0) return current; - const alreadyBound = targetCatIds.every((catId) => { - const binding = custody.carrierByTargetCatId?.[catId]; - return !!binding && targetBindingHasActionFence(binding, catId, fence); - }); - if (alreadyBound) return current; - const { next, proof } = buildActionSuccessorCarrierRebind(custody, entryId, targetCatIds, fence, now()); - const result = await messageStore.transitionQueueCustody(source.id, { - expectedRevision: custody.revision, - next, - actionSuccessorRebind: proof, - }); - if (result.kind === 'updated') return result.message; - if (result.kind === 'not_found') { - throw new Error(`action-successor Queue carrier source disappeared: ${source.id}`); - } - await waitForQueueCustodyCasRetry(attempt); - } - throw new Error(`action-successor Queue carrier rebind did not converge: ${source.id}`); -} - -function mergeBodyExposures( - current: readonly QueueBodyExposure[] | undefined, - incoming: readonly QueueBodyExposure[] | undefined, -): QueueBodyExposure[] { - const merged = (current ?? []).map((exposure) => ({ ...exposure })); - const byKey = new Map(merged.map((exposure) => [`${exposure.targetCatId}\u0000${exposure.invocationId}`, exposure])); - for (const exposure of incoming ?? []) { - const key = `${exposure.targetCatId}\u0000${exposure.invocationId}`; - const existing = byKey.get(key); - if (existing) { - if (existing.seenAt !== exposure.seenAt) throw new Error('queue body exposure timestamp is immutable'); - continue; - } - const copy = { ...exposure }; - merged.push(copy); - byKey.set(key, copy); - } - return merged; -} - -export function createInitialQueuedMessageCustody(entry: QueueEntry): QueuedMessageCustody { - const allTargetCats = catIds(entry.allTargetCats ?? entry.targetCats); - return { - version: 1, - entryId: entry.id, - revision: 1, - ownerUserId: entry.userId, - ownerAuthProvenance: entry.ownerAuthProvenance, - ...(entry.executionScope ? { executionScope: entry.executionScope } : {}), - ...(entry.authorIntentByCatId ? { authorIntentByCatId: structuredClone(entry.authorIntentByCatId) } : {}), - intent: entry.intent, - status: 'queued', - allTargetCats, - pendingTargetCats: catIds(entry.targetCats), - notifiedByCatIds: catIds(entry.queuedNotifiedByCatIds), - ...(entry.queuedAwakenedInvocationIdByCatId && Object.keys(entry.queuedAwakenedInvocationIdByCatId).length > 0 - ? { awakenedInvocationIdByCatId: { ...entry.queuedAwakenedInvocationIdByCatId } } - : {}), - ...(entry.queuedAwakenedAtByCatId && Object.keys(entry.queuedAwakenedAtByCatId).length > 0 - ? { awakenedAtByCatId: { ...entry.queuedAwakenedAtByCatId } } - : {}), - seenByCatIds: catIds(entry.queuedSeenByCatIds), - seenInvocationIdByCatId: { ...(entry.queuedSeenInvocationIdByCatId ?? {}) }, - ...(entry.queuedBodyExposures?.length - ? { bodyExposures: entry.queuedBodyExposures.map((exposure) => ({ ...exposure })) } - : {}), - targetAttempts: allTargetCats.map((catId) => initialTargetAttempt(entry.id, catId, entry.createdAt)), - failedByCatIds: catIds(entry.queuedFailedByCatIds), - handledByCatIds: catIds(entry.queuedHandledByCatIds), - ...(entry.steerRequestedByCatIds?.length ? { steerRequestedByCatIds: catIds(entry.steerRequestedByCatIds) } : {}), - ...(entry.prestartRetirement - ? { - prestartRetirement: { - ...entry.prestartRetirement, - entryIds: [...entry.prestartRetirement.entryIds], - }, - } - : {}), - ...(entry.steeredInvocationIdByCatId && Object.keys(entry.steeredInvocationIdByCatId).length > 0 - ? { steeredInvocationIdByCatId: { ...entry.steeredInvocationIdByCatId } } - : {}), - priority: entry.priority, - ...(entry.position !== undefined ? { position: entry.position } : {}), - createdAt: entry.createdAt, - updatedAt: entry.createdAt, - }; -} - -export function fanoutQueueCustodyAdmissionId(messageId: string): string { - return `queue-custody:${messageId}`; -} - -export function fanoutQueueCarrierIdempotencyKey(messageId: string, targetCatId: string): string { - return `${fanoutQueueCustodyAdmissionId(messageId)}:${targetCatId}`; -} - -export function createFanoutQueueCustodyAdmission( - messageId: string, - input: { - ownerUserId: string; - ownerAuthProvenance: QueueEntry['ownerAuthProvenance']; - /** Targets already accepted by enqueue policy and safe for restart reconstruction. */ - targetCats: readonly CatId[]; - /** Complete requested group, including targets rejected before carrier staging. */ - requestedTargetCats?: readonly CatId[]; - intent: string; - callerCatId?: CatId; - a2aParentInvocationId?: string; - receiptScope?: QueueCustodyAdmissionIntent['receiptScope']; - actionSuccessorFence?: QueueEntry['actionSuccessorFence']; - createdAt: number; - }, -): QueueCustodyAdmissionIntent { - return { - version: 1, - admissionId: fanoutQueueCustodyAdmissionId(messageId), - ownerUserId: input.ownerUserId, - ownerAuthProvenance: normalizeOwnerAuthProvenance(input.ownerAuthProvenance), - intent: input.intent, - targetCats: [...input.targetCats], - ...(input.requestedTargetCats ? { requestedTargetCats: [...input.requestedTargetCats] } : {}), - ...(input.callerCatId ? { callerCatId: input.callerCatId } : {}), - ...(input.a2aParentInvocationId ? { a2aParentInvocationId: input.a2aParentInvocationId } : {}), - ...(input.receiptScope ? { receiptScope: input.receiptScope } : {}), - ...(input.actionSuccessorFence ? { actionSuccessorFence: { ...input.actionSuccessorFence } } : {}), - priority: 'normal', - createdAt: input.createdAt, - }; -} - -/** Rebuild one independent process-local carrier per durably accepted target. */ -export function createFanoutQueueEntriesFromAdmission( - message: StoredMessage & { queueCustodyAdmission: QueueCustodyAdmissionIntent }, -): QueueEntry[] { - const admission = message.queueCustodyAdmission; - return admission.targetCats.map((targetCatId) => { - const entryId = fanoutQueueCarrierIdempotencyKey(message.id, targetCatId); - return { - id: entryId, - threadId: message.threadId, - userId: admission.ownerUserId, - ownerAuthProvenance: normalizeOwnerAuthProvenance(admission.ownerAuthProvenance), - ...(admission.actionSuccessorFence - ? { - idempotencyKey: `action:${admission.actionSuccessorFence.leaseId}:${admission.actionSuccessorFence.generation}:${targetCatId}`, - actionSuccessorFence: { ...admission.actionSuccessorFence }, - } - : { idempotencyKey: entryId }), - content: message.content, - messageId: message.id, - mergedMessageIds: [], - source: 'agent' as const, - sourceCategory: 'a2a' as const, - targetCats: [targetCatId], - allTargetCats: [targetCatId], - intent: admission.intent, - status: 'queued' as const, - createdAt: admission.createdAt, - autoExecute: true, - queueCustodyAdmissionId: admission.admissionId, - priority: admission.priority, - ...(admission.callerCatId ? { callerCatId: admission.callerCatId } : {}), - ...(admission.a2aParentInvocationId ? { a2aParentInvocationId: admission.a2aParentInvocationId } : {}), - a2aTriggerMessageId: message.id, - }; - }); -} - -interface FanoutQueueCarrierIdentity { - intent: QueueEntry['intent'] | undefined; - threadId: string | undefined; - userId: string | undefined; - ownerAuthProvenance: ReturnType; - actionSuccessorFence: QueueEntry['actionSuccessorFence']; -} - -interface FanoutQueueCarriers { - carrierByTargetCatId: Record; - carrierStateByTargetCatId: NonNullable; - targetCats: CatId[]; -} - -function hasFanoutQueueCarrierIdentity(entry: QueueEntry, identity: FanoutQueueCarrierIdentity): boolean { - return ( - entry.intent === identity.intent && - entry.threadId === identity.threadId && - entry.userId === identity.userId && - normalizeOwnerAuthProvenance(entry.ownerAuthProvenance) === identity.ownerAuthProvenance && - JSON.stringify(entry.actionSuccessorFence) === JSON.stringify(identity.actionSuccessorFence) && - entry.source === 'agent' && - entry.sourceCategory === 'a2a' - ); -} - -function appendFanoutQueueCarriers(messageId: string, entry: QueueEntry, carriers: FanoutQueueCarriers): void { - for (const targetCatId of entry.targetCats) { - if (carriers.carrierByTargetCatId[targetCatId]) { - throw new Error(`fan-out Queue target has multiple carriers: ${targetCatId}`); - } - if ( - entry.actionSuccessorFence && - entry.idempotencyKey !== actionSuccessorCarrierKey(entry.actionSuccessorFence, targetCatId) - ) { - throw new Error(`action-successor Queue carrier has mismatched idempotency: ${entry.id}/${targetCatId}`); - } - carriers.targetCats.push(targetCatId as CatId); - carriers.carrierByTargetCatId[targetCatId] = { - entryId: entry.id, - ...(entry.actionSuccessorFence - ? { - idempotencyKey: actionSuccessorCarrierKey(entry.actionSuccessorFence, targetCatId), - actionSuccessorFence: { ...entry.actionSuccessorFence }, - } - : {}), - source: 'agent', - sourceCategory: 'a2a', - ...(entry.callerCatId ? { callerCatId: entry.callerCatId } : {}), - ...(entry.a2aParentInvocationId ? { a2aParentInvocationId: entry.a2aParentInvocationId } : {}), - a2aTriggerMessageId: entry.a2aTriggerMessageId ?? messageId, - autoExecute: true, - createdAt: entry.createdAt, - }; - carriers.carrierStateByTargetCatId[targetCatId] = { - status: entry.status, - ...(entry.processingStartedAt !== undefined ? { processingStartedAt: entry.processingStartedAt } : {}), - }; - } -} - -export function createInitialFanoutQueuedMessageCustody( - messageId: string, - entries: readonly QueueEntry[], - options: { - requestedTargetCats?: readonly CatId[]; - createdAt?: number; - receiptScope?: QueuedMessageCustody['receiptScope']; - custodyEntryId?: string; - } = {}, -): QueuedMessageCustody { - const intent = entries[0]?.intent; - const threadId = entries[0]?.threadId; - const userId = entries[0]?.userId; - const ownerAuthProvenance = normalizeOwnerAuthProvenance(entries[0]?.ownerAuthProvenance); - const actionSuccessorFence = entries[0]?.actionSuccessorFence; - const identity: FanoutQueueCarrierIdentity = { - intent, - threadId, - userId, - ownerAuthProvenance, - actionSuccessorFence, - }; - const carriers: FanoutQueueCarriers = { - carrierByTargetCatId: {}, - carrierStateByTargetCatId: {}, - targetCats: [], - }; - - for (const entry of entries) { - if (!hasFanoutQueueCarrierIdentity(entry, identity)) { - throw new Error('fan-out Queue carriers must share one A2A message identity'); - } - appendFanoutQueueCarriers(messageId, entry, carriers); - } - - const allTargetCats = [...new Set(options.requestedTargetCats ?? carriers.targetCats)] as CatId[]; - if (allTargetCats.length === 0) throw new Error('fan-out Queue custody requires at least one target'); - if (carriers.targetCats.some((catId) => !allTargetCats.includes(catId))) { - throw new Error('fan-out Queue carrier target must belong to requested targets'); - } - const pendingTargetCats = allTargetCats.filter((catId) => !!carriers.carrierByTargetCatId[catId]); - const failedTargetCats = allTargetCats.filter((catId) => !carriers.carrierByTargetCatId[catId]); - const createdAt = options.createdAt ?? Math.min(...entries.map((entry) => entry.createdAt)); - if (!Number.isFinite(createdAt)) throw new Error('fan-out Queue custody requires a finite createdAt'); - const custodyEntryId = options.custodyEntryId ?? `fanout:${messageId}`; - return { - version: 1, - entryId: custodyEntryId, - revision: 1, - ownerUserId: userId, - ownerAuthProvenance, - ...(options.receiptScope ? { receiptScope: options.receiptScope } : {}), - carrierByTargetCatId: carriers.carrierByTargetCatId, - ...(Object.keys(carriers.carrierStateByTargetCatId).length > 0 - ? { carrierStateByTargetCatId: carriers.carrierStateByTargetCatId } - : {}), - intent: intent ?? 'execute', - status: pendingTargetCats.length > 0 ? 'queued' : 'terminal', - allTargetCats, - pendingTargetCats, - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: failedTargetCats, - targetAttempts: allTargetCats.map((catId) => - initialTargetAttempt(custodyEntryId, catId, createdAt, failedTargetCats.includes(catId) ? 'failed' : 'queued'), - ), - handledByCatIds: [], - priority: 'normal', - createdAt, - updatedAt: createdAt, - }; -} - -export function createInitialCrossThreadQueuedMessageCustody( - messageId: string, - entries: readonly QueueEntry[], - options: { - requestedTargetCats?: readonly CatId[]; - createdAt?: number; - } = {}, -): QueuedMessageCustody { - return createInitialFanoutQueuedMessageCustody(messageId, entries, { - ...options, - receiptScope: 'cross_thread_delivery', - custodyEntryId: `cross-thread:${messageId}`, - }); -} - -export function sameFanoutCustodyIdentity( - actual: QueuedMessageCustody | undefined, - expected: QueuedMessageCustody, -): boolean { - return ( - !!actual && - actual.receiptScope === expected.receiptScope && - actual.entryId === expected.entryId && - actual.intent === expected.intent && - normalizeOwnerAuthProvenance(actual.ownerAuthProvenance) === - normalizeOwnerAuthProvenance(expected.ownerAuthProvenance) && - JSON.stringify(actual.allTargetCats) === JSON.stringify(expected.allTargetCats) && - JSON.stringify(actual.carrierByTargetCatId) === JSON.stringify(expected.carrierByTargetCatId) - ); -} - -function activeCustodyFromEntry(entry: QueueEntry, current: QueuedMessageCustody, now: number): QueuedMessageCustody { - if (current.ownerUserId !== undefined && current.ownerUserId !== entry.userId) { - throw new Error(`Queue entry ${entry.id} owner principal is immutable`); - } - if (normalizeOwnerAuthProvenance(current.ownerAuthProvenance) !== entry.ownerAuthProvenance) { - throw new Error(`Queue entry ${entry.id} owner authentication provenance is immutable`); - } - if (current.carrierByTargetCatId || current.carrierStateByTargetCatId) { - const ownedTargets = current.allTargetCats.filter((catId) => entry.targetCats.includes(catId)); - const ownsTarget = (catId: string) => - !current.carrierByTargetCatId || current.carrierByTargetCatId[catId]?.entryId === entry.id; - if ( - ownedTargets.length === 0 || - entry.targetCats.some((catId) => !ownedTargets.includes(catId as CatId) || !ownsTarget(catId)) - ) { - throw new Error(`Queue entry ${entry.id} does not own this message custody`); - } - const owned = new Set(ownedTargets); - const mergeTargetSet = (existing: readonly CatId[], incoming: readonly string[] | undefined): CatId[] => { - const next = new Set(existing.filter((catId) => !owned.has(catId))); - for (const catId of incoming ?? []) { - if (owned.has(catId)) next.add(catId as CatId); - } - return current.allTargetCats.filter((catId) => next.has(catId)); - }; - const mergeTargetMap = ( - existing: Readonly> | undefined, - incoming: Readonly> | undefined, - ): Record => { - const next = { ...(existing ?? {}) }; - for (const catId of owned) delete next[catId]; - for (const [catId, value] of Object.entries(incoming ?? {})) { - if (owned.has(catId)) next[catId] = value; - } - return next; - }; - const bodyExposures = mergeBodyExposures(current.bodyExposures, entry.queuedBodyExposures); - const targetAttempts = projectTargetAttemptsFromEntry(current, entry, ownedTargets, now); - const carrierStateByTargetCatId = { ...(current.carrierStateByTargetCatId ?? {}) }; - for (const catId of ownedTargets) { - carrierStateByTargetCatId[catId] = { - status: entry.status, - ...(entry.status === 'processing' && entry.processingStartedAt !== undefined - ? { processingStartedAt: entry.processingStartedAt } - : {}), - }; - } - const status = Object.values(carrierStateByTargetCatId).some((state) => state.status === 'processing') - ? 'processing' - : 'queued'; - const seenInvocationIdByCatId = mergeTargetMap( - current.seenInvocationIdByCatId, - entry.queuedSeenInvocationIdByCatId, - ); - const awakenedInvocationIdByCatId = mergeTargetMap( - current.awakenedInvocationIdByCatId, - entry.queuedAwakenedInvocationIdByCatId, - ); - const awakenedAtByCatId = mergeTargetMap(current.awakenedAtByCatId, entry.queuedAwakenedAtByCatId); - const steeredInvocationIdByCatId = mergeTargetMap( - current.steeredInvocationIdByCatId, - entry.steeredInvocationIdByCatId, - ); - const steerRequestedByCatIds = mergeTargetSet(current.steerRequestedByCatIds ?? [], entry.steerRequestedByCatIds); - const { - awakenedInvocationIdByCatId: _awakenedInvocationIdByCatId, - awakenedAtByCatId: _awakenedAtByCatId, - ...stableCurrent - } = current; - return { - ...stableCurrent, - revision: current.revision + 1, - status, - carrierStateByTargetCatId, - pendingTargetCats: mergeTargetSet(current.pendingTargetCats, entry.targetCats), - notifiedByCatIds: mergeTargetSet(current.notifiedByCatIds, entry.queuedNotifiedByCatIds), - ...(Object.keys(awakenedInvocationIdByCatId).length > 0 ? { awakenedInvocationIdByCatId } : {}), - ...(Object.keys(awakenedAtByCatId).length > 0 ? { awakenedAtByCatId } : {}), - seenByCatIds: mergeTargetSet(current.seenByCatIds, entry.queuedSeenByCatIds), - seenInvocationIdByCatId, - ...(bodyExposures.length > 0 ? { bodyExposures } : {}), - ...(targetAttempts.length > 0 ? { targetAttempts } : {}), - failedByCatIds: mergeTargetSet(current.failedByCatIds, entry.queuedFailedByCatIds), - handledByCatIds: mergeTargetSet(current.handledByCatIds, entry.queuedHandledByCatIds), - ...(steerRequestedByCatIds.length > 0 ? { steerRequestedByCatIds } : {}), - ...(Object.keys(steeredInvocationIdByCatId).length > 0 ? { steeredInvocationIdByCatId } : {}), - ...(entry.prestartRetirement - ? { - prestartRetirement: { - ...entry.prestartRetirement, - entryIds: [...entry.prestartRetirement.entryIds], - }, - } - : {}), - updatedAt: now, - }; - } - - const { - position: _position, - processingStartedAt: _processingStartedAt, - awakenedInvocationIdByCatId: _awakenedInvocationIdByCatId, - awakenedAtByCatId: _awakenedAtByCatId, - steerRequestedByCatIds: _steerRequestedByCatIds, - steeredInvocationIdByCatId: _steeredInvocationIdByCatId, - ...stableCurrent - } = current; - const bodyExposures = mergeBodyExposures(current.bodyExposures, entry.queuedBodyExposures); - const targetAttempts = projectTargetAttemptsFromEntry(current, entry, current.allTargetCats, now); - return { - ...stableCurrent, - revision: current.revision + 1, - status: entry.status, - ...(entry.authorIntentByCatId ? { authorIntentByCatId: structuredClone(entry.authorIntentByCatId) } : {}), - pendingTargetCats: catIds(entry.targetCats), - notifiedByCatIds: catIds(entry.queuedNotifiedByCatIds), - ...(entry.queuedAwakenedInvocationIdByCatId && Object.keys(entry.queuedAwakenedInvocationIdByCatId).length > 0 - ? { awakenedInvocationIdByCatId: { ...entry.queuedAwakenedInvocationIdByCatId } } - : {}), - ...(entry.queuedAwakenedAtByCatId && Object.keys(entry.queuedAwakenedAtByCatId).length > 0 - ? { awakenedAtByCatId: { ...entry.queuedAwakenedAtByCatId } } - : {}), - seenByCatIds: catIds(entry.queuedSeenByCatIds), - seenInvocationIdByCatId: { ...(entry.queuedSeenInvocationIdByCatId ?? {}) }, - ...(bodyExposures.length > 0 ? { bodyExposures } : {}), - ...(targetAttempts.length > 0 ? { targetAttempts } : {}), - failedByCatIds: catIds(entry.queuedFailedByCatIds), - // The live carrier prunes retired siblings. Their committed outcomes still - // belong to this source when a remaining sibling fails or is retried. - handledByCatIds: catIds([...new Set([...current.handledByCatIds, ...(entry.queuedHandledByCatIds ?? [])])]), - ...(entry.steerRequestedByCatIds?.length ? { steerRequestedByCatIds: catIds(entry.steerRequestedByCatIds) } : {}), - ...(entry.steeredInvocationIdByCatId && Object.keys(entry.steeredInvocationIdByCatId).length > 0 - ? { steeredInvocationIdByCatId: { ...entry.steeredInvocationIdByCatId } } - : {}), - ...(entry.prestartRetirement - ? { - prestartRetirement: { - ...entry.prestartRetirement, - entryIds: [...entry.prestartRetirement.entryIds], - }, - } - : {}), - priority: entry.priority, - ...(entry.position !== undefined ? { position: entry.position } : {}), - ...(entry.status === 'processing' && entry.processingStartedAt !== undefined - ? { processingStartedAt: entry.processingStartedAt } - : {}), - updatedAt: now, - }; -} - -function comparableCustody(custody: QueuedMessageCustody): Omit { - const { revision: _revision, updatedAt: _updatedAt, ...comparable } = custody; - return comparable; -} - -function sameProjection(left: QueuedMessageCustody, right: QueuedMessageCustody): boolean { - return JSON.stringify(comparableCustody(left)) === JSON.stringify(comparableCustody(right)); -} - -function targetOutcome( - catId: string, - invocationId: string, - handledAt: number, - outcomeByCatId?: Readonly>, -): QueueTargetOutcome { - const supplied = outcomeByCatId?.[catId]; - if (supplied && supplied.invocationId !== invocationId) { - throw new Error(`queue target outcome invocation mismatch for ${catId}`); - } - return supplied - ? { ...supplied, handledAt: Math.max(supplied.handledAt, handledAt) } - : { - invocationId, - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId }, - handledAt, - }; -} - -function buildSuccessfulTargetTransition(input: { - current: QueuedMessageCustody; - successfulTargetCats: readonly string[]; - invocationId: string; - deliveredAt: number; - updatedAt: number; - outcomeByCatId?: Readonly>; -}): { next: QueuedMessageCustody; completion: QueueCustodyCompletionResult } { - const successful = new Set(input.successfulTargetCats); - const pendingHandledTargetCats = input.current.pendingTargetCats.filter( - (catId) => successful.has(catId) && input.current.seenInvocationIdByCatId[catId] === input.invocationId, - ); - const withdrawnHandledTargetCats = (input.current.withdrawnByCatIds ?? []).filter( - (catId) => - successful.has(catId) && - input.current.status === 'terminal' && - input.current.bodyExposures?.some( - (exposure) => exposure.targetCatId === catId && exposure.invocationId === input.invocationId, - ), - ); - const handledTargetCats = [...new Set([...pendingHandledTargetCats, ...withdrawnHandledTargetCats])] as CatId[]; - const pendingTargetCats = input.current.pendingTargetCats.filter((catId) => !handledTargetCats.includes(catId)); - const completion = { - handledTargetCats, - pendingTargetCats, - fullyConsumed: pendingTargetCats.length === 0, - }; - if (handledTargetCats.length === 0) return { next: input.current, completion }; - - const handled = new Set(input.current.handledByCatIds); - const seenInvocationIdByCatId = { ...input.current.seenInvocationIdByCatId }; - const awakenedInvocationIdByCatId = { ...(input.current.awakenedInvocationIdByCatId ?? {}) }; - const awakenedAtByCatId = { ...(input.current.awakenedAtByCatId ?? {}) }; - const steeredInvocationIdByCatId = { ...(input.current.steeredInvocationIdByCatId ?? {}) }; - const carrierStateByTargetCatId = { ...(input.current.carrierStateByTargetCatId ?? {}) }; - const targetOutcomeByCatId = { ...(input.current.targetOutcomeByCatId ?? {}) }; - const withdrawnByCatIds = (input.current.withdrawnByCatIds ?? []).filter( - (catId) => !handledTargetCats.includes(catId), - ); - const withdrawnAtByCatId = { ...(input.current.withdrawnAtByCatId ?? {}) }; - for (const catId of handledTargetCats) { - handled.add(catId); - delete awakenedInvocationIdByCatId[catId]; - delete awakenedAtByCatId[catId]; - delete seenInvocationIdByCatId[catId]; - delete steeredInvocationIdByCatId[catId]; - delete carrierStateByTargetCatId[catId]; - delete withdrawnAtByCatId[catId]; - const exposure = input.current.bodyExposures?.find( - (candidate) => candidate.targetCatId === catId && candidate.invocationId === input.invocationId, - ); - const handledAt = exposure ? Math.max(input.deliveredAt, exposure.seenAt + 1) : input.deliveredAt; - targetOutcomeByCatId[catId] = targetOutcome(catId, input.invocationId, handledAt, input.outcomeByCatId); - } - - const remainingSteerRequests = (input.current.steerRequestedByCatIds ?? []).filter( - (catId) => !handledTargetCats.includes(catId), - ); - const { - processingStartedAt: _processingStartedAt, - awakenedInvocationIdByCatId: _awakenedInvocationIdByCatId, - awakenedAtByCatId: _awakenedAtByCatId, - steerRequestedByCatIds: _steerRequestedByCatIds, - steeredInvocationIdByCatId: _steeredInvocationIdByCatId, - carrierStateByTargetCatId: _carrierStateByTargetCatId, - withdrawnByCatIds: _withdrawnByCatIds, - withdrawnAtByCatId: _withdrawnAtByCatId, - ...stableCurrent - } = input.current; - return { - completion, - next: { - ...stableCurrent, - revision: input.current.revision + 1, - status: completion.fullyConsumed ? 'terminal' : 'queued', - pendingTargetCats, - notifiedByCatIds: input.current.notifiedByCatIds.filter((catId) => !handledTargetCats.includes(catId)), - ...(Object.keys(awakenedInvocationIdByCatId).length > 0 ? { awakenedInvocationIdByCatId } : {}), - ...(Object.keys(awakenedAtByCatId).length > 0 ? { awakenedAtByCatId } : {}), - seenInvocationIdByCatId, - ...(Object.keys(carrierStateByTargetCatId).length > 0 ? { carrierStateByTargetCatId } : {}), - ...(remainingSteerRequests.length > 0 ? { steerRequestedByCatIds: remainingSteerRequests } : {}), - ...(Object.keys(steeredInvocationIdByCatId).length > 0 ? { steeredInvocationIdByCatId } : {}), - failedByCatIds: input.current.failedByCatIds.filter((catId) => !handledTargetCats.includes(catId)), - ...(withdrawnByCatIds.length > 0 ? { withdrawnByCatIds } : {}), - ...(Object.keys(withdrawnAtByCatId).length > 0 ? { withdrawnAtByCatId } : {}), - handledByCatIds: [...handled], - targetOutcomeByCatId, - targetAttempts: markTargetAttemptsHandled( - input.current, - handledTargetCats, - input.invocationId, - input.deliveredAt, - ), - updatedAt: input.updatedAt, - }, - }; -} - -function buildRetryTargetTransition( - current: QueuedMessageCustody, - targetCatId: string, - expectedAttemptId: string, - retriedAt: number, -): { next: QueuedMessageCustody; attempt?: QueueTargetAttempt } { - if (!current.pendingTargetCats.includes(targetCatId as CatId)) return { next: current }; - const attempts = ensureTargetAttempts(current); - const previous = latestTargetAttempt(attempts, targetCatId); - const retryableCancellation = previous?.state === 'cancelled' && previous.terminalReason === 'invocation_cancelled'; - if (!previous || previous.id !== expectedAttemptId || (previous.state !== 'failed' && !retryableCancellation)) { - return { next: current }; - } - const sequence = previous.sequence + 1; - const attempt = initialTargetAttempt(current.entryId, targetCatId, retriedAt); - attempt.sequence = sequence; - attempt.id = targetAttemptId(current.entryId, targetCatId, sequence); - const seenInvocationIdByCatId = { ...current.seenInvocationIdByCatId }; - const awakenedInvocationIdByCatId = { ...(current.awakenedInvocationIdByCatId ?? {}) }; - const awakenedAtByCatId = { ...(current.awakenedAtByCatId ?? {}) }; - delete seenInvocationIdByCatId[targetCatId]; - delete awakenedInvocationIdByCatId[targetCatId]; - delete awakenedAtByCatId[targetCatId]; - const { - awakenedInvocationIdByCatId: _awakenedInvocationIdByCatId, - awakenedAtByCatId: _awakenedAtByCatId, - ...stableCurrent - } = current; - return { - attempt, - next: { - ...stableCurrent, - revision: current.revision + 1, - notifiedByCatIds: current.notifiedByCatIds.filter((catId) => catId !== targetCatId), - seenByCatIds: current.seenByCatIds.filter((catId) => catId !== targetCatId), - seenInvocationIdByCatId, - ...(Object.keys(awakenedInvocationIdByCatId).length > 0 ? { awakenedInvocationIdByCatId } : {}), - ...(Object.keys(awakenedAtByCatId).length > 0 ? { awakenedAtByCatId } : {}), - failedByCatIds: current.failedByCatIds.filter((catId) => catId !== targetCatId), - targetAttempts: [...attempts, attempt], - updatedAt: retriedAt, - }, - }; -} -export class QueuedMessageCustodyCoordinator { - private readonly readWaitRegistration: ITaskStore['getWaitRegistration'] | undefined; - private readonly messageStore: IMessageStore; - private readonly now: () => number; - private readonly entryLocks = new Map>(); - - constructor(deps: CoordinatorDeps) { - this.messageStore = deps.messageStore; - this.readWaitRegistration = deps.readWaitRegistration; - this.now = deps.now ?? Date.now; - } - - async persistEntry(entry: QueueEntry): Promise { - return this.withEntryLock(entry.id, async () => { - const managedMessageIds: string[] = []; - for (const messageId of this.messageIds(entry)) { - const result = await this.transitionManaged(messageId, (current) => - activeCustodyFromEntry(entry, current, this.now()), - ); - if (result.managed) managedMessageIds.push(messageId); - } - return managedMessageIds; - }); - } - - /** - * Rebind one exact queued source after recovery proved that its old in-memory - * carrier is absent. The MessageStore CAS is the linearization point; a crash - * after it is recoverable from the replacement entryId already in custody. - */ - async transferEntryCustody(replacement: QueueEntry, proof: QueueCustodyReplacementProof): Promise { - if (proof.replacementEntryId !== replacement.id || proof.previousEntryId === replacement.id) { - throw new Error('Queue replacement proof does not bind the supplied successor'); - } - const messageIds = this.messageIds(replacement); - if (messageIds.length !== 1 || messageIds[0] !== proof.sourceMessageId) { - throw new Error('Queue replacement must bind one exact source message'); - } - - return this.withEntryLock(proof.previousEntryId, async () => { - for (let attempt = 0; attempt < 3; attempt += 1) { - const message = await this.messageStore.getById(proof.sourceMessageId); - const current = message?.queueCustody; - if (!message || message.threadId !== replacement.threadId || !current) { - throw new Error('Queue replacement source custody is missing or out of scope'); - } - if (current.entryId === replacement.id) return false; - if (current.entryId !== proof.previousEntryId || current.status === 'terminal') { - throw new Error('Queue replacement no longer owns the expected custody generation'); - } - const sameTargets = - current.pendingTargetCats.length === replacement.targetCats.length && - current.pendingTargetCats.every((catId) => replacement.targetCats.includes(catId)); - if ( - current.carrierByTargetCatId || - !sameTargets || - current.intent !== replacement.intent || - normalizeOwnerAuthProvenance(current.ownerAuthProvenance) !== replacement.ownerAuthProvenance - ) { - throw new Error('Queue replacement custody target, intent, or owner mismatch'); - } - const { processingStartedAt: _processingStartedAt, position: _position, ...stableCurrent } = current; - const next: QueuedMessageCustody = { - ...stableCurrent, - entryId: replacement.id, - revision: current.revision + 1, - status: 'queued', - priority: replacement.priority, - ...(replacement.position !== undefined ? { position: replacement.position } : {}), - updatedAt: this.now(), - }; - const result = await this.messageStore.transitionQueueCustody(proof.sourceMessageId, { - expectedRevision: current.revision, - next, - replacement: proof, - }); - if (result.kind === 'updated') return true; - if (result.kind === 'not_found') throw new Error('Queue replacement custody disappeared during transfer'); - } - throw new Error(`Queue replacement custody CAS retries exhausted for ${proof.sourceMessageId}`); - }); - } - - /** - * Linearize a true-recall body transfer against prompt-exposure persistence - * for the same Queue carrier. If exposure owns the lock first, its exact - * witness is present when recall classifies the message; if recall wins, - * later exposure persistence observes terminal custody and cannot invent a - * read receipt for the tombstoned body. - */ - async recallMessageToComposerDraft( - entryId: string, - messageId: string, - input: RecallMessageToComposerDraftInput, - carrierFence?: { freeze: () => boolean; restore: () => void }, - ): Promise { - return this.withEntryLock(entryId, async () => { - if (carrierFence && !carrierFence.freeze()) return { kind: 'carrier_changed' as const }; - try { - const result = await this.messageStore.recallMessageToComposerDraft(messageId, input); - if (carrierFence && result.kind !== 'recalled' && result.kind !== 'already_recalled') carrierFence.restore(); - return result; - } catch (error) { - carrierFence?.restore(); - throw error; - } - }); - } - - /** Fail before provider startup unless every queued body has an exact durable exposure witness. */ - async assertPromptExposurePersisted( - messageIds: readonly string[], - targetCatId: string, - invocationId: string, - ): Promise { - for (const messageId of messageIds) { - const message = await this.messageStore.getById(messageId); - if (!message?.queueCustody) throw new Error(`queued_prompt_exposure_rejected:${messageId}`); - const witnessed = message.queueCustody.bodyExposures?.some( - (exposure) => exposure.targetCatId === targetCatId && exposure.invocationId === invocationId, - ); - if (!witnessed) throw new Error(`queued_prompt_exposure_rejected:${messageId}`); - } - } - - /** - * A recall that wins after prompt construction must still stop provider - * startup. This barrier is intentionally separate from the witness-set - * assertion above: active foreign-target custody is not evidence that this - * invocation was expected to persist a receipt. - */ - async assertPromptBodiesNotRecalled( - messageIds: readonly string[], - targetCatId: string, - invocationId: string, - ): Promise { - for (const messageId of messageIds) { - const message = await this.messageStore.getById(messageId); - if (!message?.recall || !message.queueCustody) continue; - const witnessed = message.queueCustody.bodyExposures?.some( - (exposure) => exposure.targetCatId === targetCatId && exposure.invocationId === invocationId, - ); - if (!witnessed) throw new Error(`queued_prompt_exposure_rejected:${messageId}`); - } - } - - /** - * Mechanical no-reentry fence for an exact Queue target. - * - * A carrier can be restored after a multi-message success settlement failed - * midway. If any exact source already contains durable terminal target truth, - * the provider has crossed the side-effect boundary and the carrier may only - * be terminalized/reconciled — never executed again. - */ - async inspectTargetReplayFence(input: { - entry: QueueEntry; - targetCatId: string; - }): Promise< - | { disposition: 'dispatchable'; sourceMessageIds: string[] } - | { disposition: 'terminalized'; invocationId?: string; sourceMessageIds: string[] } - > { - const sourceMessageIds = this.messageIds(input.entry); - for (const messageId of sourceMessageIds) { - const message = await this.messageStore.getById(messageId); - const custody = message?.queueCustody; - // Missing and legacy-unbound custody are canonical attempt-classifier - // states, not process-reader failures. The downstream classifier owns - // their dispatchability; only an actual store read exception is unknown. - if (!custody) continue; - const carrier = custody.carrierByTargetCatId?.[input.targetCatId]; - if (carrier && carrier.entryId !== input.entry.id) { - return { disposition: 'terminalized', sourceMessageIds }; - } - if (!carrier && custody.entryId !== input.entry.id) { - throw new Error(`Queue replay fence entry mismatch for ${messageId}`); - } - const terminal = targetReplayTerminalTruth(custody, input.targetCatId); - if (terminal.terminalized) { - return { - disposition: 'terminalized', - ...(terminal.invocationId ? { invocationId: terminal.invocationId } : {}), - sourceMessageIds, - }; - } - } - return { disposition: 'dispatchable', sourceMessageIds }; - } - - /** - * Carrier-independent retirement fence. - * - * Pre-start retirement is a Queue lifecycle transition rather than an A2A - * property. It cancels every durable source and then clears custody, so an - * old coroutine must stop before provider creation regardless of carrier - * category. A partially retired batch stays fail-closed for reconciliation; - * it must not withdraw live siblings or replay the canceled source. - */ - async inspectCarrierRetirementFence(input: { - entries: readonly QueueEntry[]; - }): Promise< - | { disposition: 'dispatchable'; sourceMessageIds: string[] } - | { disposition: 'terminalized'; sourceMessageIds: string[] } - > { - const sourceMessageIds = [...new Set(input.entries.flatMap((entry) => this.messageIds(entry)))]; - let retiredSources = 0; - for (const messageId of sourceMessageIds) { - const message = await this.messageStore.getById(messageId); - if (!message?.queueCustody && message?.deliveryStatus === 'canceled') retiredSources += 1; - } - if (retiredSources === 0) return { disposition: 'dispatchable', sourceMessageIds }; - if (retiredSources !== sourceMessageIds.length) { - throw new Error('Queue carrier retirement is only partially terminalized'); - } - return { disposition: 'terminalized', sourceMessageIds }; - } - - /** - * Append one retry attempt only if the caller still names the exact latest - * failed or stopped-invocation attempt. This is the durable idempotency fence - * for retry clicks; author withdrawals remain terminal. - */ - async retryFailedTarget( - entry: QueueEntry, - targetCatId: string, - expectedAttemptId: string, - commitAuthority: RetryAuthorityCommit, - ): Promise { - return this.withEntryLock(entry.id, async () => { - let retriedAttempt: QueueTargetAttempt | undefined; - const transitions: RetryCustodyTransition[] = []; - for (const messageId of this.messageIds(entry)) { - const message = await this.messageStore.getById(messageId); - const current = message?.queueCustody; - if (!current || message.deliveryStatus !== 'queued') continue; - const result = buildRetryTargetTransition(current, targetCatId, expectedAttemptId, this.now()); - if (!result.attempt || result.next === current) continue; - retriedAttempt = result.attempt; - transitions.push({ messageId, current, next: result.next }); - } - if (!retriedAttempt || transitions.length === 0) return { outcome: 'not_retryable' }; - const committed = await commitAuthority(transitions); - if (committed.outcome === 'authority_stale') return committed; - if (committed.outcome === 'unavailable') return { outcome: 'unavailable' }; - return committed.outcome === 'committed' - ? { outcome: 'retried', attempt: retriedAttempt } - : { outcome: 'not_retryable' }; - }); - } - - /** - * Remove this carrier's pending targets from actionable custody while - * retaining the original queued message and an exact owner-visible receipt. - */ - async withdrawEntry(entry: QueueEntry): Promise { - return this.withEntryLock(entry.id, async () => { - let changed = false; - for (const messageId of this.messageIds(entry)) { - changed = - (await this.transition(messageId, (current) => - settleQueueCustodyWithdrawal(current, entry.targetCats, this.now()), - )) || changed; - } - return changed; - }); - } - - /** - * Terminalize the exact durable Queue targets bound to one completed action - * fence. Immutable carrier bindings remain as receipt provenance. - */ - async retireActionSuccessorFence( - messageId: string, - fence: NonNullable, - ): Promise { - const observed = await this.messageStore.getById(messageId); - const observedCustody = observed?.queueCustody; - if (!observed || !observedCustody) return null; - const observedBindings = Object.entries(observedCustody.carrierByTargetCatId ?? {}).filter(([, binding]) => - actionSuccessorFencesMatch(binding.actionSuccessorFence, fence), - ); - if (observedBindings.length === 0) return null; - const lockKey = `action:${fence.leaseId}:${fence.generation}`; - return this.withEntryLock(lockKey, async () => { - let targetCatIds = observedBindings.map(([catId]) => catId as CatId); - let entryIds = [...new Set(observedBindings.map(([, binding]) => binding.entryId))]; - const changed = await this.transition( - messageId, - (current) => { - const matchingBindings = Object.entries(current.carrierByTargetCatId ?? {}).filter(([, binding]) => - actionSuccessorFencesMatch(binding.actionSuccessorFence, fence), - ); - targetCatIds = matchingBindings.map(([catId]) => catId as CatId); - entryIds = [...new Set(matchingBindings.map(([, binding]) => binding.entryId))]; - return settleQueueCustodyWithdrawal(current, targetCatIds, this.now(), { - actionSuccessorTerminalFence: fence, - }); - }, - undefined, - true, - ); - return { - changed, - messageId, - threadId: observed.threadId, - userId: observed.userId, - entryIds, - targetCatIds, - }; - }); - } - - /** - * Preserve durable custody for the Queue entry that starts an invocation while - * preventing that trigger from masquerading as a work-period read receipt. - */ - async markPrimaryTrigger(entry: QueueEntry): Promise { - return this.withEntryLock(entry.id, async () => { - let changed = false; - for (const messageId of this.messageIds(entry)) { - const messageChanged = await this.transition(messageId, (current) => - current.receiptScope === 'primary_trigger' - ? current - : { - ...current, - revision: current.revision + 1, - receiptScope: 'primary_trigger', - updatedAt: this.now(), - }, - ); - if (messageChanged) changed = true; - } - return changed; - }); - } - - async commitSuccessfulTargets( - entry: QueueEntry, - successfulTargetCats: readonly string[], - invocationId: string, - deliveredAt: number, - outcomeByCatId?: Readonly>, - ): Promise { - return this.withEntryLock(entry.id, async () => { - const perMessage: QueueCustodyMessageCompletionResult[] = []; - for (const messageId of this.messageIds(entry)) { - const result = await this.commitMessageSuccessfulTargets( - messageId, - successfulTargetCats, - invocationId, - deliveredAt, - outcomeByCatId, - ); - perMessage.push({ messageId, ...result }); - } - return { perMessage }; - }); - } - - /** - * Settle only the Queue source messages named by durable response evidence. - * One operational carrier may contain sources with intentionally different - * target projections, so source-response settlement must never assume the - * complete carrier shares one completion result. - */ - async commitSuccessfulTargetsForMessages( - entry: QueueEntry, - messageIds: readonly string[], - successfulTargetCats: readonly string[], - invocationId: string, - deliveredAt: number, - outcomeByMessageId: Readonly>>>, - ): Promise { - const carrierMessageIds = new Set(this.messageIds(entry)); - const selectedMessageIds = [...new Set(messageIds)]; - if (selectedMessageIds.length === 0 || selectedMessageIds.some((messageId) => !carrierMessageIds.has(messageId))) { - throw new Error('source response settlement must select messages from the exact Queue carrier'); - } - return this.withEntryLock(entry.id, async () => { - const perMessage: QueueCustodyMessageCompletionResult[] = []; - for (const messageId of selectedMessageIds) { - const result = await this.commitMessageSuccessfulTargets( - messageId, - successfulTargetCats, - invocationId, - deliveredAt, - outcomeByMessageId[messageId], - ); - perMessage.push({ messageId, ...result }); - } - return { perMessage }; - }); - } - - /** - * Settle one exact source after its operational Queue carrier has already - * disappeared (for example, exposed true recall). The durable entryId still - * owns the lock, so this linearizes with both recall and prompt exposure. - */ - async commitSuccessfulTargetForMessage( - entryId: string, - messageId: string, - successfulTargetCat: string, - invocationId: string, - handledAt: number, - outcome: QueueTargetOutcome, - isCarrierDetached?: () => boolean, - ): Promise { - return this.withEntryLock(entryId, async () => { - const message = await this.messageStore.getById(messageId); - if (!message?.queueCustody || message.queueCustody.entryId !== entryId) { - throw new Error(`exact Queue custody binding missing for ${messageId}`); - } - if (message.queueCustody.status !== 'terminal' && isCarrierDetached?.() !== true) { - return { - handledTargetCats: [], - pendingTargetCats: [...message.queueCustody.pendingTargetCats], - fullyConsumed: false, - }; - } - return this.commitMessageSuccessfulTargets(messageId, [successfulTargetCat], invocationId, handledAt, { - [successfulTargetCat]: outcome, - }); - }); - } - - private async commitMessageSuccessfulTargets( - messageId: string, - successfulTargetCats: readonly string[], - invocationId: string, - deliveredAt: number, - outcomeByCatId?: Readonly>, - ): Promise { - const message = await this.messageStore.getById(messageId); - assertExactDispatchContinuationWitness(messageId, successfulTargetCats, outcomeByCatId); - const waitContinuationGuards: TypedWaitCustodyGuard[] = []; - if (message && isManagedHoldWakeMessage(message)) { - for (const catId of successfulTargetCats) { - const outcome = outcomeByCatId?.[catId]; - const continuation = outcome?.consumption?.kind === 'managed_hold_continued' ? outcome.consumption : undefined; - if ( - continuation?.transition === 'event_wait' && - !message.queueCustody?.handledByCatIds.some((holder) => holder === catId) - ) { - const reference = continuation.waitRegistration; - if (!reference || !this.readWaitRegistration || outcome?.invocationId !== invocationId) { - rejectTypedWaitCustody('proof_invalid'); - } - waitContinuationGuards.push({ - reference, - identity: { - invocationId, - userId: message.queueCustody?.ownerUserId ?? '', - catId, - threadId: message.threadId, - sourceMessageId: messageId, - holdTaskId: continuation.taskId, - }, - readSnapshot: () => this.readWaitRegistration!(reference.taskId), - }); - } - if ( - outcome?.disposition !== 'managed_hold_disposition' || - (continuation !== undefined && - (continuation.sourceMessageId !== messageId || continuation.taskId !== message.source?.meta?.taskId)) - ) { - if (continuation?.transition === 'event_wait') rejectTypedWaitCustody('proof_invalid'); - throw new Error('managed hold receipt requires its invocation-bound disposition'); - } - } - } - let completion: QueueCustodyCompletionResult = { - handledTargetCats: [], - pendingTargetCats: [], - fullyConsumed: false, - }; - await this.transition( - messageId, - (current) => { - const transition = buildSuccessfulTargetTransition({ - current, - successfulTargetCats, - invocationId, - deliveredAt, - updatedAt: this.now(), - outcomeByCatId, - }); - completion = transition.completion; - return transition.next; - }, - deliveredAt, - true, - waitContinuationGuards, - ); - return completion; - } - - async requestReminder( - entry: QueueEntry, - targetCatId: string, - invocationId: string, - reminderId: string, - ): Promise { - return this.mutateReminder(entry, (current, now) => - requestReminderAttempt(current, { - id: reminderId, - targetCatId, - invocationId, - requestedAt: now, - }), - ); - } - - async findReminderAttempt( - entry: QueueEntry, - targetCatId: string, - invocationId: string, - ): Promise { - for (const messageId of this.messageIds(entry)) { - const message = await this.messageStore.getById(messageId); - const attempt = message?.queueCustody?.reminderAttempts?.find( - (candidate) => candidate.targetCatId === targetCatId && candidate.invocationId === invocationId, - ); - if (attempt) return { ...attempt }; - } - return undefined; - } - - async markReminderDelivered(entry: QueueEntry, targetCatId: string, invocationId: string): Promise { - return this.mutateReminder(entry, (current, now) => - markReminderAttemptDelivered(current, targetCatId, invocationId, now), - ); - } - - async markReminderSeen(entry: QueueEntry, targetCatId: string, invocationId: string): Promise { - return this.mutateReminder(entry, (current, now) => - markReminderAttemptSeen(current, targetCatId, invocationId, now), - ); - } - - async markReminderMissed(entry: QueueEntry, invocationId: string): Promise { - return this.mutateReminder(entry, (current, now) => markReminderAttemptMissed(current, invocationId, now)); - } - - private async mutateReminder( - entry: QueueEntry, - mutate: (current: QueuedMessageCustody, now: number) => QueuedMessageCustody, - ): Promise { - return this.withEntryLock(entry.id, async () => { - let changed = false; - for (const messageId of this.messageIds(entry)) { - changed = - (await this.transition(messageId, (current) => { - const now = this.now(); - const mutated = mutate(current, now); - if (mutated === current) return current; - return { ...mutated, revision: current.revision + 1, updatedAt: now }; - })) || changed; - } - return changed; - }); - } - - private async transition( - messageId: string, - buildNext: (current: QueuedMessageCustody) => QueuedMessageCustody, - deliveredAt?: number, - allowTerminalCurrent = false, - waitContinuationGuards?: readonly TypedWaitCustodyGuard[], - ): Promise { - return ( - await this.transitionManaged(messageId, buildNext, deliveredAt, allowTerminalCurrent, waitContinuationGuards) - ).changed; - } - - private async transitionManaged( - messageId: string, - buildNext: (current: QueuedMessageCustody) => QueuedMessageCustody, - deliveredAt?: number, - allowTerminalCurrent = false, - waitContinuationGuards?: readonly TypedWaitCustodyGuard[], - ): Promise<{ managed: boolean; changed: boolean }> { - for (let attempt = 0; attempt < QUEUE_CUSTODY_CAS_MAX_ATTEMPTS; attempt += 1) { - const message = await this.messageStore.getById(messageId); - const current = message?.queueCustody; - if (!current || (current.status === 'terminal' && !allowTerminalCurrent)) { - return { managed: false, changed: false }; - } - const next = buildNext(current); - if (sameProjection(current, next)) return { managed: true, changed: false }; - const result = await this.messageStore.transitionQueueCustody(messageId, { - expectedRevision: current.revision, - next, - ...(waitContinuationGuards ? { waitContinuationGuards } : {}), - ...(current.status !== 'terminal' && next.status === 'terminal' && deliveredAt !== undefined - ? { deliveredAt } - : {}), - }); - if (result.kind === 'updated') return { managed: true, changed: true }; - if (result.kind === 'not_found') return { managed: false, changed: false }; - await waitForQueueCustodyCasRetry(attempt); - } - throw new Error(`queue custody CAS retries exhausted for message ${messageId}`); - } - - private messageIds(entry: Pick): string[] { - return [entry.messageId ?? '', ...entry.mergedMessageIds].filter(Boolean); - } - - private async withEntryLock(entryId: string, work: () => Promise): Promise { - const previous = this.entryLocks.get(entryId) ?? Promise.resolve(); - const run = previous.catch(() => {}).then(work); - const settled = run.then( - () => undefined, - () => undefined, - ); - this.entryLocks.set(entryId, settled); - try { - return await run; - } finally { - if (this.entryLocks.get(entryId) === settled) this.entryLocks.delete(entryId); - } - } -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartProjector.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartProjector.ts deleted file mode 100644 index e27c57a01d..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartProjector.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { CatId } from '@cat-cafe/shared'; -import type { QueuedMessageCustody, StoredMessage } from '../../stores/ports/MessageStore.js'; -import { settleQueueCustodyWithdrawal } from '../../stores/ports/queued-message-custody.js'; -import { resolveReplacedA2ATargetCats } from './QueuedMessageCustodyA2ARestartPreflight.js'; -import { resolveRestartTargets } from './QueuedMessageCustodyRestartTargets.js'; -import { projectRuntimeRestartAttempts } from './QueuedMessageCustodyRuntimeRestartAttempts.js'; -import { resolveRestartReminderAttempts } from './QueuedMessageCustodyStartupProjectionHelpers.js'; -import type { StartupCustodyDeps } from './QueuedMessageCustodyStartupTypes.js'; - -export interface RestartProjectionResult { - next: QueuedMessageCustody; - handledTargets: number; - failedTargets: number; - recoveryDeferred?: boolean; -} - -export async function buildRestartProjection( - deps: StartupCustodyDeps, - message: StoredMessage, - current: QueuedMessageCustody, - now: number, -): Promise { - const replacementPreflight = await resolveReplacedA2ATargetCats( - message, - current, - deps.a2aDispatchDispositionService, - deps.messageStore, - deps.log, - ); - if (replacementPreflight.recoveryDeferred) { - return { next: current, handledTargets: 0, failedTargets: 0, recoveryDeferred: true }; - } - const { replacedTargetCats: replacedA2ATargetCats } = replacementPreflight; - const target = await resolveRestartTargets( - message, - current, - deps.messageStore, - deps.invocationRecordStore, - deps.turnExecutionStore, - replacedA2ATargetCats, - now, - ); - // Body-read truth is stronger than reminder transport state. Resolve it - // first, then close attempts owned by the vanished runtime. - const reminderProjection = resolveRestartReminderAttempts(current, now, target.live); - const targetAttempts = projectRuntimeRestartAttempts(current, target.interrupted, now); - const authorIntentByCatId = current.authorIntentByCatId ? structuredClone(current.authorIntentByCatId) : undefined; - if (authorIntentByCatId) { - for (const catId of target.pending) { - if (replacedA2ATargetCats.has(catId) || target.live.has(catId)) continue; - const authorIntent = authorIntentByCatId[catId]; - if ( - authorIntent?.requested !== 'continue_current' || - authorIntent.fallbackAt !== undefined || - !authorIntent.boundParentInvocationId - ) { - continue; - } - const hadExposure = (current.bodyExposures ?? []).some((exposure) => exposure.targetCatId === catId); - authorIntentByCatId[catId] = { - ...authorIntent, - fallbackAt: now, - fallbackReason: hadExposure ? 'parent_non_success_after_exposure' : 'parent_terminal_before_exposure', - }; - } - } - - for (const catId of current.steerRequestedByCatIds ?? []) { - if (replacedA2ATargetCats.has(catId) || !target.pending.has(catId) || target.live.has(catId)) continue; - target.failed.add(catId); - target.failedTargets += 1; - } - - const terminal = target.pending.size === 0; - const hasLiveAcceptedTarget = target.live.size > 0; - const { - processingStartedAt: _processingStartedAt, - awakenedInvocationIdByCatId: _awakenedInvocationIdByCatId, - awakenedAtByCatId: _awakenedAtByCatId, - steerRequestedByCatIds: _steerRequestedByCatIds, - steeredInvocationIdByCatId: _steeredInvocationIdByCatId, - carrierStateByTargetCatId: _carrierStateByTargetCatId, - ...stableCurrent - } = current; - const carrierStateByTargetCatId = Object.fromEntries( - [...target.pending].map((catId) => { - if (!target.live.has(catId)) return [catId, { status: 'queued' as const }]; - const currentCarrierState = current.carrierStateByTargetCatId?.[catId]; - return [ - catId, - { - status: 'processing' as const, - processingStartedAt: - currentCarrierState?.processingStartedAt ?? current.processingStartedAt ?? current.updatedAt, - }, - ]; - }), - ); - const nextBeforeReplacementWithdrawal: QueuedMessageCustody = { - ...stableCurrent, - // Replacement withdrawal owns the one allowed revision increment. - revision: current.revision + (replacedA2ATargetCats.size > 0 ? 0 : 1), - status: terminal ? 'terminal' : hasLiveAcceptedTarget ? 'processing' : 'queued', - ...(hasLiveAcceptedTarget ? { processingStartedAt: current.processingStartedAt ?? current.updatedAt } : {}), - ...(authorIntentByCatId ? { authorIntentByCatId } : {}), - pendingTargetCats: [...target.pending] as CatId[], - notifiedByCatIds: [...target.notified] as CatId[], - ...(Object.keys(target.awakenedInvocationIdByCatId).length > 0 - ? { awakenedInvocationIdByCatId: target.awakenedInvocationIdByCatId } - : {}), - ...(Object.keys(target.awakenedAtByCatId).length > 0 ? { awakenedAtByCatId: target.awakenedAtByCatId } : {}), - seenInvocationIdByCatId: target.seenInvocationIdByCatId, - failedByCatIds: [...target.failed] as CatId[], - handledByCatIds: [...target.handled] as CatId[], - targetOutcomeByCatId: target.targetOutcomeByCatId, - ...(targetAttempts && targetAttempts.length > 0 ? { targetAttempts } : {}), - ...(Object.keys(carrierStateByTargetCatId).length > 0 ? { carrierStateByTargetCatId } : {}), - ...(reminderProjection.reminderAttempts ? { reminderAttempts: reminderProjection.reminderAttempts } : {}), - updatedAt: now, - }; - const next = - replacedA2ATargetCats.size > 0 - ? settleQueueCustodyWithdrawal(nextBeforeReplacementWithdrawal, [...replacedA2ATargetCats], now) - : nextBeforeReplacementWithdrawal; - return { - next, - handledTargets: target.handledTargets, - failedTargets: target.failedTargets, - }; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartTargets.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartTargets.ts deleted file mode 100644 index f256976a59..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartTargets.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { IInvocationRecordStore } from '../../stores/ports/InvocationRecordStore.js'; -import type { IMessageStore, QueuedMessageCustody, StoredMessage } from '../../stores/ports/MessageStore.js'; -import type { ITurnExecutionStore } from '../../stores/ports/TurnExecutionStore.js'; -import { resolveRestartExecutionWitness } from './QueuedMessageCustodyRestartWitness.js'; -import { - type QueueSourceResponseEvidence, - resolveQueueSourceResponseEvidenceFromMessages, -} from './queue-source-response-evidence.js'; - -export interface RestartTargetProjection { - pending: Set; - handled: Set; - failed: Set; - notified: Set; - awakenedInvocationIdByCatId: Record; - awakenedAtByCatId: Record; - seenInvocationIdByCatId: Record; - targetOutcomeByCatId: NonNullable; - handledTargets: number; - failedTargets: number; - interrupted: Set; - live: Set; -} - -interface RestartSourceResponse { - exposure: NonNullable[number]; - witness: QueueSourceResponseEvidence['witness']; -} - -function resolveRestartSourceResponse( - message: StoredMessage, - current: QueuedMessageCustody, - catId: string, - threadMessages: readonly StoredMessage[], -): RestartSourceResponse | null { - const exposures = [...(current.bodyExposures ?? [])] - .filter((candidate) => candidate.targetCatId === catId) - .sort((left, right) => right.seenAt - left.seenAt); - for (const exposure of exposures) { - const sourceResponse = resolveQueueSourceResponseEvidenceFromMessages({ - messages: threadMessages, - catId, - invocationId: exposure.invocationId, - sourceMessageIds: [message.id], - })[0]; - if (sourceResponse) return { exposure, witness: sourceResponse.witness }; - } - return null; -} - -function interruptAcceptedTarget(projection: RestartTargetProjection, catId: string): void { - if (!projection.pending.delete(catId)) return; - if (!projection.failed.has(catId)) projection.failedTargets += 1; - projection.failed.add(catId); - projection.interrupted.add(catId); - projection.notified.delete(catId); - delete projection.awakenedInvocationIdByCatId[catId]; - delete projection.awakenedAtByCatId[catId]; - delete projection.seenInvocationIdByCatId[catId]; -} - -function completeTarget( - projection: RestartTargetProjection, - catId: string, - invocationId?: string, - exposure?: NonNullable[number], - now?: number, -): void { - projection.pending.delete(catId); - projection.handled.add(catId); - projection.failed.delete(catId); - projection.notified.delete(catId); - delete projection.awakenedInvocationIdByCatId[catId]; - delete projection.awakenedAtByCatId[catId]; - delete projection.seenInvocationIdByCatId[catId]; - if (invocationId && exposure && now !== undefined) { - projection.targetOutcomeByCatId[catId] = { - invocationId, - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId }, - handledAt: Math.max(now, exposure.seenAt + 1), - }; - } - projection.handledTargets += 1; -} - -export async function resolveRestartTargets( - message: StoredMessage, - current: QueuedMessageCustody, - messageStore: IMessageStore, - invocationRecordStore: Pick, - turnExecutionStore: Pick | undefined, - replacedA2ATargetCats: ReadonlySet, - now: number, -): Promise { - const projection: RestartTargetProjection = { - pending: new Set(current.pendingTargetCats), - handled: new Set(current.handledByCatIds), - failed: new Set(current.failedByCatIds), - notified: new Set(current.notifiedByCatIds), - awakenedInvocationIdByCatId: { ...(current.awakenedInvocationIdByCatId ?? {}) }, - awakenedAtByCatId: { ...(current.awakenedAtByCatId ?? {}) }, - seenInvocationIdByCatId: { ...current.seenInvocationIdByCatId }, - targetOutcomeByCatId: { ...(current.targetOutcomeByCatId ?? {}) }, - handledTargets: 0, - failedTargets: 0, - interrupted: new Set(), - live: new Set(), - }; - const retainedExposureTargets = new Set( - (current.bodyExposures ?? []) - .filter((exposure) => projection.pending.has(exposure.targetCatId)) - .map((exposure) => exposure.targetCatId), - ); - let threadMessages: readonly StoredMessage[] = []; - if (retainedExposureTargets.size > 0) { - const readThread = messageStore.getByThreadAfter?.bind(messageStore); - if (readThread) { - // Exact cat/invocation/causal fences below own authority; source userId - // must not hide scheduler-authored managed-command rows. - threadMessages = await readThread(message.threadId); - } - } - - // Append-only body exposures can prove a source response even after active - // seen maps were cleared by failed bookkeeping. - for (const catId of [...projection.pending]) { - if (replacedA2ATargetCats.has(catId) || !retainedExposureTargets.has(catId)) continue; - const sourceResponse = resolveRestartSourceResponse(message, current, catId, threadMessages); - if (!sourceResponse) continue; - const { exposure, witness } = sourceResponse; - completeTarget(projection, catId); - projection.targetOutcomeByCatId[catId] = { - invocationId: exposure.invocationId, - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: exposure.invocationId }, - handledAt: Math.max(now, exposure.seenAt + 1), - consumption: witness, - }; - } - - for (const [catId, invocationId] of Object.entries(current.seenInvocationIdByCatId)) { - if (replacedA2ATargetCats.has(catId) || !projection.pending.has(catId)) continue; - const exposure = current.bodyExposures?.find( - (candidate) => candidate.targetCatId === catId && candidate.invocationId === invocationId, - ); - const witness = await resolveRestartExecutionWitness( - message, - catId, - invocationId, - exposure !== undefined, - invocationRecordStore, - turnExecutionStore, - ); - if (witness === 'child_execution' || witness === 'legacy_parent_aggregate') { - completeTarget(projection, catId, witness === 'child_execution' ? invocationId : undefined, exposure, now); - } else if (witness === 'live_child') { - projection.live.add(catId); - } else if (witness === 'interrupted_child') { - interruptAcceptedTarget(projection, catId); - } else if ( - witness === null && - (exposure !== undefined || current.awakenedInvocationIdByCatId?.[catId] === invocationId) - ) { - // Custody is the accepted-before-result witness. Missing exact child - // truth must interrupt, never replay unknown provider effects. - interruptAcceptedTarget(projection, catId); - } else { - delete projection.seenInvocationIdByCatId[catId]; - projection.failed.add(catId); - projection.failedTargets += 1; - } - } - - for (const catId of Object.keys(current.awakenedInvocationIdByCatId ?? {})) { - if (replacedA2ATargetCats.has(catId)) continue; - if (current.seenInvocationIdByCatId[catId] || !projection.pending.has(catId)) continue; - const invocationId = current.awakenedInvocationIdByCatId?.[catId]; - const witness = invocationId - ? await resolveRestartExecutionWitness( - message, - catId, - invocationId, - false, - invocationRecordStore, - turnExecutionStore, - ) - : null; - if (witness === 'live_child') projection.live.add(catId); - else if (witness === 'interrupted_child' || witness === null) interruptAcceptedTarget(projection, catId); - else if (witness === 'child_execution' || witness === 'legacy_parent_aggregate') { - completeTarget(projection, catId); - } else { - if (!projection.failed.has(catId)) projection.failedTargets += 1; - projection.failed.add(catId); - } - } - return projection; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartWitness.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartWitness.ts deleted file mode 100644 index d71dd38b32..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRestartWitness.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { CatId } from '@cat-cafe/shared'; -import type { IInvocationRecordStore } from '../../stores/ports/InvocationRecordStore.js'; -import type { StoredMessage } from '../../stores/ports/MessageStore.js'; -import type { ITurnExecutionStore } from '../../stores/ports/TurnExecutionStore.js'; - -export type RestartExecutionWitness = - | 'child_execution' - | 'legacy_parent_aggregate' - | 'live_child' - | 'interrupted_child' - | 'retryable_terminal_child'; - -export async function resolveRestartExecutionWitness( - message: StoredMessage, - catId: string, - invocationId: string, - hasExactBodyExposure: boolean, - invocationRecordStore: Pick, - turnExecutionStore?: Pick, -): Promise { - if (turnExecutionStore) { - const child = await turnExecutionStore.get(invocationId); - if (child) { - if (child.threadId !== message.threadId || child.userId !== message.userId || child.catId !== catId) { - return null; - } - if (child.status === 'succeeded') return 'child_execution'; - if (child.status === 'running') return 'live_child'; - if (child.status === 'interrupted' && child.terminalReason === 'process_restart') return 'interrupted_child'; - return 'retryable_terminal_child'; - } - } - // Once an exact child/body tuple exists, only the durable child ledger may - // close it. The parent aggregate is retained solely as a rolling-migration - // witness for pre-ledger custody that never recorded an exact exposure. - if (hasExactBodyExposure) return null; - const record = await invocationRecordStore.get(invocationId); - return record?.status === 'succeeded' && - record.threadId === message.threadId && - record.userId === message.userId && - record.targetCats.includes(catId as CatId) && - record.successfulCatIds?.includes(catId as CatId) === true - ? 'legacy_parent_aggregate' - : null; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRuntimeRestartAttempts.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRuntimeRestartAttempts.ts deleted file mode 100644 index 4cace14500..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyRuntimeRestartAttempts.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { QueueTargetAttempt } from '@cat-cafe/shared'; -import type { QueuedMessageCustody } from '../../stores/ports/MessageStore.js'; - -export function projectRuntimeRestartAttempts( - current: QueuedMessageCustody, - interruptedTargetCats: ReadonlySet, - now: number, -): QueueTargetAttempt[] | undefined { - if (interruptedTargetCats.size === 0) { - return current.targetAttempts?.map((attempt) => ({ ...attempt })); - } - const attempts = (current.targetAttempts ?? []).map((attempt) => ({ ...attempt })); - for (const catId of interruptedTargetCats) { - const candidates = attempts - .map((attempt, index) => ({ attempt, index })) - .filter(({ attempt }) => attempt.targetCatId === catId) - .sort((left, right) => left.attempt.sequence - right.attempt.sequence); - const latest = candidates.at(-1); - const invocationId = - latest?.attempt.invocationId ?? - current.seenInvocationIdByCatId[catId] ?? - current.awakenedInvocationIdByCatId?.[catId]; - if (latest) { - if (latest.attempt.state === 'interrupted') continue; - if (['failed', 'cancelled', 'handled'].includes(latest.attempt.state)) { - const sequence = latest.attempt.sequence + 1; - const entryId = current.carrierByTargetCatId?.[catId]?.entryId ?? current.entryId; - attempts.push({ - id: `${entryId}:${catId}:${sequence}`, - targetCatId: catId, - sequence, - state: 'interrupted', - terminalReason: 'runtime_restart', - createdAt: now, - updatedAt: now, - ...(invocationId ? { invocationId } : {}), - }); - continue; - } - attempts[latest.index] = { - ...latest.attempt, - state: 'interrupted', - terminalReason: 'runtime_restart', - updatedAt: Math.max(latest.attempt.updatedAt, now), - ...(invocationId ? { invocationId } : {}), - }; - continue; - } - const entryId = current.carrierByTargetCatId?.[catId]?.entryId ?? current.entryId; - attempts.push({ - id: `${entryId}:${catId}:1`, - targetCatId: catId, - sequence: 1, - state: 'interrupted', - terminalReason: 'runtime_restart', - createdAt: current.processingStartedAt ?? current.createdAt, - updatedAt: now, - ...(invocationId ? { invocationId } : {}), - }); - } - return attempts; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupMessageReconciler.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupMessageReconciler.ts deleted file mode 100644 index 6d5eea934b..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupMessageReconciler.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { CatId } from '@cat-cafe/shared'; -import { - WaitContinuationCarrierError, - waitContinuationCarrierFromStoredMessage, -} from '../../../../ball-custody/wait-continuation-carrier.js'; -import type { QueuedMessageCustody, StoredMessage } from '../../stores/ports/MessageStore.js'; -import { buildRestartProjection } from './QueuedMessageCustodyRestartProjector.js'; -import { - resolveRestartReminderAttempts, - sameActiveProjection, - uniqueCatIds, -} from './QueuedMessageCustodyStartupProjectionHelpers.js'; -import type { ReconciledMessage, StartupCustodyDeps } from './QueuedMessageCustodyStartupTypes.js'; - -export async function initializeLegacyCustody( - deps: StartupCustodyDeps, - message: StoredMessage, - now: number, -): Promise<{ message: StoredMessage; backfilled: boolean } | null> { - const explicitTargets = message.extra?.targetCats?.filter((value): value is string => typeof value === 'string'); - const targets = uniqueCatIds(explicitTargets?.length ? explicitTargets : message.mentions); - if (targets.length === 0) { - deps.log.warn( - `[queue-custody-startup] cannot restore legacy queued message without target identity: ${message.id}`, - ); - return null; - } - const custody: QueuedMessageCustody = { - version: 1, - entryId: `legacy:${message.id}`, - revision: 1, - ownerUserId: message.userId, - intent: 'execute', - status: 'queued', - allTargetCats: targets, - pendingTargetCats: targets, - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: message.timestamp, - updatedAt: now, - }; - const initialized = await deps.messageStore.initializeQueueCustody(message.id, custody); - if (initialized.kind === 'not_found' || initialized.kind === 'not_queued') return null; - return { message: initialized.message, backfilled: initialized.kind === 'initialized' }; -} - -export async function reconcileStartupCustodyMessage( - deps: StartupCustodyDeps, - messageId: string, - now: () => number, -): Promise { - for (let attempt = 0; attempt < 3; attempt += 1) { - const message = await deps.messageStore.getById(messageId); - const current = message?.queueCustody; - if (!message || message.deliveryStatus !== 'queued' || !current) return null; - if (current.status === 'terminal' && (current.withdrawnByCatIds?.length ?? 0) > 0) { - return { message, terminalized: true, handledTargets: 0, failedTargets: 0 }; - } - const built = await buildRestartProjection(deps, message, current, now()); - if (sameActiveProjection(current, built.next)) { - return { - message, - terminalized: false, - handledTargets: 0, - failedTargets: 0, - ...(built.recoveryDeferred ? { recoveryDeferred: true } : {}), - }; - } - const result = await deps.messageStore.transitionQueueCustody(messageId, { - expectedRevision: current.revision, - next: built.next, - ...(built.next.status === 'terminal' ? { deliveredAt: now() } : {}), - }); - if (result.kind === 'revision_mismatch') continue; - if (result.kind === 'not_found') return null; - return { - message: result.message, - terminalized: result.message.queueCustody?.status === 'terminal', - handledTargets: built.handledTargets, - failedTargets: built.failedTargets, - ...(built.recoveryDeferred ? { recoveryDeferred: true } : {}), - }; - } - throw new Error(`queue custody startup CAS retries exhausted for message ${messageId}`); -} - -export async function terminalizeLegacyUnfencedWait( - deps: StartupCustodyDeps, - messageId: string, - now: () => number, -): Promise<{ terminalized: boolean; failedTargets: number }> { - for (let attempt = 0; attempt < 3; attempt += 1) { - const message = await deps.messageStore.getById(messageId); - const current = message?.queueCustody; - if (!message || message.deliveryStatus !== 'queued' || !current || current.status === 'terminal') { - return { terminalized: false, failedTargets: 0 }; - } - try { - waitContinuationCarrierFromStoredMessage(message); - return { terminalized: false, failedTargets: 0 }; - } catch (error) { - if (!(error instanceof WaitContinuationCarrierError)) throw error; - } - const timestamp = now(); - const failedTargetCats = uniqueCatIds([...current.failedByCatIds, ...current.pendingTargetCats]); - const reminderProjection = resolveRestartReminderAttempts(current, timestamp); - const { - processingStartedAt: _processingStartedAt, - awakenedInvocationIdByCatId: _awakenedInvocationIdByCatId, - awakenedAtByCatId: _awakenedAtByCatId, - steerRequestedByCatIds: _steerRequestedByCatIds, - steeredInvocationIdByCatId: _steeredInvocationIdByCatId, - carrierStateByTargetCatId: _carrierStateByTargetCatId, - reminderAttempts: _reminderAttempts, - ...stableCurrent - } = current; - const result = await deps.messageStore.transitionQueueCustody(messageId, { - expectedRevision: current.revision, - next: { - ...stableCurrent, - revision: current.revision + 1, - status: 'terminal', - pendingTargetCats: [], - failedByCatIds: failedTargetCats as CatId[], - ...(reminderProjection.reminderAttempts ? { reminderAttempts: reminderProjection.reminderAttempts } : {}), - updatedAt: timestamp, - }, - deliveredAt: timestamp, - }); - if (result.kind === 'revision_mismatch') continue; - if (result.kind === 'not_found') return { terminalized: false, failedTargets: 0 }; - return { terminalized: true, failedTargets: current.pendingTargetCats.length }; - } - throw new Error(`legacy wait terminalization CAS retries exhausted for message ${messageId}`); -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupProjectionHelpers.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupProjectionHelpers.ts deleted file mode 100644 index dac3d5ab24..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupProjectionHelpers.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { CatId } from '@cat-cafe/shared'; -import type { QueuedMessageCustody } from '../../stores/ports/MessageStore.js'; -import { markReminderAttemptMissed, markReminderAttemptSeen } from '../../stores/ports/queued-message-receipt.js'; - -export function uniqueCatIds(values: readonly string[]): CatId[] { - return [...new Set(values.filter(Boolean))] as CatId[]; -} - -function activeProjection(custody: QueuedMessageCustody): Omit { - const { revision: _revision, updatedAt: _updatedAt, ...projection } = custody; - return projection; -} - -export function sameActiveProjection(left: QueuedMessageCustody, right: QueuedMessageCustody): boolean { - return JSON.stringify(activeProjection(left)) === JSON.stringify(activeProjection(right)); -} - -function reminderBelongsToLiveTarget( - current: QueuedMessageCustody, - targetCatIds: ReadonlySet, - targetCatId: string, - invocationId: string, -): boolean { - if (!targetCatIds.has(targetCatId)) return false; - const liveInvocationId = - current.awakenedInvocationIdByCatId?.[targetCatId] ?? current.seenInvocationIdByCatId[targetCatId]; - return liveInvocationId === invocationId; -} - -export function resolveRestartReminderAttempts( - current: QueuedMessageCustody, - now: number, - liveTargetCatIds: ReadonlySet = new Set(), -): QueuedMessageCustody { - let projection = current; - for (const attempt of current.reminderAttempts ?? []) { - if (attempt.state !== 'requested' && attempt.state !== 'delivered') continue; - if (reminderBelongsToLiveTarget(current, liveTargetCatIds, attempt.targetCatId, attempt.invocationId)) { - continue; - } - projection = - current.seenInvocationIdByCatId[attempt.targetCatId] === attempt.invocationId - ? markReminderAttemptSeen(projection, attempt.targetCatId, attempt.invocationId, now) - : markReminderAttemptMissed(projection, attempt.invocationId, now); - } - return projection; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupQueueEntry.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupQueueEntry.ts deleted file mode 100644 index c3f6efe978..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupQueueEntry.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { CatId } from '@cat-cafe/shared'; -import { - waitContinuationCarrierFromStoredMessage, - waitContinuationCarriersMatch, -} from '../../../../ball-custody/wait-continuation-carrier.js'; -import type { StoredMessage } from '../../stores/ports/MessageStore.js'; -import type { QueueEntry } from './InvocationQueue.js'; -import { normalizeOwnerAuthProvenance } from './owner-auth-provenance.js'; -import { - activeCarrierEntryIds, - isQueuedCarrierTarget, - queuedCarrierEntryIds, -} from './QueuedMessageCustodyCarrierProjection.js'; -import { - createCrossThreadQueueEntryFromCustody, - isManagedHoldWakeMessage, - projectQueuedAttemptIds, -} from './QueuedMessageCustodyCoordinator.js'; -import { sameActiveProjection } from './QueuedMessageCustodyStartupProjectionHelpers.js'; -import type { StartupCustodyDeps } from './QueuedMessageCustodyStartupTypes.js'; - -export function groupActiveMessages(messages: StoredMessage[]): Map { - const groups = new Map(); - for (const message of messages) { - const custody = message.queueCustody; - if (!custody) continue; - for (const entryId of queuedCarrierEntryIds(message)) { - const group = groups.get(entryId) ?? []; - group.push(message); - groups.set(entryId, group); - } - } - return groups; -} - -export async function hasUnresolvedQueuedCarrierMember( - deps: StartupCustodyDeps, - entryId: string, - messages: readonly StoredMessage[], - unresolvedQueuedMessageIds: ReadonlySet, -): Promise { - if (unresolvedQueuedMessageIds.size === 0) return false; - const primary = messages[0]; - if (!primary) return false; - try { - const threadMessages = await deps.messageStore.getByThreadAfter( - primary.threadId, - undefined, - undefined, - primary.queueCustody?.ownerUserId, - { - includeQueuedCatMessages: true, - includeQueuedUserMessages: true, - }, - ); - return threadMessages.some( - (message) => unresolvedQueuedMessageIds.has(message.id) && activeCarrierEntryIds(message).includes(entryId), - ); - } catch (error) { - deps.log.warn( - `[queue-custody-startup] cannot inspect unresolved source membership for Queue group ${entryId}; ` + - `preserving it without provider recovery: ${error instanceof Error ? error.message : String(error)}`, - ); - return true; - } -} - -export function buildQueueEntry(messages: StoredMessage[], entryId: string): QueueEntry { - messages.sort((left, right) => left.timestamp - right.timestamp || left.id.localeCompare(right.id)); - const primary = messages[0]; - const custody = primary?.queueCustody; - if (!primary || !custody || custody.pendingTargetCats.length === 0) { - throw new Error('active queue custody group is missing its primary projection'); - } - if ( - custody.executionScope && - (messages.length !== 1 || - custody.allTargetCats.length !== 1 || - (custody.executionScope === 'collective-participation' - ? custody.ownerAuthProvenance !== 'unknown' - : custody.ownerAuthProvenance !== 'strict')) - ) { - throw new Error('Collective execution scope must retain its singleton source and owner provenance after restart'); - } - if (custody.carrierByTargetCatId) { - return createCrossThreadQueueEntryFromCustody(messages, entryId, { queuedTargetsOnly: true }); - } - for (const sibling of messages.slice(1)) { - const siblingCustody = sibling.queueCustody; - if ( - !siblingCustody || - sibling.threadId !== primary.threadId || - sibling.userId !== primary.userId || - !sameActiveProjection(custody, siblingCustody) - ) { - throw new Error(`divergent queued message custody group: ${custody.entryId}`); - } - } - const pendingTargets = custody.pendingTargetCats.filter((catId) => isQueuedCarrierTarget(custody, catId)); - if (pendingTargets.length === 0) { - throw new Error(`active queue custody group has no target for carrier ${entryId}`); - } - const allTargets = [...custody.allTargetCats]; - const waitContinuationCarrier = waitContinuationCarrierFromStoredMessage(primary); - const managedHoldWake = isManagedHoldWakeMessage(primary); - for (const sibling of messages.slice(1)) { - if (!waitContinuationCarriersMatch(waitContinuationCarrierFromStoredMessage(sibling), waitContinuationCarrier)) { - throw new Error(`divergent wait continuation carriers for Queue entry ${entryId}`); - } - } - const targetSet = new Set(allTargets); - const filterTargets = (values: readonly CatId[]): CatId[] => values.filter((catId) => targetSet.has(catId)); - const filterInvocationMap = (values: Readonly>): Record => - Object.fromEntries(Object.entries(values).filter(([catId]) => targetSet.has(catId))); - const filterTimestampMap = (values: Readonly>): Record => - Object.fromEntries(Object.entries(values).filter(([catId]) => targetSet.has(catId))); - const queuedAttemptIdByCatId = projectQueuedAttemptIds(custody, pendingTargets); - return { - id: entryId, - threadId: primary.threadId, - userId: custody.ownerUserId ?? primary.userId, - ownerAuthProvenance: normalizeOwnerAuthProvenance(custody.ownerAuthProvenance), - ...(custody.executionScope ? { executionScope: custody.executionScope } : {}), - content: messages.map((message) => message.content).join('\n'), - messageId: primary.id, - mergedMessageIds: messages.slice(1).map((message) => message.id), - source: - waitContinuationCarrier || managedHoldWake || custody.executionScope || (primary.catId === null && primary.source) - ? 'connector' - : 'user', - ...(waitContinuationCarrier ? { waitContinuationCarrier } : {}), - targetCats: pendingTargets, - allTargetCats: allTargets, - ...(custody.authorIntentByCatId ? { authorIntentByCatId: structuredClone(custody.authorIntentByCatId) } : {}), - queuedNotifiedByCatIds: filterTargets(custody.notifiedByCatIds), - queuedAwakenedInvocationIdByCatId: filterInvocationMap(custody.awakenedInvocationIdByCatId ?? {}), - queuedAwakenedAtByCatId: filterTimestampMap(custody.awakenedAtByCatId ?? {}), - queuedSeenByCatIds: filterTargets(custody.seenByCatIds), - queuedSeenInvocationIdByCatId: filterInvocationMap(custody.seenInvocationIdByCatId), - queuedBodyExposures: (custody.bodyExposures ?? []) - .filter((exposure) => targetSet.has(exposure.targetCatId)) - .map((exposure) => ({ ...exposure })), - queuedFailedByCatIds: filterTargets(custody.failedByCatIds), - ...(Object.keys(queuedAttemptIdByCatId).length > 0 ? { queuedAttemptIdByCatId } : {}), - queuedHandledByCatIds: filterTargets(custody.handledByCatIds), - steerRequestedByCatIds: filterTargets(custody.steerRequestedByCatIds ?? []), - steeredInvocationIdByCatId: filterInvocationMap(custody.steeredInvocationIdByCatId ?? {}), - ...(custody.prestartRetirement - ? { - prestartRetirement: { - ...custody.prestartRetirement, - entryIds: [...custody.prestartRetirement.entryIds], - }, - } - : {}), - intent: custody.intent, - status: custody.prestartRetirement ? 'processing' : 'queued', - ...(custody.prestartRetirement - ? { processingStartedAt: custody.processingStartedAt ?? custody.prestartRetirement.startedAt } - : {}), - createdAt: custody.createdAt, - autoExecute: false, - priority: custody.priority, - ...(managedHoldWake ? { sourceCategory: 'scheduled' as const } : {}), - ...(custody.position !== undefined ? { position: custody.position } : {}), - }; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.ts deleted file mode 100644 index 26708dee2a..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { WaitContinuationCarrierError } from '../../../../ball-custody/wait-continuation-carrier.js'; -import type { StoredMessage } from '../../stores/ports/MessageStore.js'; -import type { QueueEntry } from './InvocationQueue.js'; -import { activeCarrierEntryIds, queuedCarrierEntryIds } from './QueuedMessageCustodyCarrierProjection.js'; -import { - createFanoutQueueEntriesFromAdmission, - createInitialFanoutQueuedMessageCustody, - sameFanoutCustodyIdentity, -} from './QueuedMessageCustodyCoordinator.js'; -import { - initializeLegacyCustody, - reconcileStartupCustodyMessage, - terminalizeLegacyUnfencedWait, -} from './QueuedMessageCustodyStartupMessageReconciler.js'; -import { - buildQueueEntry, - groupActiveMessages, - hasUnresolvedQueuedCarrierMember, -} from './QueuedMessageCustodyStartupQueueEntry.js'; -import type { - QueueCustodyResumeScope, - QueueCustodyStartupResult, - StartupCustodyDeps, -} from './QueuedMessageCustodyStartupTypes.js'; - -export type { QueueCustodyResumeScope, QueueCustodyStartupResult } from './QueuedMessageCustodyStartupTypes.js'; - -function emptyResult(): QueueCustodyStartupResult { - return { - entriesRestored: 0, - messagesBackfilled: 0, - messagesTerminalized: 0, - messagesFailed: 0, - handledTargets: 0, - failedTargets: 0, - resumeScopes: [], - prestartRetirements: [], - legacyVisibilityFallbackMessageIds: [], - }; -} - -export class QueuedMessageCustodyStartupReconciler { - private readonly now: () => number; - - constructor(private readonly deps: StartupCustodyDeps) { - this.now = deps.now ?? Date.now; - } - - async reconcile(): Promise { - const scan = this.deps.messageStore.scanByDeliveryStatus; - if (!scan) return emptyResult(); - - const queuedMessageIds = await scan.call(this.deps.messageStore, 'queued'); - const activeMessages: StoredMessage[] = []; - const deferredCarrierEntryIds = new Set(); - const unresolvedQueuedMessageIds = new Set(); - let messagesBackfilled = 0; - let messagesTerminalized = 0; - let messagesFailed = 0; - let handledTargets = 0; - let failedTargets = 0; - const legacyVisibilityFallbackMessageIds: string[] = []; - - for (const messageId of queuedMessageIds) { - let scannedMessage: StoredMessage | null = null; - try { - let message = await this.deps.messageStore.getById(messageId); - scannedMessage = message; - if (!message || message.deliveryStatus !== 'queued') continue; - if (!message.queueCustody) { - if (message.queueCustodyAdmission) { - const recoveryEntries = createFanoutQueueEntriesFromAdmission( - message as StoredMessage & { queueCustodyAdmission: NonNullable }, - ); - const admission = message.queueCustodyAdmission; - const expectedCustody = createInitialFanoutQueuedMessageCustody(message.id, recoveryEntries, { - requestedTargetCats: admission.requestedTargetCats ?? admission.targetCats, - createdAt: admission.createdAt, - ...(admission.receiptScope ? { receiptScope: admission.receiptScope } : {}), - ...(admission.receiptScope === 'cross_thread_delivery' - ? { custodyEntryId: `cross-thread:${message.id}` } - : {}), - }); - const initialized = await this.deps.messageStore.initializeQueueCustody(message.id, expectedCustody); - if ( - (initialized.kind !== 'initialized' && initialized.kind !== 'existing') || - !sameFanoutCustodyIdentity(initialized.message.queueCustody, expectedCustody) - ) { - throw new Error(`fan-out Queue admission recovery failed: ${initialized.kind}`); - } - message = initialized.message; - messagesBackfilled += 1; - } else if (message.catId !== null) { - legacyVisibilityFallbackMessageIds.push(message.id); - continue; - } else { - const initialized = await initializeLegacyCustody(this.deps, message, this.now()); - if (!initialized) continue; - message = initialized.message; - if (initialized.backfilled) messagesBackfilled += 1; - } - } - const reconciled = await reconcileStartupCustodyMessage(this.deps, message.id, this.now); - if (!reconciled) continue; - handledTargets += reconciled.handledTargets; - failedTargets += reconciled.failedTargets; - if (reconciled.terminalized) messagesTerminalized += 1; - else if (reconciled.recoveryDeferred) { - for (const entryId of activeCarrierEntryIds(reconciled.message)) deferredCarrierEntryIds.add(entryId); - } else { - const queuedEntryIds = new Set(queuedCarrierEntryIds(reconciled.message)); - if (queuedEntryIds.size > 0) activeMessages.push(reconciled.message); - for (const entryId of activeCarrierEntryIds(reconciled.message)) { - if (!queuedEntryIds.has(entryId)) deferredCarrierEntryIds.add(entryId); - } - } - } catch (error) { - messagesFailed += 1; - const entryIds = scannedMessage ? activeCarrierEntryIds(scannedMessage) : []; - if (entryIds.length > 0) { - for (const entryId of entryIds) deferredCarrierEntryIds.add(entryId); - } else unresolvedQueuedMessageIds.add(messageId); - this.deps.log.warn( - `[queue-custody-startup] isolated corrupt/racing message ${messageId}: ` + - `${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - const groups = groupActiveMessages(activeMessages); - const resumeScopes: QueueCustodyResumeScope[] = []; - const builtEntries: QueueEntry[] = []; - let entriesRestored = 0; - for (const [entryId, messages] of groups) { - const blockedByUnresolvedSource = await hasUnresolvedQueuedCarrierMember( - this.deps, - entryId, - messages, - unresolvedQueuedMessageIds, - ); - if (deferredCarrierEntryIds.has(entryId) || blockedByUnresolvedSource) { - this.deps.log.warn( - `[queue-custody-startup] deferred Queue group ${entryId} after incomplete source reconciliation; ` + - 'preserving every coalesced source without provider recovery', - ); - continue; - } - try { - builtEntries.push(buildQueueEntry(messages, entryId)); - } catch (error) { - if (error instanceof WaitContinuationCarrierError) { - for (const message of messages) { - try { - const disposition = await terminalizeLegacyUnfencedWait(this.deps, message.id, this.now); - if (disposition.terminalized) messagesTerminalized += 1; - failedTargets += disposition.failedTargets; - } catch (dispositionError) { - messagesFailed += 1; - this.deps.log.warn( - `[queue-custody-startup] failed to terminalize legacy unfenced wait ${message.id}: ` + - `${dispositionError instanceof Error ? dispositionError.message : String(dispositionError)}`, - ); - } - } - } else messagesFailed += messages.length; - this.deps.log.warn( - `[queue-custody-startup] isolated non-restorable Queue group ${entryId} ` + - `(${messages.map((message) => message.id).join(',')}): ` + - `${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - const retirementIntents = new Map( - builtEntries.flatMap((entry) => - entry.prestartRetirement ? [[entry.prestartRetirement.id, entry.prestartRetirement] as const] : [], - ), - ); - const retirementGroups = new Map(); - for (const entry of builtEntries) { - const inheritedIntent = - entry.prestartRetirement ?? - [...retirementIntents.values()].find((intent) => intent.entryIds.includes(entry.id)); - const restoredEntry = inheritedIntent - ? { - ...entry, - status: 'processing' as const, - processingStartedAt: entry.processingStartedAt ?? inheritedIntent.startedAt, - prestartRetirement: { ...inheritedIntent, entryIds: [...inheritedIntent.entryIds] }, - } - : entry; - const outcome = this.deps.invocationQueue.restoreDurableEntry(restoredEntry); - if (outcome === 'restored') entriesRestored += 1; - if (inheritedIntent) { - const retirementGroup = retirementGroups.get(inheritedIntent.id) ?? []; - retirementGroup.push(restoredEntry); - retirementGroups.set(inheritedIntent.id, retirementGroup); - } else if (outcome === 'restored') { - resumeScopes.push({ threadId: entry.threadId, userId: entry.userId }); - } - } - - if (queuedMessageIds.length > 0) { - this.deps.log.info( - `[queue-custody-startup] reconciled ${queuedMessageIds.length} queued message(s), ` + - `${entriesRestored} Queue owner(s) restored, ${messagesTerminalized} message(s) terminalized`, - ); - } - return { - entriesRestored, - messagesBackfilled, - messagesTerminalized, - messagesFailed, - handledTargets, - failedTargets, - resumeScopes, - prestartRetirements: [...retirementGroups.values()], - legacyVisibilityFallbackMessageIds, - }; - } -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupTypes.ts b/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupTypes.ts deleted file mode 100644 index 73c41a0f3d..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupTypes.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { A2ADispatchDispositionService } from '../../../../ball-custody/A2ADispatchDispositionService.js'; -import type { IInvocationRecordStore } from '../../stores/ports/InvocationRecordStore.js'; -import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; -import type { ITurnExecutionStore } from '../../stores/ports/TurnExecutionStore.js'; -import type { InvocationQueue, QueueEntry } from './InvocationQueue.js'; - -export interface StartupCustodyLog { - info(msg: string): void; - warn(msg: string): void; -} - -export interface StartupCustodyDeps { - messageStore: IMessageStore; - invocationRecordStore: Pick; - turnExecutionStore?: Pick; - /** Reuses the live Queue preflight to fence a source already superseded in Ball custody. */ - a2aDispatchDispositionService?: Pick; - invocationQueue: InvocationQueue; - log: StartupCustodyLog; - now?: () => number; -} - -export interface QueueCustodyResumeScope { - threadId: string; - userId: string; -} - -export interface QueueCustodyStartupResult { - entriesRestored: number; - messagesBackfilled: number; - messagesTerminalized: number; - messagesFailed: number; - handledTargets: number; - failedTargets: number; - resumeScopes: QueueCustodyResumeScope[]; - /** Restart-stable exact groups that must finish retirement before normal Queue resume. */ - prestartRetirements: QueueEntry[][]; - /** Pre-custody agent handoffs retain their legacy visibility recovery path. */ - legacyVisibilityFallbackMessageIds: string[]; -} - -export interface ReconciledMessage { - message: StoredMessage; - terminalized: boolean; - handledTargets: number; - failedTargets: number; - /** The replacement fence could not establish truth, so startup must not resume this carrier. */ - recoveryDeferred?: boolean; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts b/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts index 72e2aa5ef4..35b3cd683e 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts @@ -14,17 +14,12 @@ import { randomUUID } from 'node:crypto'; import type { CatId, ConnectorSource } from '@cat-cafe/shared'; -import type { A2ADispatchDispositionService } from '../../../../ball-custody/A2ADispatchDispositionService.js'; import type { IBallCustodyIngest } from '../../../../ball-custody/BallCustodyIngest.js'; import { buildInvocationDiedEvent } from '../../../../ball-custody/ball-custody-events.js'; import type { IInvocationRecordStore, InvocationRecord } from '../../stores/ports/InvocationRecordStore.js'; -import type { AppendMessageInput, IMessageStore } from '../../stores/ports/MessageStore.js'; +import type { AppendMessageInput } from '../../stores/ports/MessageStore.js'; import type { ITurnExecutionStore } from '../../stores/ports/TurnExecutionStore.js'; -import type { InvocationQueue, QueueEntry } from './InvocationQueue.js'; -import { - type QueueCustodyResumeScope, - QueuedMessageCustodyStartupReconciler, -} from './QueuedMessageCustodyStartupReconciler.js'; +import type { InvocationQueue } from './InvocationQueue.js'; import type { TaskProgressStore } from './TaskProgressStore.js'; export interface StartupSweepResult { @@ -38,7 +33,7 @@ export interface StartupSweepResult { queueEntriesRestored: number; queueEntriesResumed: number; /** Durable Queue scopes that remain eligible after reconciliation, whether resumed here or by the caller. */ - queueResumeScopes: QueueCustodyResumeScope[]; + queueResumeScopes: Array<{ threadId: string; userId: string }>; queueMessagesBackfilled: number; queueMessagesTerminalized: number; durationMs: number; @@ -53,11 +48,6 @@ interface MessageAppender { append(msg: AppendMessageInput): unknown; /** Mark a queued message as delivered (make visible in timeline). */ markDelivered?(id: string, deliveredAt: number): unknown; - /** #697: Scan for message IDs with a given deliveryStatus. */ - scanByDeliveryStatus?(status: string): string[] | Promise; - getById?(id: string): unknown; - initializeQueueCustody?(id: string, custody: unknown): unknown; - transitionQueueCustody?(id: string, input: unknown): unknown; } interface ConnectorMessageBroadcaster { @@ -87,12 +77,8 @@ export interface StartupReconcilerDeps { ballCustody?: IBallCustodyIngest; /** F254: exact in-memory projection rebuilt from durable queued-message custody. */ invocationQueue?: InvocationQueue; - /** F298: exact A2A replacement fence shared with live Queue admission. */ - a2aDispatchDispositionService?: Pick; /** F254: natural next-spawn hook, invoked once for each newly restored queue scope. */ resumeQueue?: (threadId: string, userId: string) => Promise; - /** Resume a durable exact-group retirement before any later entry in that scope may dispatch. */ - resumePrestartRetirement?: (entries: readonly QueueEntry[]) => Promise; } type ScanStore = IInvocationRecordStore & { scanByStatus(status: string): Promise }; @@ -134,36 +120,11 @@ export class StartupReconciler { const runResult = await this.sweepRunning(scanStore, this.deps.processStartAt, affectedThreads); const queueResult = await this.sweepStaleQueued(scanStore, affectedThreads); - // #697: Recover orphaned queued messages that have no matching InvocationRecord. - // These were persisted to MessageStore with deliveryStatus='queued' but the - // in-memory InvocationQueue entry was lost on restart. Without this, they stay - // invisible in timeline forever. - const queueCustodyRecovery = await this.reconcileDurableQueueCustody(); - const orphanedMessageRecovery = queueCustodyRecovery - ? queueCustodyRecovery.messagesTerminalized + - (await this.recoverOrphanedQueuedMessages( - affectedThreads, - new Set(queueCustodyRecovery.legacyVisibilityFallbackMessageIds), - )) - : await this.recoverOrphanedQueuedMessages(affectedThreads); - const blockedResumeScopes = new Set(); - for (const entries of queueCustodyRecovery?.prestartRetirements ?? []) { - const first = entries[0]; - if (!first) continue; - const scopeKey = `${first.threadId}\u0000${first.userId}`; - try { - const retired = (await this.deps.resumePrestartRetirement?.(entries)) ?? false; - if (!retired) blockedResumeScopes.add(scopeKey); - } catch (err) { - blockedResumeScopes.add(scopeKey); - this.deps.log.warn( - `[startup-reconciler] Failed to resume durable pre-start retirement ${first.prestartRetirement?.id}: ${String(err)}`, - ); - } - } - const queueResumeScopes = (queueCustodyRecovery?.resumeScopes ?? []).filter( - (scope) => !blockedResumeScopes.has(`${scope.threadId}\u0000${scope.userId}`), - ); + // ADR-043: Queue rows were hydrated directly from the durable ledger before + // this sweep. Atomic message+row admission makes orphan-message recovery and + // message-custody reconstruction unnecessary. + const orphanedMessageRecovery = 0; + const queueResumeScopes = this.deps.invocationQueue?.listScopes() ?? []; let queueEntriesResumed = 0; if (this.deps.resumeQueue) { for (const scope of queueResumeScopes) { @@ -198,11 +159,12 @@ export class StartupReconciler { taskProgressCleared, messagesRecovered, notifiedThreads, - queueEntriesRestored: queueCustodyRecovery?.entriesRestored ?? 0, + queueEntriesRestored: queueResumeScopes.length, queueEntriesResumed, queueResumeScopes, - queueMessagesBackfilled: queueCustodyRecovery?.messagesBackfilled ?? 0, - queueMessagesTerminalized: queueCustodyRecovery?.messagesTerminalized ?? 0, + queueMessagesBackfilled: 0, + // Compatibility counter: schema v2 never persists processing/terminal Queue rows. + queueMessagesTerminalized: 0, durationMs, }; } @@ -231,7 +193,7 @@ export class StartupReconciler { if (updated) { running++; this.recordInvocationDied(record, lastScanAt); - if (!(await this.isDurableQueuedMessage(record.userMessageId))) { + if (!this.isDurableQueuedMessage(record.threadId, record.userMessageId)) { this.trackAffectedThread(affectedThreads, record); } taskProgressCleared += await this.clearTaskProgress(record.threadId, record.targetCats); @@ -285,7 +247,7 @@ export class StartupReconciler { }); if (updated) { queued++; - if (!(await this.isDurableQueuedMessage(record.userMessageId))) { + if (!this.isDurableQueuedMessage(record.threadId, record.userMessageId)) { this.trackAffectedThread(affectedThreads, record); } if (await this.ensureMessageVisible(record)) messagesRecovered++; @@ -325,9 +287,9 @@ export class StartupReconciler { if (messageStore) { try { const stored = await messageStore.append({ + from: { kind: 'system', service: 'startup-reconciler' }, threadId, userId, - catId: null, content, mentions: [], source: RECONCILER_SOURCE, @@ -384,149 +346,9 @@ export class StartupReconciler { return cleared; } - private async isDurableQueuedMessage(messageId: string | null): Promise { - if (!this.deps.invocationQueue || !messageId || !this.deps.messageStore?.getById) return false; - try { - const message = await this.deps.messageStore.getById(messageId); - return Boolean( - message && typeof message === 'object' && (message as { deliveryStatus?: string }).deliveryStatus === 'queued', - ); - } catch { - return false; - } - } - - private async reconcileDurableQueueCustody() { - const { invocationQueue, messageStore } = this.deps; - if ( - !invocationQueue || - !messageStore?.scanByDeliveryStatus || - !messageStore.getById || - !messageStore.initializeQueueCustody || - !messageStore.transitionQueueCustody - ) { - return null; - } - const reconciler = new QueuedMessageCustodyStartupReconciler({ - invocationRecordStore: this.deps.invocationRecordStore, - ...(this.deps.turnExecutionStore ? { turnExecutionStore: this.deps.turnExecutionStore } : {}), - ...(this.deps.a2aDispatchDispositionService - ? { a2aDispatchDispositionService: this.deps.a2aDispatchDispositionService } - : {}), - invocationQueue, - messageStore: messageStore as IMessageStore, - log: this.deps.log, - }); - return reconciler.reconcile(); - } - - /** - * #697: Find messages still stuck as deliveryStatus='queued' in MessageStore - * that have no corresponding InvocationRecord (the in-memory queue entry was - * lost on restart). Mark them as delivered so they appear in the timeline. - * - * P2-1 (#805): Also mark any corresponding queued InvocationRecords as failed - * to maintain the InvocationRecord single-truth-source invariant. Without this, - * a fresh queued record (age < 5min, not caught by sweepStaleQueued) would - * remain in Redis as dirty residue — message delivered but record still queued. - */ - private async recoverOrphanedQueuedMessages( - affectedThreads: Map, - onlyMessageIds?: ReadonlySet, - ): Promise { - const { messageStore } = this.deps; - if (!messageStore?.scanByDeliveryStatus || !messageStore.markDelivered) return 0; - - let recovered = 0; - try { - const scannedIds = await messageStore.scanByDeliveryStatus('queued'); - const queuedIds = onlyMessageIds ? scannedIds.filter((id) => onlyMessageIds.has(id)) : scannedIds; - if (queuedIds.length === 0) return 0; - - this.deps.log.info(`[startup-reconciler] Found ${queuedIds.length} orphaned queued message(s) — recovering`); - const now = Date.now(); - const recoveredMessageIds = new Set(); - - for (const id of queuedIds) { - try { - const result = await messageStore.markDelivered(id, now); - if (result != null) { - recovered++; - recoveredMessageIds.add(id); - // Only owner-authored queued work represents an interrupted request. - // Legacy agent handoffs are already-authored speech whose visibility - // metadata is being repaired; treating their mentions as interrupted - // cats produces false restart notices after an otherwise clean Stop. - const msg = result as { - threadId?: string; - userId?: string; - catId?: CatId | null; - mentions?: CatId[]; - }; - if (msg.threadId && msg.catId == null) { - const existing = affectedThreads.get(msg.threadId) ?? { - catIds: [], - userId: (msg.userId as string) ?? 'unknown', - }; - // Populate catIds from message mentions so notification isn't "0 cats" - if (msg.mentions) { - for (const catId of msg.mentions) { - if (!existing.catIds.includes(catId)) existing.catIds.push(catId); - } - } - if (existing.catIds.length === 0) { - this.deps.log.warn( - `[startup-reconciler] unusual: queued message ${id} has no mentions — broadcast or system message in invocation queue`, - ); - } - affectedThreads.set(msg.threadId, existing); - } - } - } catch (err) { - this.deps.log.warn(`[startup-reconciler] Failed to recover queued message ${id}: ${String(err)}`); - } - } - - // P2-1: Clean up corresponding InvocationRecords to prevent dirty residue. - // Scan all queued records and mark any whose userMessageId was just recovered. - await this.cleanupMatchingInvocationRecords(recoveredMessageIds); - } catch (err) { - this.deps.log.warn(`[startup-reconciler] Failed to scan for orphaned queued messages: ${String(err)}`); - } - return recovered; - } - - /** - * P2-1 (#805): After recovering orphaned queued messages, clean up any - * InvocationRecords that reference those messages. Aligns record state with - * message state (both converge to terminal) so InvocationRecord remains the - * single truth source for invocation lifecycle. - */ - private async cleanupMatchingInvocationRecords(recoveredMessageIds: Set): Promise { - if (recoveredMessageIds.size === 0) return; - const store = this.deps.invocationRecordStore; - // biome-ignore lint/complexity/useLiteralKeys: TS index signature requires bracket access - if (!('scanByStatus' in store) || typeof (store as Record)['scanByStatus'] !== 'function') return; - const scanStore = store as ScanStore; - - try { - const queuedRecordIds = await scanStore.scanByStatus('queued'); - for (const recordId of queuedRecordIds) { - const record = await store.get(recordId); - if (record?.userMessageId && recoveredMessageIds.has(record.userMessageId)) { - await store.update(recordId, { - status: 'failed', - expectedStatus: 'queued', - error: 'process_restart', - }); - this.deps.log.info( - `[startup-reconciler] Marked InvocationRecord ${recordId} as failed (message ${record.userMessageId} recovered)`, - ); - } - } - } catch (err) { - this.deps.log.warn(`[startup-reconciler] Failed to clean up InvocationRecords: ${String(err)}`); - } + private isDurableQueuedMessage(threadId: string, messageId: string | null): boolean { + if (!this.deps.invocationQueue || !messageId) return false; + return this.deps.invocationQueue.findEntryWithMessageId(threadId, messageId)?.status === 'queued'; } /** @@ -537,7 +359,7 @@ export class StartupReconciler { private async ensureMessageVisible(record: InvocationRecord): Promise { const { messageStore } = this.deps; if (!messageStore?.markDelivered || !record.userMessageId) return false; - if (await this.isDurableQueuedMessage(record.userMessageId)) return false; + if (this.isDurableQueuedMessage(record.threadId, record.userMessageId)) return false; try { const result = await messageStore.markDelivered(record.userMessageId, Date.now()); if (result && typeof result === 'object' && 'deliveryTransitioned' in result) { diff --git a/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-model.ts b/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-model.ts new file mode 100644 index 0000000000..0de5932b10 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-model.ts @@ -0,0 +1,61 @@ +export interface CallerDispatchObservationPointer { + readonly ownerId: string; + readonly threadId: string; + readonly callerCatId: string; + readonly sourceMessageId: string; + readonly targetId: string; + readonly revision: number; + readonly presentedRevision: number; + readonly firstAddedBy: 'initial' | 'steer' | 'unknown'; + readonly selectionChange: 'added' | 'removed'; + readonly selectionChangedBy: 'initial' | 'steer' | 'queue_withdrawal' | 'unknown'; +} + +export interface CallerDispatchObservationInclusion { + readonly key: string; + /** Revision frozen before the asynchronous History read that produced this prompt line. */ + readonly includedRevision: number; + readonly fingerprint: string; + readonly terminal: boolean; +} + +export interface CallerDispatchObservationProjection { + readonly prompt: string; + /** Only facts actually retained in the final prompt may be acknowledged on success. */ + readonly included: readonly CallerDispatchObservationInclusion[]; + readonly truncated: boolean; +} + +export interface CallerDispatchProcessStartProjection { + readonly prompt: string; +} + +export type CallerDispatchObservationScope = Pick< + CallerDispatchObservationPointer, + 'ownerId' | 'threadId' | 'callerCatId' +>; + +export interface ObservationLine { + line: string; + terminal: boolean; + fingerprint: string; +} + +export function callerDispatchObservationSlotKey(pointer: CallerDispatchObservationScope): string { + return JSON.stringify([pointer.ownerId, pointer.threadId, pointer.callerCatId]); +} + +export function callerDispatchObservationKey( + pointer: Pick< + CallerDispatchObservationPointer, + 'ownerId' | 'threadId' | 'callerCatId' | 'sourceMessageId' | 'targetId' + >, +): string { + return JSON.stringify([ + pointer.ownerId, + pointer.threadId, + pointer.callerCatId, + pointer.sourceMessageId, + pointer.targetId, + ]); +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-projector.ts b/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-projector.ts new file mode 100644 index 0000000000..06ad453331 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-projector.ts @@ -0,0 +1,61 @@ +import type { + CallerDispatchObservationInclusion, + CallerDispatchObservationPointer, + CallerDispatchObservationProjection, + ObservationLine, +} from './caller-dispatch-observation-model.js'; + +export interface ProjectedObservation extends ObservationLine { + key: string; + includedRevision: number; +} + +const MAX_READS_PER_PROJECTION = 64; + +export async function projectCallerDispatchObservations(options: { + allPointers: readonly CallerDispatchObservationPointer[]; + maxPromptChars: number; + refresh: (pointer: CallerDispatchObservationPointer) => Promise; +}): Promise { + const pointers = options.allPointers + .slice() + .sort((left, right) => left.revision - right.revision) + .slice(0, MAX_READS_PER_PROJECTION); + if (pointers.length === 0) return { prompt: '', included: [], truncated: false }; + + const header = [ + '[Outbound Dispatch Status]', + '以下状态来自你先前提交的 source×target delivery lifecycle;不是新任务,也不会自动唤醒你。', + ]; + const observations: ProjectedObservation[] = []; + for (const pointer of pointers) { + const observation = await options.refresh(pointer); + if (observation) observations.push(observation); + } + observations.sort((left, right) => Number(right.terminal) - Number(left.terminal)); + + const lines: string[] = []; + const included: CallerDispatchObservationInclusion[] = []; + let truncated = options.allPointers.length > pointers.length; + for (const { key, line, terminal, fingerprint, includedRevision } of observations) { + const candidate = [...header, ...lines, line, '[/Outbound Dispatch Status]'].join('\n'); + if (candidate.length > options.maxPromptChars) { + truncated = true; + continue; + } + lines.push(line); + included.push({ key, includedRevision, fingerprint, terminal }); + } + + if (lines.length === 0) return { prompt: '', included: [], truncated }; + if (truncated) { + const notice = '- 其余待观察项因本 turn 输入预算保留到后续自然 invocation。'; + const withNotice = [...header, ...lines, notice, '[/Outbound Dispatch Status]'].join('\n'); + if (withNotice.length <= options.maxPromptChars) lines.push(notice); + } + return { + prompt: [...header, ...lines, '[/Outbound Dispatch Status]'].join('\n'), + included, + truncated, + }; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-reader.ts b/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-reader.ts new file mode 100644 index 0000000000..a130ad24fa --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/caller-dispatch-observation-reader.ts @@ -0,0 +1,171 @@ +import type { CatId } from '@cat-cafe/shared'; +import { projectMessageBundleReadableContentWithCoverage } from '../../context/MessageBundleSourceProjection.js'; +import { messageFrom } from '../../stores/message-from.js'; +import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; +import { canViewMessage, isTimelinePublished } from '../../stores/visibility.js'; +import type { CallerDispatchObservationPointer, ObservationLine } from './caller-dispatch-observation-model.js'; + +const MAX_BODY_CHARS = 1_200; + +interface MessageReadResult { + message: StoredMessage | null; + failed: boolean; +} + +function compactBody(value: string): string { + const compact = value.replace(/\s+/g, ' ').trim(); + if (compact.length <= MAX_BODY_CHARS) return compact; + return `${compact.slice(0, MAX_BODY_CHARS - 1)}…`; +} + +function sourceOwnsPointer(source: StoredMessage, pointer: CallerDispatchObservationPointer): boolean { + const from = messageFrom(source); + return ( + source.id === pointer.sourceMessageId && + source.userId === pointer.ownerId && + source.threadId === pointer.threadId && + !source.deletedAt && + !source._tombstone && + isTimelinePublished(source) && + from.kind === 'agent' && + from.catId === pointer.callerCatId + ); +} + +function canceledSourceOwnsWithdrawnPointer(source: StoredMessage, pointer: CallerDispatchObservationPointer): boolean { + const from = messageFrom(source); + return ( + pointer.selectionChange === 'removed' && + source.deliveryStatus === 'canceled' && + source.id === pointer.sourceMessageId && + source.userId === pointer.ownerId && + source.threadId === pointer.threadId && + !source.deletedAt && + !source._tombstone && + from.kind === 'agent' && + from.catId === pointer.callerCatId + ); +} + +function isReadableObservationResponse(response: StoredMessage, pointer: CallerDispatchObservationPointer): boolean { + return ( + response.threadId === pointer.threadId && + !response.deletedAt && + !response._tombstone && + response.origin !== 'briefing' && + isTimelinePublished(response) && + canViewMessage(response, { type: 'cat', catId: pointer.callerCatId as CatId }) + ); +} + +function unknownLine(pointer: CallerDispatchObservationPointer, reason: string): ObservationLine { + return { + line: `- ${pointer.sourceMessageId} → ${pointer.targetId}: unknown(${reason},保留待观察)`, + terminal: false, + fingerprint: `unknown:${reason}`, + }; +} + +function unadmittedObservationLine(pointer: CallerDispatchObservationPointer): ObservationLine { + if (pointer.selectionChange === 'removed') { + const withdrawalReason = + pointer.selectionChangedBy === 'steer' + ? 'withdrawn by committed Steer' + : pointer.selectionChangedBy === 'queue_withdrawal' + ? 'withdrawn by committed Queue cancellation' + : 'withdrawn by committed Queue mutation'; + return { + line: `- ${pointer.sourceMessageId} → ${pointer.targetId}: not_delivered(${withdrawalReason})`, + terminal: true, + fingerprint: `selection:removed:${pointer.selectionChangedBy}:${pointer.revision}`, + }; + } + return { + line: `- ${pointer.sourceMessageId} → ${pointer.targetId}: pending; selectedBy=${pointer.firstAddedBy}`, + terminal: false, + fingerprint: `selection:added:${pointer.selectionChangedBy}:${pointer.revision}`, + }; +} + +async function readMessage( + messageStore: Pick, + messageId: string, +): Promise { + try { + return { message: await messageStore.getById(messageId), failed: false }; + } catch { + return { message: null, failed: true }; + } +} + +function terminalObservationLine( + response: StoredMessage, + pointer: CallerDispatchObservationPointer, +): ObservationLine | null { + const readable = projectMessageBundleReadableContentWithCoverage(response); + if (!readable.fullyRepresented) return unknownLine(pointer, 'terminal payload 无法完整投影'); + const body = compactBody(readable.content) || '(empty)'; + if ( + response.lifecycle?.kind === 'response' && + response.userId === pointer.ownerId && + response.lifecycle.targetId === pointer.targetId && + response.lifecycle.inputMessageIds.includes(pointer.sourceMessageId) + ) { + if (response.lifecycle.status === 'processing') { + return unknownLine(pointer, 'settled ref 指向 processing response'); + } + return { + line: `- ${pointer.sourceMessageId} → ${pointer.targetId}: ${response.lifecycle.status}; response=${response.id}; body=${body}`, + terminal: true, + fingerprint: `response:${response.id}:${response.lifecycle.status}`, + }; + } + if ( + response.lifecycle?.kind !== 'delivery_failure' || + response.lifecycle.inputMessageId !== pointer.sourceMessageId || + !response.lifecycle.requestedTargets.includes(pointer.targetId) + ) { + return null; + } + return { + line: `- ${pointer.sourceMessageId} → ${pointer.targetId}: delivery_failure(${response.lifecycle.reason}); response=${response.id}; body=${body}`, + terminal: true, + fingerprint: `delivery_failure:${response.id}:${response.lifecycle.reason}`, + }; +} + +export async function readCallerDispatchObservationLine( + messageStore: Pick, + pointer: CallerDispatchObservationPointer, +): Promise { + const sourceRead = await readMessage(messageStore, pointer.sourceMessageId); + if (sourceRead.failed) return unknownLine(pointer, 'History 读取失败'); + const source = sourceRead.message; + if (!source) return unknownLine(pointer, 'source/scope 不可验证'); + if (!sourceOwnsPointer(source, pointer) && !canceledSourceOwnsWithdrawnPointer(source, pointer)) { + return unknownLine(pointer, 'source/scope 不可验证'); + } + if (source.lifecycle?.kind !== 'input' && source.lifecycle?.kind !== 'response') { + return unknownLine(pointer, 'source lifecycle 不匹配'); + } + const refs = (source.lifecycle.dispatchRefs ?? []).filter((ref) => ref.targetId === pointer.targetId); + if (refs.length === 0) return unadmittedObservationLine(pointer); + if (refs.length !== 1) return unknownLine(pointer, 'exact dispatchRef 不唯一'); + const ref = refs[0]; + if (!ref) return unknownLine(pointer, 'exact dispatchRef 缺失'); + if (ref.phase === 'dispatched') { + return { + line: `- ${pointer.sourceMessageId} → ${pointer.targetId}: executing`, + terminal: false, + fingerprint: `dispatch:${ref.phase}:${ref.statusMessageId}`, + }; + } + + const responseRead = await readMessage(messageStore, ref.statusMessageId); + if (responseRead.failed) return unknownLine(pointer, 'terminal response 读取失败'); + const response = responseRead.message; + if (!response || !isReadableObservationResponse(response, pointer)) { + return unknownLine(pointer, 'terminal response 不可验证'); + } + return terminalObservationLine(response, pointer) ?? unknownLine(pointer, 'response lifecycle 不匹配'); +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/convergeZombieQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/convergeZombieQueue.ts deleted file mode 100644 index cb4d880b51..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/convergeZombieQueue.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { QueueEntry } from './InvocationQueue.js'; - -/** Minimal queue surface needed to converge a dead invocation's stale entry. */ -export interface ZombieQueueConverger { - list(threadId: string, userId: string): QueueEntry[]; - removeProcessed(threadId: string, userId: string, entryId: string): QueueEntry | null; -} - -export type QueueConvergedHandler = (info: { threadId: string; userId: string; removedEntryIds: string[] }) => void; - -interface ZombieQueueRecord { - threadId: string; - userId: string; - userMessageId?: string | null; -} - -interface ZombieQueueIdentity { - invocationId: string; - reason: string; -} - -interface ZombieQueueLogger { - info: (obj: unknown, msg?: string) => void; - warn: (obj: unknown, msg?: string) => void; -} - -/** - * F220 Phase 2a (clowder-ai#972) — remove the dead invocation's stale - * `processing` queue entry. - * - * QueueEntry has no invocationId, so the trigger message is the join key: - * InvocationRecord.userMessageId matches QueueEntry.messageId or mergedMessageIds. - * Removal stays exact-id and processing-only; a concurrent replacement tombstone - * therefore degrades to null without a duplicate broadcast. - */ -export function convergeZombieQueueEntry( - queue: ZombieQueueConverger | undefined, - record: ZombieQueueRecord, - zombie: ZombieQueueIdentity, - log: ZombieQueueLogger, - onQueueConverged: QueueConvergedHandler | undefined, -): { converged: number; errors: number } { - if (!queue) return { converged: 0, errors: 0 }; - const messageId = record.userMessageId; - if (!messageId) return { converged: 0, errors: 0 }; - - try { - const stale = queue - .list(record.threadId, record.userId) - .filter( - (entry) => - entry.status === 'processing' && - (entry.messageId === messageId || entry.mergedMessageIds?.includes(messageId)), - ); - - const removedEntryIds: string[] = []; - for (const entry of stale) { - if (queue.removeProcessed(record.threadId, record.userId, entry.id)) { - removedEntryIds.push(entry.id); - log.info( - { - invocationId: zombie.invocationId, - entryId: entry.id, - messageId, - threadId: record.threadId, - reason: zombie.reason, - }, - '[reconcile-zombies] converged stale processing queue entry', - ); - } - } - - if (removedEntryIds.length > 0) { - onQueueConverged?.({ threadId: record.threadId, userId: record.userId, removedEntryIds }); - } - return { converged: removedEntryIds.length, errors: 0 }; - } catch (err) { - log.warn( - { - invocationId: zombie.invocationId, - err: err instanceof Error ? err.message : String(err), - }, - '[reconcile-zombies] failed to converge queue entry', - ); - return { converged: 0, errors: 1 }; - } -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index cb98bae0c3..012c965273 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -1163,11 +1163,6 @@ export interface InvocationDeps { import('../../cloud-bridge/cloud-return-grant.js').CloudReturnGrantStore, 'issue' >; - /** Server-owned exact A2A terminal producer used by the cloud transport. */ - readonly a2aDispatchDispositionService?: Pick< - import('../../../../ball-custody/A2ADispatchDispositionService.js').A2ADispatchDispositionService, - 'complete' - >; /** * F254 Phase B3/B4: Optional freshness re-invoke callback. * Called after invocation terminal event to decide if a re-invoke is needed @@ -1318,9 +1313,25 @@ export interface InvocationParams { invocationId: string; messageIds: readonly string[]; seenAt: number; - }) => Promise< - readonly import('../../../../ball-custody/TurnCustodyProjectionService.js').TurnCustodyWakeProvenance[] | void - >; + }) => Promise; + /** Create the exact child's durable processing response before provider startup. */ + readonly onLifecycleInvocationStarted?: (input: { + threadId: string; + userId: string; + catId: CatId; + invocationId: string; + parentInvocationId: string; + startedAt: number; + }) => Promise<{ + responseMessageId: string; + priorFrontierMessageId: string | null; + activeRun: import('@cat-cafe/shared').LifecycleActiveRun; + }>; + /** Bind the exact live provider adapter after provider turn acceptance. */ + readonly onAgentClientActiveRunReady?: (input: { + catId: CatId; + dispatcher: import('../../types.js').AgentClientActiveRunDispatcher; + }) => (() => void) | undefined; /** Scope-free seeds are bound only after this child invocation id exists. */ readonly memoryCueOpportunitySeeds?: readonly MemoryCueOpportunitySeed[]; /** F276 trial: source-only ASR scenes bound to their exact owner trigger message. */ @@ -1396,6 +1407,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP provider: 'other' as const, carrier: 'other' as const, deliverySemantics: 'undeclared' as const, + activeInvocationGuidance: 'undeclared' as const, }; let invocationCapacitySnapshot = params.capacitySnapshot; @@ -2046,6 +2058,17 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP ownsTurnExecution = true; } + const lifecycleAdmission = params.onLifecycleInvocationStarted + ? await params.onLifecycleInvocationStarted({ + threadId, + userId, + catId, + invocationId, + parentInvocationId: executionParentInvocationId, + startedAt: executionStartedAt, + }) + : undefined; + // F22 R2 P1-1 + durable child truth: expose the exact child identity only // after its running record exists. Keeping this yield inside the outer try // guarantees iterator.return() reaches the lifecycle terminalizer. @@ -2054,6 +2077,9 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP catId, turnInvocationId: invocationId, turnExecutionStartedAt: executionStartedAt, + ...(lifecycleAdmission ? { lifecycleResponseMessageId: lifecycleAdmission.responseMessageId } : {}), + ...(lifecycleAdmission ? { activeRun: lifecycleAdmission.activeRun } : {}), + ...(lifecycleAdmission ? { lifecyclePriorFrontierMessageId: lifecycleAdmission.priorFrontierMessageId } : {}), extra: { turnExecution: { invocationId, @@ -2197,22 +2223,6 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP ); } - if (params.a2aTriggerMessageId) { - if (!deps.a2aDispatchDispositionService) { - throw new Error('a2a_dispatch_disposition_service_unavailable'); - } - await deps.a2aDispatchDispositionService.complete( - { - invocationId, - catId, - threadId, - a2aTriggerMessageId: params.a2aTriggerMessageId, - originTriggerMessageId: sourceMessageId, - }, - 'completed', - ); - } - yield { type: 'system_info' as const, catId, @@ -3782,6 +3792,14 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP }), } : {}), + ...(params.onAgentClientActiveRunReady + ? { + activeRunDispatch: { + invocationId, + register: (dispatcher) => params.onAgentClientActiveRunReady!({ catId, dispatcher }), + }, + } + : {}), invocationId, ...(sessionId ? { cliSessionId: sessionId } : {}), ...(isResume && !injectSystemPrompt && params.systemPrompt diff --git a/packages/api/src/domains/cats/services/agents/invocation/lifecycle-append-projection.ts b/packages/api/src/domains/cats/services/agents/invocation/lifecycle-append-projection.ts new file mode 100644 index 0000000000..84e7a04a69 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/lifecycle-append-projection.ts @@ -0,0 +1,120 @@ +import type { + LifecycleActiveRun, + LifecycleAppendAction, + LifecycleAppendCapability, + LifecycleAppendExpectedRun, +} from '@cat-cafe/shared'; +import type { QueueEntry } from './InvocationQueue.js'; +import { isSystemPinnedQueueEntry, queueEntryTargetCats } from './InvocationQueue.js'; +import type { InvocationTrackerLike } from './live-invocation-projection.js'; + +type LifecycleAppendTracker = Pick< + InvocationTrackerLike, + 'getActiveSlots' | 'getUserId' | 'getAgentClientActiveRunDispatcher' +>; + +export type LifecycleAppendProjection = + | { readonly available: true; readonly action: LifecycleAppendAction } + | { + readonly available: false; + readonly reason: + | 'entry_ineligible' + | 'target_missing' + | 'active_run_missing' + | 'client_unsupported' + | 'owner_mismatch'; + }; + +export type LifecycleAppendCapabilityProjection = + | { readonly available: true; readonly capability: LifecycleAppendCapability } + | { + readonly available: false; + readonly reason: 'active_run_missing' | 'client_unsupported' | 'owner_mismatch'; + }; + +/** + * Project the live, request-owned capability for one target. This is the only + * source for pre-admission Append affordances; carrier type and local liveness + * are intentionally insufficient. + */ +export function projectLifecycleAppendCapability(input: { + readonly threadId: string; + readonly userId: string; + readonly targetId: string; + readonly activeRun?: LifecycleActiveRun; + readonly invocationTracker: LifecycleAppendTracker; +}): LifecycleAppendCapabilityProjection { + if (input.invocationTracker.getUserId(input.threadId, input.targetId) !== input.userId) { + return { available: false, reason: 'owner_mismatch' }; + } + const activeRun = input.activeRun; + if (!activeRun || activeRun.threadId !== input.threadId || activeRun.targetId !== input.targetId) { + return { available: false, reason: 'active_run_missing' }; + } + const dispatcher = input.invocationTracker.getAgentClientActiveRunDispatcher?.(input.threadId, input.targetId); + if (!dispatcher || dispatcher.invocationId !== activeRun.invocationId || dispatcher.capabilities.append !== true) { + return { available: false, reason: 'client_unsupported' }; + } + return { + available: true, + capability: { + kind: 'append', + expectedRun: { + targetId: input.targetId, + invocationId: activeRun.invocationId, + responseMessageId: activeRun.responseMessageId, + }, + }, + }; +} + +export function projectLifecycleAppendAction(input: { + readonly threadId: string; + readonly userId: string; + readonly queueRevision: string; + readonly entry: QueueEntry; + readonly invocationTracker: LifecycleAppendTracker; + readonly targetIds?: readonly string[]; +}): LifecycleAppendProjection { + const { entry, invocationTracker } = input; + if (entry.status !== 'queued' || entry.kind === 'private_input' || isSystemPinnedQueueEntry(entry)) { + return { available: false, reason: 'entry_ineligible' }; + } + const pendingTargetCats = queueEntryTargetCats(entry); + const targetCats = input.targetIds ? [...new Set(input.targetIds)] : pendingTargetCats; + if (targetCats.length === 0) return { available: false, reason: 'target_missing' }; + if (targetCats.some((targetId) => !pendingTargetCats.includes(targetId))) { + return { available: false, reason: 'target_missing' }; + } + + const activeRunByTarget = new Map( + invocationTracker + .getActiveSlots(input.threadId) + .flatMap((slot) => (slot.activeRun ? [[slot.catId, slot.activeRun] as const] : [])), + ); + const expectedRuns: LifecycleAppendExpectedRun[] = []; + let unavailableReason: Extract['reason'] = 'active_run_missing'; + for (const targetId of targetCats) { + const capability = projectLifecycleAppendCapability({ + threadId: input.threadId, + userId: input.userId, + targetId, + activeRun: activeRunByTarget.get(targetId), + invocationTracker, + }); + if (!capability.available) { + unavailableReason = capability.reason; + continue; + } + expectedRuns.push(capability.capability.expectedRun); + } + if (expectedRuns.length === 0) return { available: false, reason: unavailableReason }; + return { + available: true, + action: { + kind: 'append', + expectedQueueRevision: input.queueRevision, + expectedRuns, + }, + }; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/live-invocation-projection.ts b/packages/api/src/domains/cats/services/agents/invocation/live-invocation-projection.ts index 4818f18227..3d1002fb89 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/live-invocation-projection.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/live-invocation-projection.ts @@ -11,9 +11,11 @@ * 从 `routes/queue.ts` 原样迁移的既有兼容逻辑,不是本 PR 新增的代偿层。 */ +import type { LifecycleActiveRun } from '@cat-cafe/shared'; import type { IDraftStore } from '../../stores/ports/DraftStore.js'; import type { IInvocationRecordStore } from '../../stores/ports/InvocationRecordStore.js'; import type { ITurnExecutionStore } from '../../stores/ports/TurnExecutionStore.js'; +import type { AgentClientActiveRunDispatcher } from '../../types.js'; import type { CodexAppServerLifecycleSnapshot } from '../providers/CodexAppServerLifecycle.js'; import { getCodexAppServerLifecycle } from '../providers/CodexAppServerLifecycleRegistry.js'; import { getThreadLiveInvocations } from './getThreadLiveInvocations.js'; @@ -23,6 +25,10 @@ export interface InvocationTrackerLike { has(threadId: string, catId?: string): boolean; getUserId(threadId: string, catId: string): string | null; getExecutionId?(threadId: string, catId: string): string | undefined; + /** Canceled tombstones remain observable while provider teardown commits its durable terminal. */ + getSlotState(threadId: string, catId: string): 'active' | 'canceled' | 'absent'; + /** Optional exact identity for a canceled tombstone; absence must fail closed to repair. */ + getCanceledSlotIdentity?(threadId: string, catId: string): { executionId: string; userId: string } | undefined; cancel( threadId: string, catId: string, @@ -30,7 +36,9 @@ export interface InvocationTrackerLike { abortReason?: string, ): { cancelled: boolean; catIds: string[]; executionIds?: string[] }; /** Issue #83: Get all active slots for a thread (F5 refresh recovery) */ - getActiveSlots(threadId: string): Array<{ catId: string; startedAt: number }>; + getActiveSlots(threadId: string): Array<{ catId: string; startedAt: number; activeRun?: LifecycleActiveRun }>; + /** Exact live provider seam; absence means explicit Append is unsupported now. */ + getAgentClientActiveRunDispatcher?(threadId: string, catId: string): AgentClientActiveRunDispatcher | undefined; /** 稀疏候选索引:本进程持有 slot 的 thread。 */ listActiveThreadIds?(): string[]; /** F-invocation-stale-recovery: Cancel ALL active slots for a thread (abort controllers + delete slots). */ @@ -67,6 +75,7 @@ export interface ActiveInvocationProjection { turnInvocationId?: string; appServerLifecycle?: CodexAppServerLifecycleSnapshot; freshnessCarrierCapability?: import('@cat-cafe/shared').FreshnessCarrierCapability; + activeRun?: LifecycleActiveRun; } export interface LifecycleProjectionCandidate { @@ -74,6 +83,7 @@ export interface LifecycleProjectionCandidate { startedAt: number; lifecycleOwnerId?: string; turnInvocationId?: string; + activeRun?: LifecycleActiveRun; } export function getRequestOwnedTrackerExecutionId( @@ -182,6 +192,17 @@ export async function resolveActiveInvocationsStrict( // losing the most diagnostic field. Use `feature` for the F194 marker instead. onLog: (event) => log.info({ ...event, feature: 'F194' }, 'F194 liveness event'), }); + // Read the dynamic Active Run only after the asynchronous canonical liveness + // snapshot. Child admission can bind the run while record/draft/child stores + // are being read; taking this map before the await returns a torn `/queue` + // projection (new child identity + missing run), which then erases the newer + // websocket run during authoritative frontend hydration. + const trackerActiveRunByCatId = new Map( + invocationTracker + .getActiveSlots(threadId) + .filter((slot): slot is typeof slot & { activeRun: LifecycleActiveRun } => Boolean(slot.activeRun)) + .map((slot) => [slot.catId, slot.activeRun]), + ); // Zombie candidates are diagnostic output only. The explicit owner reaper owns // all terminal writes; GET /queue remains observational. // 砚砚 R5 P2: filter null catId — frontend turns queue.activeInvocations[].catId into a @@ -201,11 +222,17 @@ export async function resolveActiveInvocationsStrict( const lifecycleOwnerId = resolveLifecycleOwnerId(threadId, userId, s.catId, s.executionId, invocationTracker); const turnInvocationId = s.invocationId !== s.executionId && lifecycleOwnerId === s.executionId ? s.invocationId : undefined; + const activeRun = trackerActiveRunByCatId.get(s.catId); + const exactActiveRun = + activeRun && (activeRun.invocationId === s.invocationId || activeRun.invocationId === turnInvocationId) + ? activeRun + : undefined; byCatId.set(s.catId, { catId: s.catId, startedAt: s.startedAt, lifecycleOwnerId, ...(turnInvocationId ? { turnInvocationId } : {}), + ...(exactActiveRun ? { activeRun: exactActiveRun } : {}), }); } } diff --git a/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-kernel.ts b/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-kernel.ts new file mode 100644 index 0000000000..2298e69282 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-kernel.ts @@ -0,0 +1,169 @@ +import { isDeepStrictEqual } from 'node:util'; +import type { + LifecycleDispatchRef, + LifecycleQueueSnapshot, + LifecycleResponseBubble, + ReorderVisibleLifecycleEntriesCommand, +} from '@cat-cafe/shared'; +import type { LifecycleTerminalInput } from './message-lifecycle-validation.js'; +import { isLifecycleTerminalInput, validateLifecycleQueueEntry } from './message-lifecycle-validation.js'; + +export type { LifecycleQueueEntryValidation, LifecycleTerminalInput } from './message-lifecycle-validation.js'; +export { validateLifecycleQueueEntry } from './message-lifecycle-validation.js'; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +export type ApplyVisibleQueueOrderResult = + | { readonly outcome: 'applied'; readonly snapshot: LifecycleQueueSnapshot } + | { + readonly outcome: 'conflict'; + readonly reason: + | 'stale_revision' + | 'invalid_revision' + | 'invalid_snapshot' + | 'scope_mismatch' + | 'invalid_order' + | 'visible_set_changed'; + }; + +function sameStringSet(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + const rightSet = new Set(right); + return rightSet.size === right.length && left.every((value) => rightSet.has(value)); +} + +/** Apply a complete visible-row reorder as one immutable revision transition. */ +export function applyVisibleQueueOrder( + snapshot: LifecycleQueueSnapshot, + command: ReorderVisibleLifecycleEntriesCommand, + nextRevision: string, +): ApplyVisibleQueueOrderResult { + if (command.expectedQueueRevision !== snapshot.revision) { + return { outcome: 'conflict', reason: 'stale_revision' }; + } + if (!isNonEmptyString(nextRevision) || nextRevision === snapshot.revision) { + return { outcome: 'conflict', reason: 'invalid_revision' }; + } + if ( + !isNonEmptyString(snapshot.revision) || + !Array.isArray(snapshot.entries) || + snapshot.entries.some((entry) => !validateLifecycleQueueEntry(entry).valid) || + new Set(snapshot.entries.map((entry) => entry.id)).size !== snapshot.entries.length + ) { + return { outcome: 'conflict', reason: 'invalid_snapshot' }; + } + if (snapshot.entries.some((entry) => entry.threadId !== command.threadId)) { + return { outcome: 'conflict', reason: 'scope_mismatch' }; + } + if (new Set(command.orderedVisibleEntryIds).size !== command.orderedVisibleEntryIds.length) { + return { outcome: 'conflict', reason: 'invalid_order' }; + } + if (new Set(snapshot.reorderableVisibleEntryIds).size !== snapshot.reorderableVisibleEntryIds.length) { + return { outcome: 'conflict', reason: 'visible_set_changed' }; + } + if (!sameStringSet(snapshot.reorderableVisibleEntryIds, command.orderedVisibleEntryIds)) { + return { outcome: 'conflict', reason: 'visible_set_changed' }; + } + + const byId = new Map(snapshot.entries.map((entry) => [entry.id, entry])); + if ( + command.orderedVisibleEntryIds.some((entryId) => { + const entry = byId.get(entryId); + return !entry || entry.kind === 'private_input'; + }) + ) { + return { outcome: 'conflict', reason: 'visible_set_changed' }; + } + + const positions = new Map(command.orderedVisibleEntryIds.map((entryId, position) => [entryId, position])); + return { + outcome: 'applied', + snapshot: { + revision: nextRevision, + reorderableVisibleEntryIds: [...command.orderedVisibleEntryIds], + entries: snapshot.entries.map((entry) => { + const position = positions.get(entry.id); + return position === undefined ? { ...entry } : { ...entry, position }; + }), + }, + }; +} + +export type AdvanceDispatchRefResult = + | { readonly outcome: 'applied' | 'replayed'; readonly ref: LifecycleDispatchRef } + | { readonly outcome: 'conflict'; readonly reason: 'target_mismatch' | 'status_mismatch' | 'phase_regression' }; + +function sameDispatchRef(left: LifecycleDispatchRef, right: LifecycleDispatchRef): boolean { + return ( + left.targetId === right.targetId && + left.phase === right.phase && + left.statusMessageId === right.statusMessageId && + left.dispatchedAt === right.dispatchedAt + ); +} + +/** Monotonic derived projection reducer; it never terminalizes a canonical owner. */ +export function advanceDispatchRef( + current: LifecycleDispatchRef, + next: LifecycleDispatchRef, +): AdvanceDispatchRefResult { + if (current.targetId !== next.targetId) return { outcome: 'conflict', reason: 'target_mismatch' }; + if (sameDispatchRef(current, next)) return { outcome: 'replayed', ref: current }; + if (current.phase === next.phase) { + if ( + current.phase === 'dispatched' && + current.statusMessageId === next.statusMessageId && + current.dispatchedAt === undefined && + next.dispatchedAt !== undefined + ) { + return { outcome: 'applied', ref: next }; + } + return { outcome: 'conflict', reason: 'status_mismatch' }; + } + if (current.phase === 'settled') { + return { outcome: 'conflict', reason: 'phase_regression' }; + } + if ( + current.phase === 'dispatched' && + next.phase === 'settled' && + (current.statusMessageId !== next.statusMessageId || + (next.dispatchedAt !== undefined && current.dispatchedAt !== next.dispatchedAt)) + ) { + return { outcome: 'conflict', reason: 'status_mismatch' }; + } + return { outcome: 'applied', ref: next }; +} + +export type ApplyLifecycleTerminalResult = + | { readonly outcome: 'applied' | 'replayed'; readonly bubble: LifecycleResponseBubble } + | { readonly outcome: 'conflict'; readonly reason: 'different_terminal' | 'invalid_terminal' }; + +function sameTerminal(bubble: LifecycleResponseBubble, terminal: LifecycleTerminalInput): boolean { + return ( + bubble.status === terminal.status && + bubble.completedAt === terminal.completedAt && + bubble.reason === terminal.reason && + isDeepStrictEqual(bubble.body, terminal.body) + ); +} + +/** Commit/replay the one durable delivery-result terminal for an admitted bubble. */ +export function applyLifecycleTerminal( + bubble: LifecycleResponseBubble, + terminal: LifecycleTerminalInput, +): ApplyLifecycleTerminalResult { + if (!isLifecycleTerminalInput(terminal) || terminal.completedAt < bubble.startedAt) { + return { outcome: 'conflict', reason: 'invalid_terminal' }; + } + if (bubble.status !== 'processing') { + return sameTerminal(bubble, terminal) + ? { outcome: 'replayed', bubble } + : { outcome: 'conflict', reason: 'different_terminal' }; + } + return { outcome: 'applied', bubble: { ...bubble, ...terminal } }; +} + +export type { LifecycleQueueOrderKey } from './message-lifecycle-queue-order.js'; +export { compareLifecycleQueueEntries } from './message-lifecycle-queue-order.js'; diff --git a/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-queue-order.ts b/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-queue-order.ts new file mode 100644 index 0000000000..6aeed102e4 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-queue-order.ts @@ -0,0 +1,24 @@ +import type { LifecycleQueueEntry } from '@cat-cafe/shared'; + +const PRIORITY_RANK: Readonly> = { urgent: 0, normal: 1 }; + +export interface LifecycleQueueOrderKey { + readonly id: string; + readonly priority: LifecycleQueueEntry['priority']; + readonly enqueuedAt: number; + readonly position?: number; +} + +/** The only lifecycle Queue comparator: position → priority → FIFO → stable id. */ +export function compareLifecycleQueueEntries(a: LifecycleQueueOrderKey, b: LifecycleQueueOrderKey): number { + const aPositioned = a.position !== undefined; + const bPositioned = b.position !== undefined; + if (aPositioned !== bPositioned) return aPositioned ? -1 : 1; + if (a.position !== undefined && b.position !== undefined && a.position !== b.position) { + return a.position - b.position; + } + const priorityDelta = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]; + if (priorityDelta !== 0) return priorityDelta; + if (a.enqueuedAt !== b.enqueuedAt) return a.enqueuedAt - b.enqueuedAt; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-validation.ts b/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-validation.ts new file mode 100644 index 0000000000..a312679ba8 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/message-lifecycle-validation.ts @@ -0,0 +1,145 @@ +import type { LifecycleResponseBubble, MessageContent } from '@cat-cafe/shared'; +import { CatRoutingErrorSchema, MessageContentsSchema } from '@cat-cafe/shared'; +import { z } from 'zod'; + +export type LifecycleQueueEntryValidation = + | { readonly valid: true } + | { readonly valid: false; readonly reason: string }; + +const NonEmptyStringSchema = z.string().min(1); +const UniqueTargetsSchema = z + .array(NonEmptyStringSchema) + .refine((targets) => new Set(targets).size === targets.length, 'targets must be unique'); +const NonEmptyUniqueTargetsSchema = z + .array(NonEmptyStringSchema) + .min(1) + .refine((targets) => new Set(targets).size === targets.length, 'targets must be unique'); + +const LifecycleMessageFromSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('user'), userId: NonEmptyStringSchema }).strict(), + z.object({ kind: z.literal('agent'), catId: NonEmptyStringSchema }).strict(), + z + .object({ + kind: z.literal('external'), + connectorId: NonEmptyStringSchema, + sender: z.object({ id: NonEmptyStringSchema, name: z.string().optional() }).strict().optional(), + address: z + .object({ chatId: NonEmptyStringSchema, messageId: NonEmptyStringSchema.optional() }) + .strict() + .optional(), + }) + .strict(), + z.object({ kind: z.literal('plugin'), instanceId: NonEmptyStringSchema }).strict(), + z.object({ kind: z.literal('system'), service: NonEmptyStringSchema }).strict(), +]); + +const InlinePayloadSchema = z + .object({ + type: z.literal('inline'), + body: MessageContentsSchema, + routingWarnings: z.array(CatRoutingErrorSchema).optional(), + }) + .strict(); + +const MessageRefPayloadSchema = z.object({ type: z.literal('message_ref'), messageId: NonEmptyStringSchema }).strict(); + +const queueEntryBaseShape = { + id: NonEmptyStringSchema, + threadId: NonEmptyStringSchema, + from: LifecycleMessageFromSchema, + ownerAuthProvenance: z.enum(['strict', 'compatibility_fallback', 'unknown']), + priority: z.enum(['urgent', 'normal']), + enqueuedAt: z.number().finite(), +}; + +/** Complete runtime counterpart of the shared LifecycleQueueEntry union. */ +export const LifecycleQueueEntrySchema = z.discriminatedUnion('kind', [ + z + .object({ + ...queueEntryBaseShape, + kind: z.literal('conversation_input'), + sourceRecordId: NonEmptyStringSchema, + payload: InlinePayloadSchema, + targets: UniqueTargetsSchema, + position: z.number().int().nonnegative().optional(), + }) + .strict(), + z + .object({ + ...queueEntryBaseShape, + kind: z.literal('message_wake'), + sourceRecordId: z.undefined().optional(), + payload: MessageRefPayloadSchema, + targets: NonEmptyUniqueTargetsSchema, + position: z.number().int().nonnegative().optional(), + }) + .strict(), + z + .object({ + ...queueEntryBaseShape, + kind: z.literal('private_input'), + sourceRecordId: z.undefined().optional(), + payload: InlinePayloadSchema, + targets: NonEmptyUniqueTargetsSchema, + position: z.undefined().optional(), + }) + .strict(), +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function invalidQueueEntryReason(value: Record, path: PropertyKey | undefined): string { + switch (path) { + case 'id': + case 'threadId': + return 'invalid_identity'; + case 'from': + return 'invalid_from'; + case 'targets': + return 'invalid_targets'; + case 'ownerAuthProvenance': + return 'invalid_owner_auth_provenance'; + case 'priority': + return 'invalid_priority'; + case 'enqueuedAt': + return 'invalid_enqueued_at'; + case 'position': + return 'invalid_position'; + case 'payload': + return isRecord(value.payload) ? `invalid_${String(value.kind)}` : 'invalid_payload'; + default: + return ['conversation_input', 'message_wake', 'private_input'].includes(String(value.kind)) + ? `invalid_${String(value.kind)}` + : 'invalid_kind'; + } +} + +/** Validate the complete discriminated Queue envelope before owner lookup or client effect. */ +export function validateLifecycleQueueEntry(value: unknown): LifecycleQueueEntryValidation { + if (!isRecord(value)) return { valid: false, reason: 'entry_not_object' }; + const result = LifecycleQueueEntrySchema.safeParse(value); + if (result.success) return { valid: true }; + return { valid: false, reason: invalidQueueEntryReason(value, result.error.issues[0]?.path[0]) }; +} + +export interface LifecycleTerminalInput { + readonly status: Exclude; + readonly body: readonly MessageContent[]; + readonly completedAt: number; + readonly reason?: string; +} + +const LifecycleTerminalInputSchema = z + .object({ + status: z.enum(['completed', 'failed', 'canceled', 'interrupted']), + body: MessageContentsSchema, + completedAt: z.number().finite(), + reason: z.string().optional(), + }) + .strict(); + +export function isLifecycleTerminalInput(value: unknown): value is LifecycleTerminalInput { + return LifecycleTerminalInputSchema.safeParse(value).success; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-entry-settlement.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-entry-settlement.ts deleted file mode 100644 index dd5d574d38..0000000000 --- a/packages/api/src/domains/cats/services/agents/invocation/queue-entry-settlement.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type QueueEntrySettlement = 'consume' | 'transfer' | 'rollback' | 'retain'; - -export type QueueEntryTerminalReason = 'succeeded' | 'user_cancel' | 'system_failure' | 'superseded'; - -export type QueueReplacementCustody = - | { readonly kind: 'none' } - | { - readonly kind: 'verified'; - readonly previousEntryId: string; - readonly replacementEntryId: string; - readonly sourceMessageId: string; - }; - -export type QueueEntryActionFence = - | { readonly kind: 'none' } - | { readonly kind: 'action_successor'; readonly leaseId: string; readonly generation: number }; - -/** Existing durable lifecycle that has already accepted terminal responsibility for this Queue carrier. */ -export type QueueEntryDurableTerminalOwner = - | { readonly kind: 'none' } - | { readonly kind: 'freshness_supplement'; readonly supplementId: string }; - -export interface QueueEntrySettlementInput { - readonly terminalReason: QueueEntryTerminalReason; - readonly replacement: QueueReplacementCustody; - readonly actionFence: QueueEntryActionFence; - readonly durableTerminalOwner?: QueueEntryDurableTerminalOwner; - /** Legacy rows without durable custody cannot be rolled back truthfully after their message was published. */ - readonly custody?: 'durable' | 'legacy_unbound' | 'absent'; -} - -/** - * Gate 2 canonical Queue-attempt decision. - * - * This function chooses responsibility only. F264 exact target evidence still - * performs the successful-message receipt commit; it is not a second Queue - * lifecycle owner. - */ -export function resolveQueueEntrySettlement(input: QueueEntrySettlementInput): QueueEntrySettlement { - if (input.terminalReason === 'user_cancel' || input.terminalReason === 'succeeded') return 'consume'; - - if (input.terminalReason === 'superseded') { - return input.replacement.kind === 'verified' ? 'transfer' : 'retain'; - } - - if (input.actionFence.kind === 'action_successor' || input.durableTerminalOwner?.kind === 'freshness_supplement') { - return 'consume'; - } - return input.custody === 'legacy_unbound' ? 'consume' : 'rollback'; -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerCommit.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerCommit.ts new file mode 100644 index 0000000000..9631d00ee7 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerCommit.ts @@ -0,0 +1,140 @@ +import { + assertQueueLedgerEntry, + cloneQueueLedgerEntry, + type QueueLedgerCommitMode, + type QueueLedgerEntry, + type QueueLedgerTransitionResult, +} from './QueueLedger.js'; + +export function commitInMemoryQueueLedgerEntry(input: { + rows: Map; + unindexEntries(threadId: string, entries: readonly QueueLedgerEntry[]): void; + threadId: string; + entryId: string; + claimId: string; + mode: QueueLedgerCommitMode; + at: number; + replacement?: QueueLedgerEntry; + retiredTargetIds?: readonly string[]; +}): QueueLedgerTransitionResult { + const current = input.rows.get(input.threadId); + const index = current?.findIndex((entry) => entry.id === input.entryId) ?? -1; + if (!current || index < 0) return { outcome: 'not_found' }; + const entry = current[index]; + if (!entry) return { outcome: 'not_found' }; + if (input.mode === 'processing') return commitClaimedTarget(input, current, index, entry); + if (input.mode === 'queued') return commitClaimedState(input, current, index, entry); + return commitWithdrawn(input, current, index, entry); +} + +function replacementFor(entry: QueueLedgerEntry, replacement: QueueLedgerEntry | undefined): QueueLedgerEntry { + const next = replacement ? cloneQueueLedgerEntry(replacement) : cloneQueueLedgerEntry(entry); + if (next.id !== entry.id || next.threadId !== entry.threadId) { + throw new Error('Queue commit identity mismatch'); + } + return next; +} + +function commitClaimedState( + input: Parameters[0], + current: QueueLedgerEntry[], + index: number, + entry: QueueLedgerEntry, +): QueueLedgerTransitionResult { + if (entry.status !== 'claimed' || entry.claimId !== input.claimId) return { outcome: 'state_changed' }; + const next = replacementFor(entry, input.replacement); + next.status = 'queued'; + delete next.claimId; + delete next.claimedAt; + delete next.claimedTargetIds; + delete next.claimedFromTargetless; + delete next.processingStartedAt; + assertQueueLedgerEntry(next); + current[index] = next; + return { outcome: 'updated', entry: cloneQueueLedgerEntry(next) }; +} + +function commitClaimedTarget( + input: Parameters[0], + current: QueueLedgerEntry[], + index: number, + entry: QueueLedgerEntry, +): QueueLedgerTransitionResult { + if (entry.status !== 'claimed' || entry.claimId !== input.claimId || !entry.claimedTargetIds?.length) { + return { outcome: 'state_changed' }; + } + const claimedTargetSet = new Set(entry.claimedTargetIds); + const claimedTargets = input.retiredTargetIds ? [...new Set(input.retiredTargetIds)] : [...entry.claimedTargetIds]; + if ( + claimedTargets.length === 0 || + claimedTargets.length !== (input.retiredTargetIds?.length ?? claimedTargets.length) || + claimedTargets.some((targetId) => !claimedTargetSet.has(targetId)) + ) { + return { outcome: 'state_changed' }; + } + const source = replacementFor(entry, input.replacement); + const attempted = cloneQueueLedgerEntry(source); + attempted.targets = claimedTargets; + attempted.status = 'processing'; + attempted.processingStartedAt = input.at; + delete attempted.claimId; + delete attempted.claimedAt; + delete attempted.claimedTargetIds; + delete attempted.claimedFromTargetless; + delete attempted.terminalAt; + + const claimedSet = new Set(claimedTargets); + const remainingTargets = entry.targets.filter((targetId) => !claimedSet.has(targetId)); + const remainingClaimedTargets = entry.claimedTargetIds.filter((targetId) => !claimedSet.has(targetId)); + if (remainingTargets.length === 0) { + current.splice(index, 1); + input.unindexEntries(input.threadId, [entry]); + if (current.length === 0) input.rows.delete(input.threadId); + } else { + const remaining = cloneQueueLedgerEntry(entry); + remaining.targets = remainingTargets; + if (remainingClaimedTargets.length > 0) { + remaining.status = 'claimed'; + remaining.claimedTargetIds = remainingClaimedTargets; + } else { + remaining.status = 'queued'; + delete remaining.claimId; + delete remaining.claimedAt; + delete remaining.claimedTargetIds; + delete remaining.claimedFromTargetless; + } + delete remaining.processingStartedAt; + delete remaining.terminalAt; + delete remaining.delivery.steerRequestedAt; + if (remaining.delivery.authorIntentByTarget) { + remaining.delivery.authorIntentByTarget = Object.fromEntries( + Object.entries(remaining.delivery.authorIntentByTarget).filter(([targetId]) => + remainingTargets.includes(targetId), + ), + ); + } + assertQueueLedgerEntry(remaining); + current[index] = remaining; + } + return { outcome: 'updated', entry: attempted }; +} + +function commitWithdrawn( + input: Parameters[0], + current: QueueLedgerEntry[], + index: number, + entry: QueueLedgerEntry, +): QueueLedgerTransitionResult { + if (entry.status !== 'claimed' || entry.claimId !== input.claimId) return { outcome: 'state_changed' }; + const terminal = replacementFor(entry, input.replacement); + terminal.status = 'terminal'; + terminal.terminalAt = input.at; + delete terminal.claimId; + delete terminal.claimedAt; + delete terminal.claimedTargetIds; + delete terminal.claimedFromTargetless; + current.splice(index, 1); + input.unindexEntries(input.threadId, [entry]); + if (current.length === 0) input.rows.delete(input.threadId); + return { outcome: 'updated', entry: cloneQueueLedgerEntry(terminal) }; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.ts new file mode 100644 index 0000000000..9ec5576365 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.ts @@ -0,0 +1,329 @@ +import { commitInMemoryQueueLedgerEntry } from './InMemoryQueueLedgerCommit.js'; +import { + assertQueueLedgerEntry, + cloneQueueLedgerEntry, + type QueueLedgerClaimResult, + type QueueLedgerCommitMode, + type QueueLedgerEnqueueResult, + type QueueLedgerEntry, + type QueueLedgerStore, + type QueueLedgerTargetExpansionResult, + type QueueLedgerTargetReconcileResult, + type QueueLedgerTransitionResult, + queueLedgerAdmissionsMatch, +} from './QueueLedger.js'; + +export class InMemoryQueueLedgerStore implements QueueLedgerStore { + private readonly rows = new Map(); + private readonly messageRows = new Map>>(); + + private indexEntries(threadId: string, entries: readonly QueueLedgerEntry[]): void { + const threadIndex = this.messageRows.get(threadId) ?? new Map>(); + for (const entry of entries) { + const messageId = entry.payload.messageId; + if (!messageId) continue; + const entryIds = threadIndex.get(messageId) ?? new Set(); + entryIds.add(entry.id); + threadIndex.set(messageId, entryIds); + } + if (threadIndex.size > 0) this.messageRows.set(threadId, threadIndex); + } + + private unindexEntries(threadId: string, entries: readonly QueueLedgerEntry[]): void { + const threadIndex = this.messageRows.get(threadId); + if (!threadIndex) return; + for (const entry of entries) { + const messageId = entry.payload.messageId; + if (!messageId) continue; + const entryIds = threadIndex.get(messageId); + entryIds?.delete(entry.id); + if (entryIds?.size === 0) threadIndex.delete(messageId); + } + if (threadIndex.size === 0) this.messageRows.delete(threadId); + } + + enqueueNow(entries: readonly QueueLedgerEntry[], maxQueuedUserEntries?: number): QueueLedgerEnqueueResult { + if (entries.length === 0) throw new Error('queue ledger enqueue requires at least one row'); + for (const entry of entries) assertQueueLedgerEntry(entry); + const threadId = entries[0]?.threadId; + if (entries.some((entry) => entry.threadId !== threadId)) + throw new Error('queue ledger enqueue must be one thread'); + if (new Set(entries.map((entry) => entry.id)).size !== entries.length) { + throw new Error('queue ledger enqueue ids must be unique'); + } + const current = this.rows.get(threadId) ?? []; + const existing = entries.map((entry) => current.find((candidate) => candidate.id === entry.id)); + const existingEntries = existing.filter((entry): entry is QueueLedgerEntry => entry !== undefined); + if (existingEntries.length === entries.length) { + return existingEntries.every((entry, index) => { + const input = entries[index]; + return input !== undefined && queueLedgerAdmissionsMatch(entry, input); + }) + ? { outcome: 'replayed', entries: existingEntries.map(cloneQueueLedgerEntry) } + : { outcome: 'conflict', entries: [] }; + } + if (existing.some(Boolean)) return { outcome: 'conflict', entries: [] }; + if (maxQueuedUserEntries !== undefined) { + const queuedUserSources = new Set( + current + .filter((entry) => entry.from.kind === 'user' && entry.status === 'queued') + .map((entry) => entry.payload.sourceRecordId), + ); + const incomingUserSources = new Set( + entries.filter((entry) => entry.from.kind === 'user').map((entry) => entry.payload.sourceRecordId), + ); + if (new Set([...queuedUserSources, ...incomingUserSources]).size > maxQueuedUserEntries) { + return { outcome: 'full', entries: [] }; + } + } + const inserted = entries.map(cloneQueueLedgerEntry); + current.push(...inserted); + this.rows.set(threadId, current); + this.indexEntries(threadId, inserted); + return { outcome: 'enqueued', entries: inserted.map(cloneQueueLedgerEntry) }; + } + + async enqueue( + entries: readonly QueueLedgerEntry[], + maxQueuedUserEntries?: number, + ): Promise { + return this.enqueueNow(entries, maxQueuedUserEntries); + } + + async expandTargets( + threadId: string, + entryId: string, + bindTargetCatId: string, + expectedQueuedEntryIds: readonly string[], + siblingEntries: readonly QueueLedgerEntry[], + ): Promise { + if (!bindTargetCatId) throw new Error('queue target expansion requires a target'); + for (const entry of siblingEntries) assertQueueLedgerEntry(entry); + const current = this.rows.get(threadId); + if (!current) return { outcome: 'not_found', entries: [] }; + const anchor = current.find((entry) => entry.id === entryId); + if (!anchor) return { outcome: 'not_found', entries: [] }; + if (anchor.status !== 'queued') { + return { outcome: 'state_changed', entries: [] }; + } + if ( + expectedQueuedEntryIds.some((expectedId) => expectedId !== entryId) || + siblingEntries.some( + (entry) => + entry.id !== entryId || + entry.threadId !== threadId || + entry.payload.sourceRecordId !== anchor.payload.sourceRecordId || + entry.status !== 'queued', + ) + ) { + throw new Error('invalid queue target expansion rows'); + } + const requestedTargets = [bindTargetCatId, ...siblingEntries.flatMap((entry) => entry.targets)]; + const nextTargets = [...new Set([...anchor.targets, ...requestedTargets])]; + if (nextTargets.length === anchor.targets.length) { + return { outcome: 'replayed', entries: [cloneQueueLedgerEntry(anchor)] }; + } + anchor.targets = nextTargets; + return { + outcome: 'expanded', + entries: [cloneQueueLedgerEntry(anchor)], + }; + } + + async reconcileTargets( + threadId: string, + entryId: string, + addTargetIds: readonly string[], + removeTargetIds: readonly string[], + authorIntentByTarget: Readonly> = {}, + ): Promise { + const additions = [...new Set(addTargetIds)]; + const removals = new Set(removeTargetIds); + if ( + additions.some((targetId) => !targetId || removals.has(targetId)) || + removals.has('') || + additions.length !== addTargetIds.length || + removals.size !== removeTargetIds.length + ) { + throw new Error('invalid Queue target reconciliation'); + } + const current = this.rows.get(threadId); + const index = current?.findIndex((entry) => entry.id === entryId) ?? -1; + if (!current || index < 0) return { outcome: 'not_found' }; + const row = current[index]!; + if (row.status !== 'queued') return { outcome: 'state_changed' }; + + const nextTargets = row.targets.filter((targetId) => !removals.has(targetId)); + const present = new Set(nextTargets); + for (const targetId of additions) { + if (!present.has(targetId)) { + nextTargets.push(targetId); + present.add(targetId); + } + } + const nextIntent = Object.fromEntries( + nextTargets.flatMap((targetId) => { + const intent = authorIntentByTarget?.[targetId] ?? row.delivery.authorIntentByTarget?.[targetId]; + return intent ? [[targetId, structuredClone(intent)] as const] : []; + }), + ); + const unchanged = + nextTargets.length === row.targets.length && + nextTargets.every((targetId, index) => row.targets[index] === targetId) && + JSON.stringify(nextIntent) === JSON.stringify(row.delivery.authorIntentByTarget ?? {}); + if (unchanged) return { outcome: 'replayed', entry: cloneQueueLedgerEntry(row) }; + + if (nextTargets.length === 0) { + current.splice(index, 1); + this.unindexEntries(threadId, [row]); + if (current.length === 0) this.rows.delete(threadId); + return { outcome: 'updated', entry: null }; + } + row.targets = nextTargets; + row.delivery.authorIntentByTarget = nextIntent; + assertQueueLedgerEntry(row); + return { outcome: 'updated', entry: cloneQueueLedgerEntry(row) }; + } + + /** Roll back only rows created by the same synchronous memory admission. */ + removeEnqueuedNow(entries: readonly QueueLedgerEntry[]): void { + if (entries.length === 0) return; + const threadId = entries[0]?.threadId; + const current = threadId ? this.rows.get(threadId) : undefined; + if (!current) return; + const ids = new Set(entries.map((entry) => entry.id)); + const remaining = current.filter((entry) => !ids.has(entry.id)); + const removed = current.filter((entry) => ids.has(entry.id)); + if (remaining.length === 0) this.rows.delete(threadId); + else this.rows.set(threadId, remaining); + this.unindexEntries(threadId, removed); + } + + async list(threadId: string): Promise { + return (this.rows.get(threadId) ?? []).map(cloneQueueLedgerEntry); + } + + async listAll(threadId: string): Promise { + return this.list(threadId); + } + + async getByMessageIds(threadId: string, messageIds: readonly string[]): Promise> { + const grouped = new Map(); + const threadIndex = this.messageRows.get(threadId); + if (!threadIndex) return grouped; + for (const messageId of new Set(messageIds)) { + const entryIds = threadIndex.get(messageId); + if (!entryIds) continue; + const entries: QueueLedgerEntry[] = []; + for (const entryId of entryIds) { + const entry = this.getNow(threadId, entryId); + if (!entry) throw new Error(`queue message index references missing row: ${entryId}`); + if (entry.payload.messageId !== messageId) { + throw new Error(`queue message index identity mismatch: ${messageId}:${entryId}`); + } + entries.push(entry); + } + grouped.set(messageId, entries); + } + return grouped; + } + + async listThreadIds(): Promise { + return [...this.rows.keys()].sort(); + } + + async get(threadId: string, entryId: string): Promise { + return this.getNow(threadId, entryId); + } + + getNow(threadId: string, entryId: string): QueueLedgerEntry | null { + const entry = this.rows.get(threadId)?.find((candidate) => candidate.id === entryId); + return entry ? cloneQueueLedgerEntry(entry) : null; + } + + async claim( + threadId: string, + entryId: string, + claimId: string, + claimedAt: number, + bindTargetCatId?: string, + steerRequestedAt?: number, + ): Promise { + return this.claimPrefix(threadId, [entryId], claimId, claimedAt, bindTargetCatId, steerRequestedAt); + } + + async claimPrefix( + threadId: string, + entryIds: readonly string[], + claimId: string, + claimedAt: number, + bindTargetCatId?: string, + steerRequestedAt?: number, + ): Promise { + if (entryIds.length === 0 || !claimId || !Number.isFinite(claimedAt)) throw new Error('invalid queue claim'); + const current = this.rows.get(threadId); + if (!current) return { outcome: 'not_found' }; + const selected = entryIds.map((id) => current.find((entry) => entry.id === id)); + const selectedEntries = selected.filter((entry): entry is QueueLedgerEntry => entry !== undefined); + if (selectedEntries.length !== selected.length) return { outcome: 'not_found' }; + if (selectedEntries.some((entry) => entry.status !== 'queued')) return { outcome: 'state_changed' }; + if ( + bindTargetCatId && + selectedEntries.some((entry) => entry.targets.length > 0 && !entry.targets.includes(bindTargetCatId)) + ) { + return { outcome: 'state_changed' }; + } + for (const entry of selectedEntries) { + entry.status = 'claimed'; + entry.claimId = claimId; + entry.claimedAt = claimedAt; + const wasTargetless = entry.targets.length === 0; + if (bindTargetCatId && wasTargetless) entry.targets = [bindTargetCatId]; + entry.claimedTargetIds = bindTargetCatId ? [bindTargetCatId] : [...entry.targets]; + if (bindTargetCatId && wasTargetless) entry.claimedFromTargetless = true; + if (steerRequestedAt !== undefined) entry.delivery.steerRequestedAt = steerRequestedAt; + } + return { outcome: 'claimed', claimId, entries: selectedEntries.map(cloneQueueLedgerEntry) }; + } + + async commit( + threadId: string, + entryId: string, + claimId: string, + mode: QueueLedgerCommitMode, + at: number, + replacement?: QueueLedgerEntry, + retiredTargetIds?: readonly string[], + ): Promise { + return commitInMemoryQueueLedgerEntry({ + rows: this.rows, + unindexEntries: (currentThreadId, entries) => this.unindexEntries(currentThreadId, entries), + threadId, + entryId, + claimId, + mode, + at, + ...(replacement ? { replacement } : {}), + ...(retiredTargetIds ? { retiredTargetIds } : {}), + }); + } + + async restore( + threadId: string, + entryId: string, + claimId: string, + restoreUnassignedTarget = false, + ): Promise { + const entry = this.rows.get(threadId)?.find((candidate) => candidate.id === entryId); + if (!entry) return { outcome: 'not_found' }; + if (entry.status !== 'claimed' || entry.claimId !== claimId) return { outcome: 'state_changed' }; + entry.status = 'queued'; + delete entry.claimId; + delete entry.claimedAt; + delete entry.delivery.steerRequestedAt; + if (restoreUnassignedTarget || entry.claimedFromTargetless) entry.targets = []; + delete entry.claimedTargetIds; + delete entry.claimedFromTargetless; + return { outcome: 'updated', entry: cloneQueueLedgerEntry(entry) }; + } +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts new file mode 100644 index 0000000000..5ea87d2bdf --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts @@ -0,0 +1,224 @@ +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { CatRoutingError, MessageFrom, QueueAuthorIntent, WaitContinuationCarrierV1 } from '@cat-cafe/shared'; +import type { CallerTraceContext } from '../../../../../../infrastructure/telemetry/genai-semconv.js'; +import type { ActionSuccessorFence } from '../../../../../ball-custody/ActionSuccessorAdmissionContract.js'; +import type { CloudDispatchProvenance } from '../../../cloud-bridge/types.js'; +import type { ToolExecutionPolicy } from '../../../types.js'; +import type { OwnerAuthProvenance } from '../owner-auth-provenance.js'; + +export type QueueOwner = { kind: 'user'; userId: string } | { kind: 'system'; service: string }; + +/** `processing`/`terminal` are transient transition snapshots; durable rows are only queued or short-claimed. */ +export type QueueLedgerStatus = 'queued' | 'claimed' | 'processing' | 'terminal'; + +export interface QueuePrestartRetirementIntent { + id: string; + primaryEntryId: string; + entryIds: string[]; + targetCatId: string; + startedAt: number; +} + +export interface QueueLedgerPayload { + /** Stable identity of the one source record represented by this Queue Entry. */ + sourceRecordId: string; + content: string; + messageId?: string; + routingWarnings?: readonly CatRoutingError[]; +} + +export interface QueueLedgerExecution { + intent: string; + ownerAuthProvenance: OwnerAuthProvenance; + autoExecute: boolean; + a2aParentInvocationId?: string; + freshnessClosureId?: string; + freshnessSupplementId?: string; + freshnessSupplementLineageId?: string; + freshnessSupplementSeq?: 1 | 2; + readOnlyToolPolicy?: ToolExecutionPolicy; + actionSuccessorFence?: ActionSuccessorFence; + waitContinuationCarrier?: WaitContinuationCarrierV1; + suggestedSkill?: string; + callerTraceContext?: CallerTraceContext; + a2aTriggerMessageId?: string; + cloudDispatchProvenance?: CloudDispatchProvenance; + requiresExactCloudDispatchProvenance?: boolean; +} + +export interface QueueLedgerDelivery { + authorIntentByTarget?: Record; + steerRequestedAt?: number; +} + +/** + * RFC #1356 canonical Queue record. One source has one durable Queue Entry; + * targets are the exact members whose delivery is still pending. History owns + * actual delivery receipts, so a target must leave this set when it is adopted. + */ +export interface QueueLedgerEntry { + version: 2; + id: string; + threadId: string; + owner: QueueOwner; + kind: 'conversation_input' | 'message_wake' | 'private_input'; + from: MessageFrom; + targets: string[]; + payload: QueueLedgerPayload; + execution: QueueLedgerExecution; + delivery: QueueLedgerDelivery; + status: QueueLedgerStatus; + enqueuedAt: number; + claimedAt?: number; + claimId?: string; + /** Exact pending targets protected by the short reversible claim. */ + claimedTargetIds?: string[]; + /** The claimed target was resolved from a previously targetless entry. */ + claimedFromTargetless?: boolean; + processingStartedAt?: number; + terminalAt?: number; + retiringGroupId?: string; + priority: 'urgent' | 'normal'; + sourceCategory?: + | 'ci' + | 'review' + | 'conflict' + | 'scheduled' + | 'a2a' + | 'a2a_failure' + | 'continuation' + | 'issue' + | 'freshness'; + position?: number; +} + +export interface QueueLedgerEnqueueResult { + outcome: 'enqueued' | 'replayed' | 'full' | 'conflict'; + entries: QueueLedgerEntry[]; +} + +export type QueueLedgerTargetExpansionResult = + | { outcome: 'expanded' | 'replayed'; entries: QueueLedgerEntry[] } + | { outcome: 'not_found' | 'state_changed' | 'conflict'; entries: [] }; + +export type QueueLedgerTargetReconcileResult = + | { outcome: 'updated' | 'replayed'; entry: QueueLedgerEntry | null } + | { outcome: 'not_found' | 'state_changed' }; + +export type QueueLedgerClaimResult = + | { outcome: 'claimed'; entries: QueueLedgerEntry[]; claimId: string } + | { outcome: 'not_found' | 'state_changed' }; + +export type QueueLedgerTransitionResult = + | { outcome: 'updated'; entry: QueueLedgerEntry } + | { outcome: 'not_found' | 'state_changed' }; + +export type QueueLedgerCommitMode = 'queued' | 'processing' | 'withdrawn'; + +export interface QueueLedgerStore { + enqueue(entries: readonly QueueLedgerEntry[], maxQueuedUserEntries?: number): Promise; + /** Atomically bind an optional targetless entry and merge additional targets into that same source entry. */ + expandTargets( + threadId: string, + entryId: string, + bindTargetCatId: string, + expectedQueuedEntryIds: readonly string[], + siblingEntries: readonly QueueLedgerEntry[], + ): Promise; + /** Atomically apply an explicit Steer target delta to one pending source record. */ + reconcileTargets( + threadId: string, + entryId: string, + addTargetIds: readonly string[], + removeTargetIds: readonly string[], + authorIntentByTarget: Readonly>, + ): Promise; + listThreadIds(): Promise; + list(threadId: string): Promise; + /** Active Queue rows only. History owns delivery and terminal truth. */ + listAll(threadId: string): Promise; + /** Active Queue rows for exact Message identities, without scanning thread history. */ + getByMessageIds(threadId: string, messageIds: readonly string[]): Promise>; + get(threadId: string, entryId: string): Promise; + claim( + threadId: string, + entryId: string, + claimId: string, + claimedAt: number, + bindTargetCatId?: string, + steerRequestedAt?: number, + ): Promise; + claimPrefix( + threadId: string, + entryIds: readonly string[], + claimId: string, + claimedAt: number, + bindTargetCatId?: string, + steerRequestedAt?: number, + ): Promise; + commit( + threadId: string, + entryId: string, + claimId: string, + mode: QueueLedgerCommitMode, + at: number, + replacement?: QueueLedgerEntry, + retiredTargetIds?: readonly string[], + ): Promise; + restore( + threadId: string, + entryId: string, + claimId: string, + restoreUnassignedTarget?: boolean, + ): Promise; +} + +export function queueOwnerKey(owner: QueueOwner): string { + return owner.kind === 'user' ? `user:${owner.userId}` : `system:${owner.service}`; +} + +export function queueOwner(entry: { owner?: QueueOwner; userId?: string }): QueueOwner { + if (entry.owner) return structuredClone(entry.owner); + if (!entry.userId) throw new Error('queue owner is missing'); + if (entry.userId === 'system' || entry.userId === 'scheduler') { + return { kind: 'system', service: entry.userId }; + } + return { kind: 'user', userId: entry.userId }; +} + +export function queueEntryId(sourcePersistentId: string): string { + if (!sourcePersistentId) throw new Error('queue entry identity requires a persistent source'); + const digest = createHash('sha256').update(sourcePersistentId).digest('hex').slice(0, 32); + return `queue:${digest}`; +} + +export function cloneQueueLedgerEntry(entry: QueueLedgerEntry): QueueLedgerEntry { + return structuredClone(entry); +} + +/** + * Compare only the immutable producer admission contract. Lifecycle progress, + * queue ordering, and distributed trace context may legitimately differ when + * the same durable source is replayed. + */ +export function queueLedgerAdmissionsMatch(existing: QueueLedgerEntry, incoming: QueueLedgerEntry): boolean { + const { callerTraceContext: _existingTrace, ...existingExecution } = existing.execution; + const { callerTraceContext: _incomingTrace, ...incomingExecution } = incoming.execution; + return ( + existing.version === incoming.version && + existing.id === incoming.id && + existing.threadId === incoming.threadId && + isDeepStrictEqual(existing.owner, incoming.owner) && + existing.kind === incoming.kind && + isDeepStrictEqual(existing.from, incoming.from) && + isDeepStrictEqual(existing.targets, incoming.targets) && + isDeepStrictEqual(existing.payload, incoming.payload) && + isDeepStrictEqual(existingExecution, incomingExecution) && + isDeepStrictEqual(existing.delivery.authorIntentByTarget, incoming.delivery.authorIntentByTarget) && + existing.priority === incoming.priority && + existing.sourceCategory === incoming.sourceCategory + ); +} + +export { assertQueueLedgerEntry } from './QueueLedgerValidation.js'; diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts new file mode 100644 index 0000000000..4e63733a35 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts @@ -0,0 +1,99 @@ +import type { CatRoutingError, MessageFrom, QueueAuthorIntent, WaitContinuationCarrierV1 } from '@cat-cafe/shared'; +import type { CallerTraceContext } from '../../../../../../infrastructure/telemetry/genai-semconv.js'; +import type { ActionSuccessorFence } from '../../../../../ball-custody/ActionSuccessorAdmissionContract.js'; +import type { CloudDispatchProvenance } from '../../../cloud-bridge/types.js'; +import type { ToolExecutionPolicy } from '../../../types.js'; +import type { OwnerAuthProvenance } from '../owner-auth-provenance.js'; +import { type QueueLedgerEntry, type QueueOwner, queueEntryId } from './QueueLedger.js'; + +export interface QueueLedgerAdmissionInput { + sourceId: string; + threadId: string; + owner: QueueOwner; + kind: QueueLedgerEntry['kind']; + from: MessageFrom; + targetCatIds: readonly string[]; + content: string; + messageId?: string; + routingWarnings?: readonly CatRoutingError[]; + authorIntentByCatId?: Readonly>; + intent: string; + ownerAuthProvenance: OwnerAuthProvenance; + autoExecute?: boolean; + priority?: QueueLedgerEntry['priority']; + sourceCategory?: QueueLedgerEntry['sourceCategory']; + a2aParentInvocationId?: string; + freshnessClosureId?: string; + freshnessSupplementId?: string; + freshnessSupplementLineageId?: string; + freshnessSupplementSeq?: 1 | 2; + readOnlyToolPolicy?: ToolExecutionPolicy; + actionSuccessorFence?: ActionSuccessorFence; + waitContinuationCarrier?: WaitContinuationCarrierV1; + suggestedSkill?: string; + callerTraceContext?: CallerTraceContext; + a2aTriggerMessageId?: string; + cloudDispatchProvenance?: CloudDispatchProvenance; + requiresExactCloudDispatchProvenance?: boolean; + enqueuedAt: number; +} + +export function createQueueLedgerAdmission(input: QueueLedgerAdmissionInput): QueueLedgerEntry[] { + if (!input.sourceId) throw new Error('Queue admission requires a persistent source id'); + if (input.targetCatIds.length === 0 && input.kind !== 'conversation_input') { + throw new Error(`${input.kind} Queue admission requires an exact target`); + } + if (new Set(input.targetCatIds).size !== input.targetCatIds.length) { + throw new Error('Queue admission targets must be unique'); + } + return [ + { + version: 2, + id: queueEntryId(input.sourceId), + threadId: input.threadId, + owner: structuredClone(input.owner), + kind: input.kind, + from: structuredClone(input.from), + targets: [...input.targetCatIds], + payload: { + sourceRecordId: input.sourceId, + content: input.content, + ...(input.messageId ? { messageId: input.messageId } : {}), + ...(input.routingWarnings?.length ? { routingWarnings: structuredClone(input.routingWarnings) } : {}), + }, + execution: { + intent: input.intent, + ownerAuthProvenance: input.ownerAuthProvenance, + autoExecute: input.autoExecute ?? false, + ...(input.a2aParentInvocationId ? { a2aParentInvocationId: input.a2aParentInvocationId } : {}), + ...(input.freshnessClosureId ? { freshnessClosureId: input.freshnessClosureId } : {}), + ...(input.freshnessSupplementId ? { freshnessSupplementId: input.freshnessSupplementId } : {}), + ...(input.freshnessSupplementLineageId + ? { freshnessSupplementLineageId: input.freshnessSupplementLineageId } + : {}), + ...(input.freshnessSupplementSeq ? { freshnessSupplementSeq: input.freshnessSupplementSeq } : {}), + ...(input.readOnlyToolPolicy ? { readOnlyToolPolicy: structuredClone(input.readOnlyToolPolicy) } : {}), + ...(input.actionSuccessorFence ? { actionSuccessorFence: structuredClone(input.actionSuccessorFence) } : {}), + ...(input.waitContinuationCarrier + ? { waitContinuationCarrier: structuredClone(input.waitContinuationCarrier) } + : {}), + ...(input.suggestedSkill ? { suggestedSkill: input.suggestedSkill } : {}), + ...(input.callerTraceContext ? { callerTraceContext: structuredClone(input.callerTraceContext) } : {}), + ...(input.a2aTriggerMessageId ? { a2aTriggerMessageId: input.a2aTriggerMessageId } : {}), + ...(input.cloudDispatchProvenance + ? { cloudDispatchProvenance: structuredClone(input.cloudDispatchProvenance) } + : {}), + ...(input.requiresExactCloudDispatchProvenance ? { requiresExactCloudDispatchProvenance: true } : {}), + }, + delivery: { + ...(input.authorIntentByCatId && Object.keys(input.authorIntentByCatId).length > 0 + ? { authorIntentByTarget: structuredClone(input.authorIntentByCatId) } + : {}), + }, + status: 'queued', + enqueuedAt: input.enqueuedAt, + priority: input.priority ?? 'normal', + ...(input.sourceCategory ? { sourceCategory: input.sourceCategory } : {}), + }, + ]; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts new file mode 100644 index 0000000000..5ba419764a --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts @@ -0,0 +1,145 @@ +import { isMessageFrom } from '@cat-cafe/shared'; +import type { QueueLedgerEntry, QueueLedgerExecution, QueueLedgerPayload, QueueOwner } from './QueueLedger.js'; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isFiniteTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function assertOptionalTimestamp(value: unknown, field: string): void { + if (value !== undefined && !isFiniteTimestamp(value)) throw new Error(`queue ledger ${field} is invalid`); +} + +function assertQueueLedgerState(entry: QueueLedgerEntry): void { + if ( + entry.status === 'queued' && + (entry.claimId !== undefined || + entry.claimedAt !== undefined || + entry.claimedTargetIds !== undefined || + entry.claimedFromTargetless !== undefined) + ) { + throw new Error('queued ledger entry cannot carry a claim'); + } + if ( + entry.status === 'claimed' && + (!entry.claimId || + entry.claimedAt === undefined || + !Array.isArray(entry.claimedTargetIds) || + entry.claimedTargetIds.some((targetId) => !entry.targets.includes(targetId))) + ) { + throw new Error('claimed ledger entry requires claim identity and timestamp'); + } + if (entry.status === 'processing' && entry.processingStartedAt === undefined) { + throw new Error('processing ledger entry requires processingStartedAt'); + } + if (entry.status === 'terminal' && entry.terminalAt === undefined) { + throw new Error('terminal ledger entry requires terminalAt'); + } + if (entry.status === 'terminal' && (entry.claimId !== undefined || entry.claimedAt !== undefined)) { + throw new Error('terminal ledger entry cannot carry a claim'); + } +} + +function assertQueueOwner(value: unknown): asserts value is QueueOwner { + if (!isRecord(value)) throw new Error('queue ledger owner is invalid'); + const owner = value as Partial; + if (owner.kind === 'user' && typeof owner.userId === 'string' && owner.userId) return; + if (owner.kind === 'system' && typeof owner.service === 'string' && owner.service) return; + throw new Error('queue ledger owner is incomplete'); +} + +function assertQueueTargets(value: unknown): asserts value is string[] { + if ( + !Array.isArray(value) || + !value.every((targetId) => typeof targetId === 'string' && targetId.length > 0) || + new Set(value).size !== value.length + ) { + throw new Error('queue ledger targets are invalid'); + } +} + +function assertQueuePayload(value: unknown): asserts value is QueueLedgerPayload { + if (!isRecord(value) || typeof value.sourceRecordId !== 'string' || !value.sourceRecordId) { + throw new Error('queue ledger payload identity is incomplete'); + } + if (typeof value.content !== 'string') throw new Error('queue ledger payload content is invalid'); + if (value.messageId !== undefined && typeof value.messageId !== 'string') { + throw new Error('queue ledger payload messageId is invalid'); + } +} + +function assertQueueExecution(value: unknown): asserts value is QueueLedgerExecution { + if (!isRecord(value)) throw new Error('queue ledger execution is invalid'); + if (typeof value.intent !== 'string' || !value.intent) throw new Error('queue ledger execution intent is invalid'); + if (!['strict', 'compatibility_fallback', 'unknown'].includes(String(value.ownerAuthProvenance))) { + throw new Error('queue ledger owner auth provenance is invalid'); + } + if (typeof value.autoExecute !== 'boolean') throw new Error('queue ledger autoExecute is invalid'); + if ( + value.requiresExactCloudDispatchProvenance !== undefined && + typeof value.requiresExactCloudDispatchProvenance !== 'boolean' + ) { + throw new Error('queue ledger exact cloud provenance requirement is invalid'); + } + if (value.cloudDispatchProvenance !== undefined) { + if ( + !isRecord(value.cloudDispatchProvenance) || + typeof value.cloudDispatchProvenance.sourceMessageId !== 'string' || + !value.cloudDispatchProvenance.sourceMessageId || + typeof value.cloudDispatchProvenance.calledByCatId !== 'string' || + !value.cloudDispatchProvenance.calledByCatId || + typeof value.cloudDispatchProvenance.intent !== 'string' || + !isRecord(value.cloudDispatchProvenance.sourceSender) + ) { + throw new Error('queue ledger cloud dispatch provenance is invalid'); + } + } +} + +function assertQueueClassification(entry: Partial): void { + if (entry.kind !== 'conversation_input' && entry.kind !== 'message_wake' && entry.kind !== 'private_input') { + throw new Error('queue ledger kind is invalid'); + } + if (!['queued', 'claimed', 'processing', 'terminal'].includes(entry.status ?? '')) { + throw new Error('queue ledger status is invalid'); + } + if (entry.priority !== 'urgent' && entry.priority !== 'normal') throw new Error('queue ledger priority is invalid'); + const sourceCategories = [ + 'ci', + 'review', + 'conflict', + 'scheduled', + 'a2a', + 'a2a_failure', + 'continuation', + 'issue', + 'freshness', + ]; + if (entry.sourceCategory !== undefined && !sourceCategories.includes(entry.sourceCategory)) { + throw new Error('queue ledger source category is invalid'); + } +} + +export function assertQueueLedgerEntry(value: unknown): asserts value is QueueLedgerEntry { + if (!isRecord(value)) throw new Error('queue ledger row is invalid'); + const entry = value as Partial; + if (entry.version !== 2) throw new Error('unsupported queue ledger entry version'); + if (typeof entry.id !== 'string' || !entry.id || typeof entry.threadId !== 'string' || !entry.threadId) { + throw new Error('queue ledger identity is incomplete'); + } + assertQueueOwner(entry.owner); + assertQueueClassification(entry); + assertQueueTargets(entry.targets); + if (!isMessageFrom(entry.from)) throw new Error('queue ledger sender is invalid'); + assertQueuePayload(entry.payload); + assertQueueExecution(entry.execution); + if (!isRecord(entry.delivery)) throw new Error('queue ledger delivery is invalid'); + if (!isFiniteTimestamp(entry.enqueuedAt)) throw new Error('queue ledger enqueuedAt is invalid'); + assertOptionalTimestamp(entry.claimedAt, 'claimedAt'); + assertOptionalTimestamp(entry.processingStartedAt, 'processingStartedAt'); + assertOptionalTimestamp(entry.terminalAt, 'terminalAt'); + assertQueueLedgerState(entry as QueueLedgerEntry); +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerCodec.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerCodec.ts new file mode 100644 index 0000000000..3652554889 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerCodec.ts @@ -0,0 +1,166 @@ +import { + assertQueueLedgerEntry, + type QueueLedgerEntry, + type QueueLedgerTransitionResult, + queueEntryId, +} from './QueueLedger.js'; + +type PersistedQueueRow = Record & { + version?: unknown; + status?: unknown; + target?: unknown; + targets?: unknown; + payload?: unknown; + delivery?: unknown; + enqueuedAt?: unknown; +}; + +export interface QueueLedgerV2MigrationPlan { + entries: Array<[id: string, raw: string]>; + order: string[]; + messageIndex: Record; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** Collapse retired v1 source×target rows into one pending v2 source entry. */ +export function migrateQueueLedgerRowsToV2( + rawById: Readonly>, + persistedOrder: readonly string[], +): QueueLedgerV2MigrationPlan { + const orderRank = new Map(persistedOrder.map((id, index) => [id, index])); + const candidates = Object.entries(rawById) + .map(([storedId, raw]) => { + const parsed = JSON.parse(raw) as unknown; + const row = record(parsed) as PersistedQueueRow | null; + if (!row) throw new Error(`corrupt queue ledger row: ${storedId}`); + return { storedId, row }; + }) + .sort((left, right) => { + const leftRank = orderRank.get(left.storedId) ?? Number.MAX_SAFE_INTEGER; + const rightRank = orderRank.get(right.storedId) ?? Number.MAX_SAFE_INTEGER; + if (leftRank !== rightRank) return leftRank - rightRank; + const leftAt = typeof left.row.enqueuedAt === 'number' ? left.row.enqueuedAt : Number.MAX_SAFE_INTEGER; + const rightAt = typeof right.row.enqueuedAt === 'number' ? right.row.enqueuedAt : Number.MAX_SAFE_INTEGER; + return leftAt - rightAt || left.storedId.localeCompare(right.storedId); + }); + + const grouped = new Map< + string, + { row: Record; targets: string[]; seenTargets: Set; intents: Record } + >(); + for (const { storedId, row } of candidates) { + if (row.version !== 1 && row.version !== 2) { + throw new Error(`unsupported queue ledger entry version: ${String(row.version)}`); + } + if (row.status !== 'queued' && row.status !== 'claimed') continue; + const payload = record(row.payload); + const sourceRecordId = payload?.sourceRecordId ?? payload?.sourceId; + if (typeof sourceRecordId !== 'string' || !sourceRecordId) { + throw new Error(`queue ledger payload identity is incomplete: ${storedId}`); + } + const rawTargets: string[] = []; + if (Array.isArray(row.targets)) { + for (const targetId of row.targets) { + if (typeof targetId !== 'string' || !targetId) throw new Error(`queue ledger targets are invalid: ${storedId}`); + rawTargets.push(targetId); + } + } else { + const target = record(row.target); + if (target?.kind === 'cat' && typeof target.catId === 'string' && target.catId) rawTargets.push(target.catId); + else if (target?.kind !== 'unassigned') throw new Error(`queue ledger target is invalid: ${storedId}`); + } + + let group = grouped.get(sourceRecordId); + if (!group) { + const nextPayload: Record = { ...payload, sourceRecordId }; + delete nextPayload.sourceId; + const nextRow: Record = { + ...row, + version: 2, + id: queueEntryId(sourceRecordId), + payload: nextPayload, + }; + delete nextRow.target; + delete nextRow.claimId; + delete nextRow.claimedAt; + delete nextRow.claimedTargetIds; + delete nextRow.claimedFromTargetless; + delete nextRow.processingStartedAt; + delete nextRow.terminalAt; + delete nextRow.retiringGroupId; + nextRow.status = 'queued'; + group = { row: nextRow, targets: [], seenTargets: new Set(), intents: {} }; + grouped.set(sourceRecordId, group); + } + for (const targetId of rawTargets) { + if (!group.seenTargets.has(targetId)) { + group.seenTargets.add(targetId); + group.targets.push(targetId); + } + } + const delivery = record(row.delivery); + const byTarget = record(delivery?.authorIntentByTarget); + if (byTarget) { + for (const targetId of rawTargets) { + if (byTarget[targetId] !== undefined) group.intents[targetId] = byTarget[targetId]; + } + } else if (delivery?.authorIntent !== undefined && rawTargets.length === 1) { + group.intents[rawTargets[0] as string] = delivery.authorIntent; + } + } + + const entries: Array<[string, string]> = []; + const order: string[] = []; + const messageIndex: Record = {}; + for (const group of grouped.values()) { + const pendingIntents = Object.fromEntries( + group.targets.flatMap((targetId) => + group.intents[targetId] === undefined ? [] : [[targetId, group.intents[targetId]]], + ), + ); + group.row.targets = group.targets; + group.row.delivery = Object.keys(pendingIntents).length > 0 ? { authorIntentByTarget: pendingIntents } : {}; + const entry = group.row as unknown as QueueLedgerEntry; + assertQueueLedgerEntry(entry); + const raw = JSON.stringify(entry); + entries.push([entry.id, raw]); + order.push(entry.id); + if (entry.payload.messageId) messageIndex[entry.payload.messageId] = [entry.id]; + } + return { entries, order, messageIndex }; +} + +export function hydrateQueueLedgerEntry(raw: string): QueueLedgerEntry { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') throw new Error('corrupt queue ledger row'); + const entry = parsed as QueueLedgerEntry; + assertQueueLedgerEntry(entry); + return entry; +} + +export function hydrateQueueMessageIndex(raw: string, messageId: string): string[] { + const parsed: unknown = JSON.parse(raw); + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + parsed.some((entryId) => typeof entryId !== 'string' || entryId.length === 0) || + new Set(parsed).size !== parsed.length + ) { + throw new Error(`corrupt queue message index: ${messageId}`); + } + return parsed; +} + +export function queueLedgerTransitionResult(raw: unknown): QueueLedgerTransitionResult { + if (!Array.isArray(raw)) throw new Error('invalid queue ledger transition reply'); + const outcome = Number(raw[0]); + if (outcome === -1) return { outcome: 'not_found' }; + if (outcome === 0) return { outcome: 'state_changed' }; + if (outcome !== 1 || typeof raw[1] !== 'string') throw new Error('invalid queue ledger transition outcome'); + return { outcome: 'updated', entry: hydrateQueueLedgerEntry(raw[1]) }; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerReader.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerReader.ts new file mode 100644 index 0000000000..7216948877 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerReader.ts @@ -0,0 +1,94 @@ +import type { RedisClient } from '@cat-cafe/shared/utils'; +import type { QueueLedgerEntry } from './QueueLedger.js'; +import { QueueLedgerKeys } from './queue-ledger-keys.js'; +import { GET_QUEUE_ROWS_BY_MESSAGE_IDS_LUA, LIST_QUEUE_ROWS_LUA } from './queue-ledger-redis-scripts.js'; +import { hydrateQueueLedgerEntry } from './RedisQueueLedgerCodec.js'; + +function parseSerializedRows(raw: unknown, operation: string): QueueLedgerEntry[] { + if (typeof raw !== 'string') throw new Error(`${operation} returned a non-string snapshot`); + const decoded: unknown = JSON.parse(raw); + if (!Array.isArray(decoded) || decoded.some((value) => typeof value !== 'string')) { + throw new Error(`${operation} returned an invalid snapshot`); + } + return decoded.map(hydrateQueueLedgerEntry); +} + +export async function listRedisQueueLedgerEntries(redis: RedisClient, threadId: string): Promise { + const raw = await redis.eval( + LIST_QUEUE_ROWS_LUA, + 2, + QueueLedgerKeys.entries(threadId), + QueueLedgerKeys.order(threadId), + ); + return parseSerializedRows(raw, 'Queue order read'); +} + +export async function listAllRedisQueueLedgerEntries( + redis: RedisClient, + threadId: string, +): Promise { + const raws = await redis.hvals(QueueLedgerKeys.entries(threadId)); + return raws + .map(hydrateQueueLedgerEntry) + .sort((left, right) => left.enqueuedAt - right.enqueuedAt || left.id.localeCompare(right.id)); +} + +export async function getRedisQueueLedgerEntriesByMessageIds( + redis: RedisClient, + threadId: string, + messageIds: readonly string[], +): Promise> { + const uniqueMessageIds = [...new Set(messageIds.filter((messageId) => messageId.length > 0))]; + const grouped = new Map(); + if (uniqueMessageIds.length === 0) return grouped; + const raw = await redis.eval( + GET_QUEUE_ROWS_BY_MESSAGE_IDS_LUA, + 2, + QueueLedgerKeys.entries(threadId), + QueueLedgerKeys.messageIndex(threadId), + ...uniqueMessageIds, + ); + if (typeof raw !== 'string') throw new Error('Queue message-index read returned a non-string snapshot'); + const decoded: unknown = JSON.parse(raw); + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { + throw new Error('Queue message-index read returned an invalid snapshot'); + } + const rowsByMessage = decoded as Record; + for (const messageId of uniqueMessageIds) { + const serializedRows = rowsByMessage[messageId]; + if (serializedRows === undefined) continue; + if (!Array.isArray(serializedRows) || serializedRows.some((value) => typeof value !== 'string')) { + throw new Error(`Queue message index returned invalid rows: ${messageId}`); + } + const entries = serializedRows.map((value) => hydrateQueueLedgerEntry(value as string)); + if (entries.some((entry) => entry.payload.messageId !== messageId)) { + throw new Error(`queue message index identity mismatch: ${messageId}`); + } + grouped.set(messageId, entries); + } + return grouped; +} + +export async function listRedisQueueLedgerThreadIds(redis: RedisClient, keyPrefix: string): Promise { + const threadIds = new Set(); + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${keyPrefix}queue:{*}:order`, 'COUNT', 200); + cursor = nextCursor; + for (const key of keys) { + const localKey = keyPrefix && key.startsWith(keyPrefix) ? key.slice(keyPrefix.length) : key; + const match = /^queue:\{(.+)\}:order$/.exec(localKey); + if (match?.[1]) threadIds.add(decodeURIComponent(match[1])); + } + } while (cursor !== '0'); + return [...threadIds].sort(); +} + +export async function getRedisQueueLedgerEntry( + redis: RedisClient, + threadId: string, + entryId: string, +): Promise { + const raw = await redis.hget(QueueLedgerKeys.entries(threadId), entryId); + return raw ? hydrateQueueLedgerEntry(raw) : null; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.ts new file mode 100644 index 0000000000..996c860cd0 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.ts @@ -0,0 +1,347 @@ +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { + assertQueueLedgerEntry, + cloneQueueLedgerEntry, + type QueueLedgerClaimResult, + type QueueLedgerCommitMode, + type QueueLedgerEnqueueResult, + type QueueLedgerEntry, + type QueueLedgerStore, + type QueueLedgerTargetExpansionResult, + type QueueLedgerTargetReconcileResult, + type QueueLedgerTransitionResult, + queueLedgerAdmissionsMatch, +} from './QueueLedger.js'; +import { QueueLedgerKeys } from './queue-ledger-keys.js'; +import { + CLAIM_QUEUE_PREFIX_LUA, + CLAIM_QUEUE_ROW_LUA, + COMMIT_QUEUE_ROW_LUA, + ENQUEUE_QUEUE_ROWS_LUA, + EXPAND_QUEUE_TARGET_ROWS_LUA, + MIGRATE_QUEUE_LEDGER_V2_LUA, + RECONCILE_QUEUE_TARGETS_LUA, + RESTORE_QUEUE_ROW_LUA, +} from './queue-ledger-redis-scripts.js'; +import { + hydrateQueueLedgerEntry, + migrateQueueLedgerRowsToV2, + queueLedgerTransitionResult, +} from './RedisQueueLedgerCodec.js'; +import { + getRedisQueueLedgerEntriesByMessageIds, + getRedisQueueLedgerEntry, + listAllRedisQueueLedgerEntries, + listRedisQueueLedgerEntries, + listRedisQueueLedgerThreadIds, +} from './RedisQueueLedgerReader.js'; + +export { hydrateQueueLedgerEntry, migrateQueueLedgerRowsToV2 } from './RedisQueueLedgerCodec.js'; + +export class RedisQueueLedgerStore implements QueueLedgerStore { + private readonly migrations = new Map>(); + + constructor(private readonly redis: RedisClient) {} + + usesRedisClient(redis: RedisClient): boolean { + return this.redis === redis; + } + + private get keyPrefix(): string { + return (this.redis.options as { keyPrefix?: string }).keyPrefix ?? ''; + } + + private async ensureThreadMigrated(threadId: string): Promise { + const existing = this.migrations.get(threadId); + if (existing) return existing; + const migration = this.migrateThread(threadId).catch((error) => { + this.migrations.delete(threadId); + throw error; + }); + this.migrations.set(threadId, migration); + return migration; + } + + private async migrateThread(threadId: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + if ((await this.redis.get(QueueLedgerKeys.schema(threadId))) === '2') return; + const [rawById, order] = await Promise.all([ + this.redis.hgetall(QueueLedgerKeys.entries(threadId)), + this.redis.lrange(QueueLedgerKeys.order(threadId), 0, -1), + ]); + const plan = migrateQueueLedgerRowsToV2(rawById, order); + const expectedEntries = Object.entries(rawById).sort(([left], [right]) => left.localeCompare(right)); + const result = Number( + await this.redis.eval( + MIGRATE_QUEUE_LEDGER_V2_LUA, + 4, + QueueLedgerKeys.entries(threadId), + QueueLedgerKeys.order(threadId), + QueueLedgerKeys.messageIndex(threadId), + QueueLedgerKeys.schema(threadId), + JSON.stringify(expectedEntries), + JSON.stringify(order), + JSON.stringify(plan.entries), + JSON.stringify(plan.order), + JSON.stringify(plan.messageIndex), + ), + ); + if (result === 1 || result === 2) return; + if (result !== 0) throw new Error(`unexpected Queue v2 migration outcome: ${result}`); + } + throw new Error(`Queue v2 migration did not converge for thread ${threadId}`); + } + + async enqueue( + entries: readonly QueueLedgerEntry[], + maxQueuedUserEntries?: number, + ): Promise { + if (entries.length === 0) throw new Error('queue ledger enqueue requires at least one row'); + for (const entry of entries) assertQueueLedgerEntry(entry); + const first = entries[0]; + if (!first) throw new Error('queue ledger enqueue requires at least one row'); + const threadId = first.threadId; + await this.ensureThreadMigrated(threadId); + if (entries.some((entry) => entry.threadId !== threadId)) + throw new Error('queue ledger enqueue must be one thread'); + const serialized = entries.map((entry) => JSON.stringify(entry)); + const raw = Number( + await this.redis.eval( + ENQUEUE_QUEUE_ROWS_LUA, + 3, + QueueLedgerKeys.entries(threadId), + QueueLedgerKeys.order(threadId), + QueueLedgerKeys.messageIndex(threadId), + maxQueuedUserEntries === undefined ? '-1' : String(maxQueuedUserEntries), + String(entries.length), + ...serialized, + ), + ); + if (raw === 0) return { outcome: 'full', entries: [] }; + if (raw === -1) return { outcome: 'conflict', entries: [] }; + if (raw !== 1 && raw !== 2) throw new Error(`unexpected queue ledger enqueue outcome: ${raw}`); + if (raw === 1) return { outcome: 'enqueued', entries: entries.map(cloneQueueLedgerEntry) }; + const existingRaws = await this.redis.hmget(QueueLedgerKeys.entries(threadId), ...entries.map((entry) => entry.id)); + if (existingRaws.some((value) => typeof value !== 'string')) { + throw new Error('Queue replay identity vanished after atomic preflight'); + } + const existing = existingRaws.map((value) => hydrateQueueLedgerEntry(value as string)); + if ( + !existing.every((entry, index) => { + const input = entries[index]; + return input !== undefined && queueLedgerAdmissionsMatch(entry, input); + }) + ) { + return { outcome: 'conflict', entries: [] }; + } + return { outcome: 'replayed', entries: existing }; + } + + async expandTargets( + threadId: string, + entryId: string, + bindTargetCatId: string, + expectedQueuedEntryIds: readonly string[], + siblingEntries: readonly QueueLedgerEntry[], + ): Promise { + await this.ensureThreadMigrated(threadId); + if (!bindTargetCatId) throw new Error('queue target expansion requires a target'); + for (const entry of siblingEntries) assertQueueLedgerEntry(entry); + const result = (await this.redis.eval( + EXPAND_QUEUE_TARGET_ROWS_LUA, + 3, + QueueLedgerKeys.entries(threadId), + QueueLedgerKeys.order(threadId), + QueueLedgerKeys.messageIndex(threadId), + entryId, + bindTargetCatId, + String(expectedQueuedEntryIds.length), + String(siblingEntries.length), + ...expectedQueuedEntryIds, + ...siblingEntries.map((entry) => JSON.stringify(entry)), + )) as [number | string, string]; + const raw = Number(result[0]); + if (raw === -2) return { outcome: 'not_found', entries: [] }; + if (raw === 0) return { outcome: 'state_changed', entries: [] }; + if (raw === -1) return { outcome: 'conflict', entries: [] }; + if (raw !== 1 && raw !== 2) throw new Error(`unexpected queue target expansion outcome: ${raw}`); + const serialized = JSON.parse(result[1]) as unknown; + if (!Array.isArray(serialized) || serialized.some((value) => typeof value !== 'string')) { + throw new Error('Queue target expansion returned invalid committed rows'); + } + const entries = serialized.map((value) => hydrateQueueLedgerEntry(value)); + const anchor = entries[0]; + if (!anchor || !anchor.targets.includes(bindTargetCatId)) { + return { outcome: 'conflict', entries: [] }; + } + if ( + !entries.slice(1 + expectedQueuedEntryIds.length).every((entry, index) => { + const input = siblingEntries[index]; + return input !== undefined && queueLedgerAdmissionsMatch(entry, input); + }) + ) { + return { outcome: 'conflict', entries: [] }; + } + return { outcome: raw === 1 ? 'expanded' : 'replayed', entries }; + } + + async reconcileTargets( + threadId: string, + entryId: string, + addTargetIds: readonly string[], + removeTargetIds: readonly string[], + authorIntentByTarget: Readonly> = {}, + ): Promise { + await this.ensureThreadMigrated(threadId); + const result = (await this.redis.eval( + RECONCILE_QUEUE_TARGETS_LUA, + 3, + QueueLedgerKeys.entries(threadId), + QueueLedgerKeys.order(threadId), + QueueLedgerKeys.messageIndex(threadId), + entryId, + JSON.stringify(addTargetIds), + JSON.stringify(removeTargetIds), + JSON.stringify(authorIntentByTarget ?? {}), + )) as [number | string, string]; + const outcome = Number(result[0]); + if (outcome === -1) return { outcome: 'not_found' }; + if (outcome === 0) return { outcome: 'state_changed' }; + if (outcome !== 1 && outcome !== 2) throw new Error(`unexpected Queue target reconcile outcome: ${outcome}`); + const entry = result[1] ? hydrateQueueLedgerEntry(result[1]) : null; + return { outcome: outcome === 1 ? 'updated' : 'replayed', entry }; + } + + async list(threadId: string): Promise { + await this.ensureThreadMigrated(threadId); + return listRedisQueueLedgerEntries(this.redis, threadId); + } + + async listAll(threadId: string): Promise { + await this.ensureThreadMigrated(threadId); + return listAllRedisQueueLedgerEntries(this.redis, threadId); + } + + async getByMessageIds(threadId: string, messageIds: readonly string[]): Promise> { + await this.ensureThreadMigrated(threadId); + return getRedisQueueLedgerEntriesByMessageIds(this.redis, threadId, messageIds); + } + + async listThreadIds(): Promise { + return listRedisQueueLedgerThreadIds(this.redis, this.keyPrefix); + } + + async get(threadId: string, entryId: string): Promise { + await this.ensureThreadMigrated(threadId); + return getRedisQueueLedgerEntry(this.redis, threadId, entryId); + } + + async claim( + threadId: string, + entryId: string, + claimId: string, + claimedAt: number, + bindTargetCatId?: string, + steerRequestedAt?: number, + ): Promise { + await this.ensureThreadMigrated(threadId); + const raw = await this.redis.eval( + CLAIM_QUEUE_ROW_LUA, + 1, + QueueLedgerKeys.entries(threadId), + entryId, + claimId, + String(claimedAt), + bindTargetCatId ?? '', + steerRequestedAt === undefined ? '' : String(steerRequestedAt), + ); + const result = queueLedgerTransitionResult(raw); + return result.outcome === 'updated' + ? { outcome: 'claimed', entries: [result.entry], claimId } + : { outcome: result.outcome }; + } + + async claimPrefix( + threadId: string, + entryIds: readonly string[], + claimId: string, + claimedAt: number, + bindTargetCatId?: string, + steerRequestedAt?: number, + ): Promise { + await this.ensureThreadMigrated(threadId); + if (entryIds.length === 0) throw new Error('queue prefix claim requires at least one row'); + const raw = await this.redis.eval( + CLAIM_QUEUE_PREFIX_LUA, + 1, + QueueLedgerKeys.entries(threadId), + String(entryIds.length), + claimId, + String(claimedAt), + bindTargetCatId ?? '', + steerRequestedAt === undefined ? '' : String(steerRequestedAt), + ...entryIds, + ); + if (!Array.isArray(raw)) throw new Error('invalid queue prefix claim reply'); + const outcome = Number(raw[0]); + if (outcome === -1) return { outcome: 'not_found' }; + if (outcome === 0) return { outcome: 'state_changed' }; + if (outcome !== 1 || typeof raw[1] !== 'string') throw new Error('invalid queue prefix claim outcome'); + const encoded: unknown = JSON.parse(raw[1]); + if (!Array.isArray(encoded) || encoded.some((item) => typeof item !== 'string')) { + throw new Error('invalid queue prefix claim payload'); + } + return { + outcome: 'claimed', + claimId, + entries: encoded.map((item) => hydrateQueueLedgerEntry(item)), + }; + } + + async commit( + threadId: string, + entryId: string, + claimId: string, + mode: QueueLedgerCommitMode, + at: number, + replacement?: QueueLedgerEntry, + retiredTargetIds?: readonly string[], + ): Promise { + await this.ensureThreadMigrated(threadId); + if (replacement) assertQueueLedgerEntry(replacement); + return queueLedgerTransitionResult( + await this.redis.eval( + COMMIT_QUEUE_ROW_LUA, + 3, + QueueLedgerKeys.entries(threadId), + QueueLedgerKeys.order(threadId), + QueueLedgerKeys.messageIndex(threadId), + entryId, + claimId, + mode, + String(at), + replacement ? JSON.stringify(replacement) : '', + retiredTargetIds ? JSON.stringify(retiredTargetIds) : '', + ), + ); + } + + async restore( + threadId: string, + entryId: string, + claimId: string, + restoreUnassignedTarget = false, + ): Promise { + await this.ensureThreadMigrated(threadId); + return queueLedgerTransitionResult( + await this.redis.eval( + RESTORE_QUEUE_ROW_LUA, + 1, + QueueLedgerKeys.entries(threadId), + entryId, + claimId, + restoreUnassignedTarget ? '1' : '0', + ), + ); + } +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-keys.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-keys.ts new file mode 100644 index 0000000000..b7a7aef0b5 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-keys.ts @@ -0,0 +1,11 @@ +function threadTag(threadId: string): string { + if (!threadId) throw new Error('queue ledger thread id is required'); + return encodeURIComponent(threadId); +} + +export const QueueLedgerKeys = { + entries: (threadId: string) => `queue:{${threadTag(threadId)}}:entries`, + order: (threadId: string) => `queue:{${threadTag(threadId)}}:order`, + messageIndex: (threadId: string) => `queue:{${threadTag(threadId)}}:messages`, + schema: (threadId: string) => `queue:{${threadTag(threadId)}}:schema`, +} as const; diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-scripts.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-scripts.ts new file mode 100644 index 0000000000..95adafc980 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-scripts.ts @@ -0,0 +1,338 @@ +/** + * ADR-043 queue mutations. Every script performs all validation before its + * first write because Redis does not roll back writes made before a Lua error. + */ +export const LIST_QUEUE_ROWS_LUA = ` +local ids = redis.call('LRANGE', KEYS[2], 0, -1) +if #ids == 0 then return '[]' end +local rows = {} +for i = 1, #ids do + local raw = redis.call('HGET', KEYS[1], ids[i]) + if not raw then return redis.error_reply('QUEUE_ORDER_ROW_MISSING:' .. ids[i]) end + rows[#rows + 1] = raw +end +return cjson.encode(rows) +`; + +export const GET_QUEUE_ROWS_BY_MESSAGE_IDS_LUA = ` +local grouped = {} +for i = 1, #ARGV do + local messageId = ARGV[i] + local indexRaw = redis.call('HGET', KEYS[2], messageId) + if indexRaw then + local decodedOk, entryIds = pcall(cjson.decode, indexRaw) + if not decodedOk or type(entryIds) ~= 'table' or #entryIds == 0 then + return redis.error_reply('QUEUE_MESSAGE_INDEX_INVALID:' .. messageId) + end + local rows = {} + for j = 1, #entryIds do + local entryId = entryIds[j] + if type(entryId) ~= 'string' or entryId == '' then + return redis.error_reply('QUEUE_MESSAGE_INDEX_INVALID:' .. messageId) + end + local raw = redis.call('HGET', KEYS[1], entryId) + if not raw then return redis.error_reply('QUEUE_MESSAGE_INDEX_ROW_MISSING:' .. entryId) end + rows[#rows + 1] = raw + end + grouped[messageId] = rows + end +end +return cjson.encode(grouped) +`; + +export const MIGRATE_QUEUE_LEDGER_V2_LUA = ` +local currentSchema = redis.call('GET', KEYS[4]) +if currentSchema == '2' then return 2 end + +local expectedEntries = cjson.decode(ARGV[1]) +local expectedOrder = cjson.decode(ARGV[2]) +local nextEntries = cjson.decode(ARGV[3]) +local nextOrder = cjson.decode(ARGV[4]) +local nextMessageIndex = cjson.decode(ARGV[5]) +if type(expectedEntries) ~= 'table' or type(expectedOrder) ~= 'table' or + type(nextEntries) ~= 'table' or type(nextOrder) ~= 'table' or type(nextMessageIndex) ~= 'table' then + return redis.error_reply('QUEUE_V2_MIGRATION_INVALID') +end + +local currentPairs = redis.call('HGETALL', KEYS[1]) +if #currentPairs ~= #expectedEntries * 2 then return 0 end +local currentById = {} +for i = 1, #currentPairs, 2 do currentById[currentPairs[i]] = currentPairs[i + 1] end +for i = 1, #expectedEntries do + local pair = expectedEntries[i] + if type(pair) ~= 'table' or type(pair[1]) ~= 'string' or type(pair[2]) ~= 'string' or + currentById[pair[1]] ~= pair[2] then return 0 end +end + +local currentOrder = redis.call('LRANGE', KEYS[2], 0, -1) +if #currentOrder ~= #expectedOrder then return 0 end +for i = 1, #expectedOrder do if currentOrder[i] ~= expectedOrder[i] then return 0 end end + +-- Redis does not roll back writes when a Lua script raises. Validate every +-- replacement pair before deleting the v1 hashes/lists. +for i = 1, #nextEntries do + local pair = nextEntries[i] + if type(pair) ~= 'table' or type(pair[1]) ~= 'string' or type(pair[2]) ~= 'string' then + return redis.error_reply('QUEUE_V2_MIGRATION_INVALID_ENTRY') + end +end +for i = 1, #nextOrder do + if type(nextOrder[i]) ~= 'string' or nextOrder[i] == '' then + return redis.error_reply('QUEUE_V2_MIGRATION_INVALID_ORDER') + end +end +for messageId, entryIds in pairs(nextMessageIndex) do + if type(messageId) ~= 'string' or messageId == '' or type(entryIds) ~= 'table' then + return redis.error_reply('QUEUE_V2_MIGRATION_INVALID_MESSAGE_INDEX') + end + for i = 1, #entryIds do + if type(entryIds[i]) ~= 'string' or entryIds[i] == '' then + return redis.error_reply('QUEUE_V2_MIGRATION_INVALID_MESSAGE_INDEX') + end + end +end + +redis.call('DEL', KEYS[1], KEYS[2], KEYS[3]) +for i = 1, #nextEntries do + local pair = nextEntries[i] + redis.call('HSET', KEYS[1], pair[1], pair[2]) +end +for i = 1, #nextOrder do redis.call('RPUSH', KEYS[2], nextOrder[i]) end +for messageId, entryIds in pairs(nextMessageIndex) do + redis.call('HSET', KEYS[3], messageId, cjson.encode(entryIds)) +end +redis.call('SET', KEYS[4], '2') +return 1 +`; + +export const ENQUEUE_QUEUE_ROWS_LUA = ` +local rowsKey = KEYS[1] +local orderKey = KEYS[2] +local maxQueuedUsers = tonumber(ARGV[1]) +local count = tonumber(ARGV[2]) +if not count or count < 1 then return redis.error_reply('QUEUE_ENQUEUE_EMPTY') end + +local incoming = {} +local existingCount = 0 +local incomingUserSources = {} +for i = 1, count do + local raw = ARGV[2 + i] + local row = cjson.decode(raw) + if not row.id or not row.threadId or row.status ~= 'queued' then + return redis.error_reply('QUEUE_ENQUEUE_INVALID_ROW') + end + local existing = redis.call('HGET', rowsKey, row.id) + if existing then + existingCount = existingCount + 1 + end + if row.from and row.from.kind == 'user' then incomingUserSources[row.payload.sourceRecordId] = true end + incoming[i] = { id = row.id, raw = raw, row = row } +end +if existingCount == count then return 2 end +if existingCount ~= 0 then return -1 end + +local messageIndexUpdates = {} +for i = 1, count do + local row = incoming[i].row + local messageId = row.payload and row.payload.messageId + if messageId and messageId ~= '' then + local update = messageIndexUpdates[messageId] + if not update then + update = { ids = {}, seen = {} } + local existingIndexRaw = redis.call('HGET', KEYS[3], messageId) + if existingIndexRaw then + local decodedOk, decoded = pcall(cjson.decode, existingIndexRaw) + if not decodedOk or type(decoded) ~= 'table' then + return redis.error_reply('QUEUE_MESSAGE_INDEX_INVALID') + end + for j = 1, #decoded do + if type(decoded[j]) ~= 'string' or decoded[j] == '' or update.seen[decoded[j]] then + return redis.error_reply('QUEUE_MESSAGE_INDEX_INVALID') + end + update.seen[decoded[j]] = true + update.ids[#update.ids + 1] = decoded[j] + end + end + messageIndexUpdates[messageId] = update + end + if not update.seen[row.id] then + update.seen[row.id] = true + update.ids[#update.ids + 1] = row.id + end + end +end + +if maxQueuedUsers and maxQueuedUsers >= 0 then + local queuedUserSources = {} + local activeIds = redis.call('LRANGE', orderKey, 0, -1) + for i = 1, #activeIds do + local currentRaw = redis.call('HGET', rowsKey, activeIds[i]) + if not currentRaw then return redis.error_reply('QUEUE_ORDER_ROW_MISSING') end + local row = cjson.decode(currentRaw) + if row.status == 'queued' and row.from and row.from.kind == 'user' then + queuedUserSources[row.payload.sourceRecordId] = true + end + end + for sourceId, _ in pairs(incomingUserSources) do queuedUserSources[sourceId] = true end + local queuedUserCount = 0 + for _, _ in pairs(queuedUserSources) do queuedUserCount = queuedUserCount + 1 end + if queuedUserCount > maxQueuedUsers then return 0 end +end + +for i = 1, count do + redis.call('HSET', rowsKey, incoming[i].id, incoming[i].raw) + redis.call('RPUSH', orderKey, incoming[i].id) +end +for messageId, update in pairs(messageIndexUpdates) do + redis.call('HSET', KEYS[3], messageId, cjson.encode(update.ids)) +end +return 1 +`; + +export const EXPAND_QUEUE_TARGET_ROWS_LUA = ` +local rowsKey = KEYS[1] +local anchorId = ARGV[1] +local bindTargetCatId = ARGV[2] +local expectedCount = tonumber(ARGV[3]) +local count = tonumber(ARGV[4]) +if not bindTargetCatId or bindTargetCatId == '' or not expectedCount or expectedCount < 0 or not count or count < 0 then + return redis.error_reply('QUEUE_TARGET_EXPANSION_INVALID') +end +local anchorRaw = redis.call('HGET', rowsKey, anchorId) +if not anchorRaw then return {-2, ''} end +local anchor = cjson.decode(anchorRaw) +if anchor.status ~= 'queued' then return {0, ''} end +if type(anchor.targets) ~= 'table' then return {-1, ''} end + +for i = 1, expectedCount do + local expectedId = ARGV[4 + i] + if expectedId ~= anchorId then return {0, ''} end +end + +local seenTargets = {} +for i = 1, #anchor.targets do + local targetId = anchor.targets[i] + if type(targetId) ~= 'string' or targetId == '' or seenTargets[targetId] then return {-1, ''} end + seenTargets[targetId] = true +end +local changed = false +if not seenTargets[bindTargetCatId] then + anchor.targets[#anchor.targets + 1] = bindTargetCatId + seenTargets[bindTargetCatId] = true + changed = true +end + +for i = 1, count do + local raw = ARGV[4 + expectedCount + i] + local row = cjson.decode(raw) + if row.id ~= anchorId or row.threadId ~= anchor.threadId or row.status ~= 'queued' or row.payload.sourceRecordId ~= anchor.payload.sourceRecordId or type(row.targets) ~= 'table' then + return redis.error_reply('QUEUE_TARGET_EXPANSION_INVALID_ROW') + end + for j = 1, #row.targets do + local targetId = row.targets[j] + if type(targetId) ~= 'string' or targetId == '' then return redis.error_reply('QUEUE_TARGET_EXPANSION_INVALID_ROW') end + if not seenTargets[targetId] then + anchor.targets[#anchor.targets + 1] = targetId + seenTargets[targetId] = true + changed = true + end + end + if row.delivery and row.delivery.authorIntentByTarget then + anchor.delivery = anchor.delivery or {} + anchor.delivery.authorIntentByTarget = anchor.delivery.authorIntentByTarget or {} + for targetId, intent in pairs(row.delivery.authorIntentByTarget) do + anchor.delivery.authorIntentByTarget[targetId] = intent + end + end +end +if not changed then return {2, cjson.encode({anchorRaw})} end +anchorRaw = cjson.encode(anchor) +redis.call('HSET', rowsKey, anchorId, anchorRaw) +return {1, cjson.encode({anchorRaw})} +`; + +export const RECONCILE_QUEUE_TARGETS_LUA = ` +local function encodeRow(value) + local encoded = cjson.encode(value) + encoded = string.gsub(encoded, '"targets":{}', '"targets":[]') + return encoded +end +local id = ARGV[1] +local addTargets = cjson.decode(ARGV[2]) +local removeTargets = cjson.decode(ARGV[3]) +local intentByTarget = cjson.decode(ARGV[4]) +if type(addTargets) ~= 'table' or type(removeTargets) ~= 'table' or type(intentByTarget) ~= 'table' then + return redis.error_reply('QUEUE_TARGET_RECONCILE_INVALID') +end +local raw = redis.call('HGET', KEYS[1], id) +if not raw then return {-1, ''} end +local row = cjson.decode(raw) +if row.status ~= 'queued' then return {0, raw} end +if type(row.targets) ~= 'table' then return redis.error_reply('QUEUE_TARGET_RECONCILE_INVALID_ROW') end + +local removeSet = {} +for i = 1, #removeTargets do + local targetId = removeTargets[i] + if type(targetId) ~= 'string' or targetId == '' or removeSet[targetId] then + return redis.error_reply('QUEUE_TARGET_RECONCILE_INVALID') + end + removeSet[targetId] = true +end +local addSet = {} +for i = 1, #addTargets do + local targetId = addTargets[i] + if type(targetId) ~= 'string' or targetId == '' or addSet[targetId] or removeSet[targetId] then + return redis.error_reply('QUEUE_TARGET_RECONCILE_INVALID') + end + addSet[targetId] = true +end + +local nextTargets = {} +local present = {} +for i = 1, #row.targets do + local targetId = row.targets[i] + if type(targetId) ~= 'string' or targetId == '' or present[targetId] then + return redis.error_reply('QUEUE_TARGET_RECONCILE_INVALID_ROW') + end + if not removeSet[targetId] then + nextTargets[#nextTargets + 1] = targetId + present[targetId] = true + end +end +for i = 1, #addTargets do + local targetId = addTargets[i] + if not present[targetId] then + nextTargets[#nextTargets + 1] = targetId + present[targetId] = true + end +end + +row.delivery = row.delivery or {} +local existingIntent = row.delivery.authorIntentByTarget or {} +local nextIntent = {} +for i = 1, #nextTargets do + local targetId = nextTargets[i] + if intentByTarget[targetId] then nextIntent[targetId] = intentByTarget[targetId] + elseif existingIntent[targetId] then nextIntent[targetId] = existingIntent[targetId] end +end +row.targets = nextTargets +row.delivery.authorIntentByTarget = nextIntent +local next = encodeRow(row) +if next == raw then return {2, raw} end +if #nextTargets == 0 then + redis.call('HDEL', KEYS[1], id) + redis.call('LREM', KEYS[2], 1, id) + local messageId = row.payload and row.payload.messageId + if messageId and messageId ~= '' then redis.call('HDEL', KEYS[3], messageId) end + return {1, ''} +end +redis.call('HSET', KEYS[1], id, next) +return {1, next} +`; + +export { + CLAIM_QUEUE_PREFIX_LUA, + CLAIM_QUEUE_ROW_LUA, + COMMIT_QUEUE_ROW_LUA, + RESTORE_QUEUE_ROW_LUA, +} from './queue-ledger-redis-transition-scripts.js'; diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-transition-scripts.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-transition-scripts.ts new file mode 100644 index 0000000000..4fa556c376 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/queue-ledger-redis-transition-scripts.ts @@ -0,0 +1,257 @@ +export const CLAIM_QUEUE_ROW_LUA = ` +local function encodeRow(value) + local encoded = cjson.encode(value) + encoded = string.gsub(encoded, '"targets":{}', '"targets":[]') + encoded = string.gsub(encoded, '"claimedTargetIds":{}', '"claimedTargetIds":[]') + return encoded +end +local raw = redis.call('HGET', KEYS[1], ARGV[1]) +if not raw then return {-1, ''} end +local row = cjson.decode(raw) +if row.status ~= 'queued' then return {0, raw} end +row.status = 'claimed' +row.claimId = ARGV[2] +row.claimedAt = tonumber(ARGV[3]) +local bindTargetCatId = ARGV[4] +if bindTargetCatId and bindTargetCatId ~= '' then + if type(row.targets) ~= 'table' then return {0, raw} end + local found = false + for i = 1, #row.targets do if row.targets[i] == bindTargetCatId then found = true end end + if #row.targets > 0 and not found then return {0, raw} end + if #row.targets == 0 then + row.targets = { bindTargetCatId } + row.claimedFromTargetless = true + end + row.claimedTargetIds = { bindTargetCatId } +else + row.claimedTargetIds = row.targets +end +local steerRequestedAt = tonumber(ARGV[5]) +if steerRequestedAt then row.delivery.steerRequestedAt = steerRequestedAt end +local next = encodeRow(row) +redis.call('HSET', KEYS[1], ARGV[1], next) +return {1, next} +`; + +export const CLAIM_QUEUE_PREFIX_LUA = ` +local function encodeRow(value) + local encoded = cjson.encode(value) + encoded = string.gsub(encoded, '"targets":{}', '"targets":[]') + encoded = string.gsub(encoded, '"claimedTargetIds":{}', '"claimedTargetIds":[]') + return encoded +end +local count = tonumber(ARGV[1]) +if not count or count < 1 then return redis.error_reply('QUEUE_CLAIM_PREFIX_EMPTY') end +local claimId = ARGV[2] +local claimedAt = tonumber(ARGV[3]) +local bindTargetCatId = ARGV[4] +local steerRequestedAt = tonumber(ARGV[5]) +local rows = {} +for i = 1, count do + local id = ARGV[5 + i] + local raw = redis.call('HGET', KEYS[1], id) + if not raw then return {-1, ''} end + local row = cjson.decode(raw) + if row.status ~= 'queued' then return {0, raw} end + if bindTargetCatId and bindTargetCatId ~= '' then + if type(row.targets) ~= 'table' then return {0, raw} end + local found = false + for j = 1, #row.targets do if row.targets[j] == bindTargetCatId then found = true end end + if #row.targets > 0 and not found then return {0, raw} end + end + rows[i] = { id = id, row = row } +end +local encoded = {} +for i = 1, count do + rows[i].row.status = 'claimed' + rows[i].row.claimId = claimId + rows[i].row.claimedAt = claimedAt + if bindTargetCatId and bindTargetCatId ~= '' then + if #rows[i].row.targets == 0 then + rows[i].row.targets = { bindTargetCatId } + rows[i].row.claimedFromTargetless = true + end + rows[i].row.claimedTargetIds = { bindTargetCatId } + else + rows[i].row.claimedTargetIds = rows[i].row.targets + end + if steerRequestedAt then rows[i].row.delivery.steerRequestedAt = steerRequestedAt end + local next = encodeRow(rows[i].row) + redis.call('HSET', KEYS[1], rows[i].id, next) + encoded[i] = next +end +return {1, cjson.encode(encoded)} +`; + +export const COMMIT_QUEUE_ROW_LUA = ` +local function encodeRow(value) + local encoded = cjson.encode(value) + encoded = string.gsub(encoded, '"targets":{}', '"targets":[]') + encoded = string.gsub(encoded, '"claimedTargetIds":{}', '"claimedTargetIds":[]') + return encoded +end +local id = ARGV[1] +local claimId = ARGV[2] +local mode = ARGV[3] +local at = tonumber(ARGV[4]) +local replacementRaw = ARGV[5] +local retiredTargetIdsRaw = ARGV[6] +local raw = redis.call('HGET', KEYS[1], id) +if not raw then return {-1, ''} end +local row = cjson.decode(raw) + +if mode == 'queued' then + if row.status ~= 'claimed' or row.claimId ~= claimId then return {0, raw} end + local nextRow = row + if replacementRaw and replacementRaw ~= '' then + nextRow = cjson.decode(replacementRaw) + if nextRow.id ~= id or nextRow.threadId ~= row.threadId then + return redis.error_reply('QUEUE_COMMIT_IDENTITY_MISMATCH') + end + end + nextRow.status = 'queued' + nextRow.processingStartedAt = nil + nextRow.claimId = nil + nextRow.claimedAt = nil + nextRow.claimedTargetIds = nil + nextRow.claimedFromTargetless = nil + local next = encodeRow(nextRow) + redis.call('HSET', KEYS[1], id, next) + return {1, next} +end + +local function removeMessageIndex(rowToRemove) + local messageId = rowToRemove.payload and rowToRemove.payload.messageId + if not messageId or messageId == '' then return end + redis.call('HDEL', KEYS[3], messageId) +end + +if mode == 'processing' then + if row.status ~= 'claimed' or row.claimId ~= claimId or type(row.claimedTargetIds) ~= 'table' or #row.claimedTargetIds == 0 then + return {0, raw} + end + local attempted = cjson.decode(raw) + if replacementRaw and replacementRaw ~= '' then + attempted = cjson.decode(replacementRaw) + if attempted.id ~= id or attempted.threadId ~= row.threadId then + return redis.error_reply('QUEUE_COMMIT_IDENTITY_MISMATCH') + end + end + local retiredTargetIds = row.claimedTargetIds + if retiredTargetIdsRaw and retiredTargetIdsRaw ~= '' then + retiredTargetIds = cjson.decode(retiredTargetIdsRaw) + end + if type(retiredTargetIds) ~= 'table' or #retiredTargetIds == 0 then return {0, raw} end + local claimed = {} + for i = 1, #row.claimedTargetIds do claimed[row.claimedTargetIds[i]] = true end + local retired = {} + for i = 1, #retiredTargetIds do + local targetId = retiredTargetIds[i] + if type(targetId) ~= 'string' or targetId == '' or retired[targetId] or not claimed[targetId] then + return {0, raw} + end + retired[targetId] = true + end + attempted.targets = retiredTargetIds + attempted.status = 'processing' + attempted.processingStartedAt = at + attempted.claimId = nil + attempted.claimedAt = nil + attempted.claimedTargetIds = nil + attempted.claimedFromTargetless = nil + attempted.terminalAt = nil + + local remainingTargets = {} + for i = 1, #row.targets do + if not retired[row.targets[i]] then remainingTargets[#remainingTargets + 1] = row.targets[i] end + end + local remainingClaimedTargets = {} + for i = 1, #row.claimedTargetIds do + if not retired[row.claimedTargetIds[i]] then + remainingClaimedTargets[#remainingClaimedTargets + 1] = row.claimedTargetIds[i] + end + end + if #remainingTargets == 0 then + redis.call('HDEL', KEYS[1], id) + redis.call('LREM', KEYS[2], 1, id) + removeMessageIndex(row) + else + row.targets = remainingTargets + if #remainingClaimedTargets > 0 then + row.status = 'claimed' + row.claimedTargetIds = remainingClaimedTargets + else + row.status = 'queued' + row.claimId = nil + row.claimedAt = nil + row.claimedTargetIds = nil + row.claimedFromTargetless = nil + end + row.processingStartedAt = nil + row.terminalAt = nil + if row.delivery then + row.delivery.steerRequestedAt = nil + if row.delivery.authorIntentByTarget then + local remainingIntent = {} + for i = 1, #remainingTargets do + local targetId = remainingTargets[i] + if row.delivery.authorIntentByTarget[targetId] then + remainingIntent[targetId] = row.delivery.authorIntentByTarget[targetId] + end + end + row.delivery.authorIntentByTarget = remainingIntent + end + end + redis.call('HSET', KEYS[1], id, encodeRow(row)) + end + return {1, encodeRow(attempted)} +end + +if mode ~= 'withdrawn' then + return redis.error_reply('QUEUE_COMMIT_INVALID_MODE') +end +if row.status ~= 'claimed' or row.claimId ~= claimId then return {0, raw} end +if replacementRaw and replacementRaw ~= '' then + local replacement = cjson.decode(replacementRaw) + if replacement.id ~= id or replacement.threadId ~= row.threadId then + return redis.error_reply('QUEUE_COMMIT_IDENTITY_MISMATCH') + end + row = replacement +end +row.status = 'terminal' +row.terminalAt = at +row.claimId = nil +row.claimedAt = nil +row.claimedTargetIds = nil +row.claimedFromTargetless = nil +local terminal = encodeRow(row) +redis.call('HDEL', KEYS[1], id) +redis.call('LREM', KEYS[2], 1, id) +removeMessageIndex(row) +return {1, terminal} +`; + +export const RESTORE_QUEUE_ROW_LUA = ` +local function encodeRow(value) + local encoded = cjson.encode(value) + encoded = string.gsub(encoded, '"targets":{}', '"targets":[]') + encoded = string.gsub(encoded, '"claimedTargetIds":{}', '"claimedTargetIds":[]') + return encoded +end +local raw = redis.call('HGET', KEYS[1], ARGV[1]) +if not raw then return {-1, ''} end +local row = cjson.decode(raw) +if row.status ~= 'claimed' or row.claimId ~= ARGV[2] then return {0, raw} end +row.status = 'queued' +row.claimId = nil +row.claimedAt = nil +row.delivery.steerRequestedAt = nil +if ARGV[3] == '1' or row.claimedFromTargetless then + row.targets = cjson.decode('[]') +end +row.claimedTargetIds = nil +row.claimedFromTargetless = nil +local next = encodeRow(row) +redis.call('HSET', KEYS[1], ARGV[1], next) +return {1, next} +`; diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-liveness-diagnostics.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-liveness-diagnostics.ts index c3a8e4e29c..beb949b2b7 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/queue-liveness-diagnostics.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-liveness-diagnostics.ts @@ -1,10 +1,8 @@ /** * Liveness policy for a Queue that is holding entries but running nothing. * - * Both halves answer the operator question "there are messages, why is nothing - * happening?" — one decides whether a restored entry may be parked, the other - * explains why a continuation attempt started nothing. They live together and - * outside QueueProcessor so this policy stays readable and testable on its own. + * Explain why a continuation attempt started nothing while the strict Queue + * head remains pending. */ export type ContinuationOutcome = 'no_dispatchable_candidate' | 'all_candidate_slots_busy' | 'start_rejected'; @@ -56,57 +54,3 @@ export function describeContinuationOutcome(input: { export function classifyContinuationOutcome(deferredForBusySlot: number): ContinuationOutcome { return deferredForBusySlot > 0 ? 'all_candidate_slots_busy' : 'no_dispatchable_candidate'; } - -/** - * Whether a restored entry may still be parked once the preview I/O has - * finished. - * - * Preparing the paused projection is real I/O, and a replacement owner can take - * the slot while it runs. Committing the pause afterwards would park a turn that - * has already moved on, so supersession must be re-checked after the await — - * not only before it. - */ -export function mayCommitPause(input: { - supersededByReplacement: boolean; - stillHasDispatchableQueued: boolean; -}): boolean { - return !input.supersededByReplacement && input.stillHasDispatchableQueued; -} - -export interface ParkRequeuedEntryPort { - hasDispatchableQueued(): boolean; - isSupersededByReplacement(invocationId: string): boolean; - prepareNotifications(): Promise; - commitPause(reason: 'canceled' | 'failed', notifications: readonly TNotification[]): void; -} - -/** - * Make a restored-but-not-restarted Queue entry observable. - * - * A user cancel deliberately does not restart the entry — an interrupt must not - * be silently undone — but skipping the pause bookkeeping too left the slot - * reporting idle while the entry sat in Queue. The pause vocabulary is coarser - * than the completion vocabulary, so a user cancel parks under `canceled`. - */ -export async function parkRequeuedEntryVisibly( - input: { - threadId: string; - catId: string; - status: 'failed' | 'canceled' | 'canceled_by_user'; - invocationId: string | undefined; - }, - port: ParkRequeuedEntryPort, -): Promise { - if (!port.hasDispatchableQueued()) return; - const reason = input.status === 'failed' ? 'failed' : 'canceled'; - const notifications = await port.prepareNotifications(); - if ( - !mayCommitPause({ - supersededByReplacement: input.invocationId !== undefined && port.isSupersededByReplacement(input.invocationId), - stillHasDispatchableQueued: port.hasDispatchableQueued(), - }) - ) { - return; - } - port.commitPause(reason, notifications); -} diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-prestart-group-retirement.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-prestart-group-retirement.ts index aa3cf8d022..b30e003638 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/queue-prestart-group-retirement.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-prestart-group-retirement.ts @@ -1,7 +1,5 @@ import type { IMessageStore } from '../../stores/ports/MessageStore.js'; -import type { QueuePrestartRetirementIntent } from '../../stores/ports/queued-message-custody.js'; -import type { QueueEntry } from './InvocationQueue.js'; -import type { QueuedMessageCustodyCoordinator } from './QueuedMessageCustodyCoordinator.js'; +import { type QueueEntry, queueEntryMessageIds, queueEntryOwnerId } from './InvocationQueue.js'; interface RetirementLogger { error(bindings: Record, message: string): void; @@ -10,7 +8,8 @@ interface RetirementLogger { export interface PrestartGroupRetirementDeps { finalizeSupplement(entry: QueueEntry): Promise; messageStore?: Pick; - queueCustodyCoordinator?: Pick; + shouldCancelMessage(entry: QueueEntry, messageId: string): boolean; + commitCarrier(entry: QueueEntry): Promise; emitMessageDeleted(userId: string, threadId: string, messageId: string): void; log: RetirementLogger; } @@ -35,12 +34,6 @@ export interface PreparedPrestartRetirement { interface ProcessingGroupPort { getProcessingGroupAcrossUsers(threadId: string, entryId: string): QueueEntry[] | null; - canRemoveProcessingGroupAcrossUsers(threadId: string, entryId: string): boolean; - removeProcessingGroupAcrossUsers(threadId: string, entryId: string): QueueEntry[] | null; -} - -function messageIds(entry: Pick): string[] { - return [entry.messageId, ...entry.mergedMessageIds].filter((messageId): messageId is string => !!messageId); } async function terminalizeMessages( @@ -49,13 +42,14 @@ async function terminalizeMessages( terminalizedMessageIds: Set, ): Promise { if (!deps.messageStore) return true; - for (const messageId of messageIds(entry)) { + for (const messageId of queueEntryMessageIds(entry)) { if (terminalizedMessageIds.has(messageId)) continue; + if (!deps.shouldCancelMessage(entry, messageId)) continue; try { const canceled = await deps.messageStore.markCanceled(messageId); terminalizedMessageIds.add(messageId); if (canceled?.deliveryTransitioned === true) { - deps.emitMessageDeleted(entry.userId, entry.threadId, messageId); + deps.emitMessageDeleted(queueEntryOwnerId(entry), entry.threadId, messageId); } } catch (err) { deps.log.error({ err, messageId, entryId: entry.id }, 'Failed to cancel superseded Queue message'); @@ -67,8 +61,8 @@ async function terminalizeMessages( /** * Terminalize one still-visible processing group in durable dependency order. - * Custody is last: if supplement or message truth cannot close, the durable - * Queue carrier remains available for retry/restart recovery. + * The ledger terminal commit is last: if supplement or message truth cannot + * close, the claimed Queue row remains available for deterministic recovery. */ export async function terminalizePrestartProcessingGroup( carriers: readonly QueueEntry[], @@ -79,9 +73,9 @@ export async function terminalizePrestartProcessingGroup( if (!(await deps.finalizeSupplement(carrier))) return false; if (!(await terminalizeMessages(carrier, deps, terminalizedMessageIds))) return false; try { - await deps.queueCustodyCoordinator?.withdrawEntry(carrier); + if (!(await deps.commitCarrier(carrier))) throw new Error('ledger state changed'); } catch (err) { - deps.log.error({ err, entryId: carrier.id }, 'Failed to withdraw superseded Queue custody'); + deps.log.error({ err, entryId: carrier.id }, 'Failed to withdraw superseded Queue entry'); return false; } } @@ -93,47 +87,7 @@ export async function terminalizePreparedPrestartRetirements( deps: PrestartGroupRetirementDeps, ): Promise { for (const retirement of retirements) { - const existingIntent = retirement.carriers.find((carrier) => carrier.prestartRetirement)?.prestartRetirement; - const intent: QueuePrestartRetirementIntent = existingIntent ?? { - id: `prestart-retirement:${retirement.threadId}:${retirement.entryId}:${retirement.barrier.startedAt}`, - primaryEntryId: retirement.entryId, - entryIds: retirement.carriers.map((carrier) => carrier.id), - targetCatId: retirement.targetCatId, - startedAt: retirement.barrier.startedAt, - }; - if ( - retirement.carriers.some( - (carrier) => - carrier.prestartRetirement !== undefined && - JSON.stringify(carrier.prestartRetirement) !== JSON.stringify(intent), - ) - ) { - deps.log.error( - { threadId: retirement.threadId, entryId: retirement.entryId }, - 'Refused divergent pre-start retirement intent', - ); - return false; - } - - const carriers = retirement.carriers.map((carrier) => ({ - ...carrier, - prestartRetirement: { ...intent, entryIds: [...intent.entryIds] }, - })); - const anchor = carriers.at(-1); - const persistenceOrder = anchor ? [anchor, ...carriers.slice(0, -1)] : []; - try { - for (const carrier of persistenceOrder) { - await deps.queueCustodyCoordinator?.persistEntry(carrier); - } - } catch (err) { - deps.log.error( - { err, threadId: retirement.threadId, entryId: retirement.entryId, retirementIntentId: intent.id }, - 'Failed to persist pre-start retirement intent', - ); - return false; - } - - if (!(await terminalizePrestartProcessingGroup(carriers, deps))) return false; + if (!(await terminalizePrestartProcessingGroup(retirement.carriers, deps))) return false; } return true; } @@ -191,14 +145,7 @@ export function commitPreparedPrestartRetirements(input: { }): boolean { for (const retirement of input.retirements) { if (input.slots.get(retirement.key) !== retirement.barrier) return false; - if (!input.queue.canRemoveProcessingGroupAcrossUsers(retirement.threadId, retirement.entryId)) { - return false; - } - } - for (const retirement of input.retirements) { - if (!input.queue.removeProcessingGroupAcrossUsers(retirement.threadId, retirement.entryId)) { - return false; - } + if (input.queue.getProcessingGroupAcrossUsers(retirement.threadId, retirement.entryId)) return false; } for (const retirement of input.retirements) { if (input.slots.get(retirement.key) === retirement.barrier) input.slots.delete(retirement.key); diff --git a/packages/api/src/domains/cats/services/agents/invocation/reconcileZombies.ts b/packages/api/src/domains/cats/services/agents/invocation/reconcileZombies.ts index 54ea8909e5..59211b17c3 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/reconcileZombies.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/reconcileZombies.ts @@ -20,14 +20,66 @@ import type { CatId } from '@cat-cafe/shared'; import type { IBallCustodyIngest } from '../../../../ball-custody/BallCustodyIngest.js'; import { buildInvocationDiedEvent } from '../../../../ball-custody/ball-custody-events.js'; import type { IInvocationRecordStore } from '../../stores/ports/InvocationRecordStore.js'; -import { - convergeZombieQueueEntry, - type QueueConvergedHandler, - type ZombieQueueConverger, -} from './convergeZombieQueue.js'; import type { ZombieRecord } from './getThreadLiveInvocations.js'; +import type { QueueEntry } from './InvocationQueue.js'; import type { TaskProgressStore } from './TaskProgressStore.js'; +interface ZombieQueueConverger { + list(threadId: string, userId: string): QueueEntry[]; + removeProcessedAcrossUsersDurable( + threadId: string, + entryId: string, + terminalOutcome: 'interrupted', + failureReason: 'runtime_restart', + ): Promise; +} + +type QueueConvergedHandler = (info: { threadId: string; userId: string; removedEntryIds: string[] }) => void; + +async function convergeZombieQueueEntry( + queue: ZombieQueueConverger | undefined, + record: { threadId: string; userId: string; userMessageId?: string | null }, + zombie: Pick, + log: NonNullable, + onQueueConverged: QueueConvergedHandler | undefined, +): Promise<{ converged: number; errors: number }> { + if (!queue || !record.userMessageId) return { converged: 0, errors: 0 }; + const messageId = record.userMessageId; + try { + const removedEntryIds: string[] = []; + const stale = queue + .list(record.threadId, record.userId) + .filter( + (entry) => + (entry.status === 'claimed' || entry.status === 'processing') && entry.payload.messageId === messageId, + ); + for (const entry of stale) { + const removed = await queue.removeProcessedAcrossUsersDurable( + record.threadId, + entry.id, + 'interrupted', + 'runtime_restart', + ); + if (!removed) continue; + removedEntryIds.push(entry.id); + log.info( + { invocationId: zombie.invocationId, entryId: entry.id, messageId, reason: zombie.reason }, + '[reconcile-zombies] terminalized stale processing queue entry', + ); + } + if (removedEntryIds.length > 0) { + onQueueConverged?.({ threadId: record.threadId, userId: record.userId, removedEntryIds }); + } + return { converged: removedEntryIds.length, errors: 0 }; + } catch (err) { + log.warn( + { invocationId: zombie.invocationId, err: err instanceof Error ? err.message : String(err) }, + '[reconcile-zombies] failed to terminalize queue entry', + ); + return { converged: 0, errors: 1 }; + } +} + export interface ReconcileZombieDeps { invocationRecordStore: IInvocationRecordStore; /** Optional — if absent, TaskProgress is not cleared (test or embedded mode). */ @@ -181,7 +233,7 @@ async function processZombie( // reconcile may have flipped the record terminal but died before converging the // queue, and future sweeps only enumerate *running* records, so this entry would // pin the head forever. removeProcessed is idempotent, so retrying is safe. - const qc = convergeZombieQueueEntry(deps.invocationQueue, current, zombie, log, deps.onQueueConverged); + const qc = await convergeZombieQueueEntry(deps.invocationQueue, current, zombie, log, deps.onQueueConverged); log.info( { invocationId: zombie.invocationId, currentStatus: current.status, reason: zombie.reason }, '[reconcile-zombies] skipped (already terminal); re-attempted TaskProgress cleanup', @@ -242,7 +294,7 @@ async function processZombie( ); // #972: the record is now terminal, so its `processing` queue entry is a corpse // pinning the queue head. Converge it or later user work never runs. - const qc = convergeZombieQueueEntry(deps.invocationQueue, updated, zombie, log, deps.onQueueConverged); + const qc = await convergeZombieQueueEntry(deps.invocationQueue, updated, zombie, log, deps.onQueueConverged); let terminalRecoveryErrors = 0; if (deps.onReconciledZombie) { try { diff --git a/packages/api/src/domains/cats/services/agents/invocation/thread-execution-situation.ts b/packages/api/src/domains/cats/services/agents/invocation/thread-execution-situation.ts new file mode 100644 index 0000000000..1799cfc702 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/invocation/thread-execution-situation.ts @@ -0,0 +1,91 @@ +import { hasExactLifecycleProcessingDispatch, type LifecycleActiveRun, type MessageFrom } from '@cat-cafe/shared'; +import { messageFrom } from '../../stores/message-from.js'; +import type { IMessageStore } from '../../stores/ports/MessageStore.js'; + +export interface ThreadExecutionSituationSourceMessage { + readonly messageId: string; + readonly from: MessageFrom; +} + +export interface ThreadExecutionSituationRun { + readonly phase: 'processing'; + readonly targetId: string; + readonly invocationId: string; + readonly responseMessageId: string; + readonly startedAt: number; + readonly sources: readonly ThreadExecutionSituationSourceMessage[]; +} + +export interface ThreadExecutionSituation { + readonly kind: 'thread_execution_situation.v1'; + /** False means at least one runtime witness could not be joined to exact lifecycle evidence. */ + readonly complete: boolean; + readonly activeRuns: readonly ThreadExecutionSituationRun[]; +} + +export interface ThreadExecutionSituationSource { + resolve(threadId: string): Promise; +} + +export function createThreadExecutionSituationSource(input: { + readonly messageStore: Pick; + readonly listActiveRuns: (threadId: string) => readonly LifecycleActiveRun[]; +}): ThreadExecutionSituationSource { + return { + async resolve(threadId) { + let activeRuns: readonly LifecycleActiveRun[]; + try { + activeRuns = input.listActiveRuns(threadId).filter((run) => run.threadId === threadId); + } catch { + return { kind: 'thread_execution_situation.v1', complete: false, activeRuns: [] }; + } + + let complete = true; + const projected: ThreadExecutionSituationRun[] = []; + for (const run of activeRuns) { + // Private-only work has no public History anchor and therefore no A79 + // situation claim. It remains an internal execution, just as the UI + // intentionally renders no delivery avatar for it. + if (run.inputMessageIds.length === 0) continue; + try { + const response = await input.messageStore.getById(run.responseMessageId); + const sourceMessages = await Promise.all( + run.inputMessageIds.map((messageId) => input.messageStore.getById(messageId)), + ); + const sources = sourceMessages.flatMap((source) => { + if ( + !source || + source.threadId !== threadId || + !hasExactLifecycleProcessingDispatch({ + sourceMessageId: source.id, + sourceDispatchRefs: source.lifecycle?.dispatchRefs ?? [], + responseMessageId: run.responseMessageId, + responseLifecycle: response?.lifecycle, + activeRuns, + }) + ) { + return []; + } + return [{ messageId: source.id, from: messageFrom(source) }]; + }); + if (!response || response.threadId !== threadId || sources.length !== run.inputMessageIds.length) { + complete = false; + continue; + } + projected.push({ + phase: 'processing', + targetId: run.targetId, + invocationId: run.invocationId, + responseMessageId: run.responseMessageId, + startedAt: run.startedAt, + sources, + }); + } catch { + complete = false; + } + } + projected.sort((left, right) => left.startedAt - right.startedAt || left.targetId.localeCompare(right.targetId)); + return { kind: 'thread_execution_situation.v1', complete, activeRuns: projected }; + }, + }; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts index e4c31d90a8..d98a388bb9 100644 --- a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts @@ -20,16 +20,7 @@ import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { type CatId, type CliEffortPreset, createCatId, resolveCliEffortOverride } from '@cat-cafe/shared'; -import { - CAT_CAFE_SPLIT_ENTRYPOINTS, - expandManagedMcpNamesForUserMerge, - MCP_CALLBACK_ENV_KEYS, - resolveCatCafeNodeCommand, - resolvePencilCommand, - resolveServersForCat, - summarizeMcpInjection, -} from '../../../../../config/capabilities/capability-orchestrator.js'; -import { isRetiredGithubMcpConfigEntry } from '../../../../../config/capabilities/retired-github-mcp.js'; +import { summarizeMcpInjection } from '../../../../../config/capabilities/capability-orchestrator.js'; import { getCatEffort } from '../../../../../config/cat-config-loader.js'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; @@ -54,6 +45,7 @@ import { sanitizeRawEvent } from '../providers/codex-audit-hooks.js'; import { appendLocalImagePathHints, collectImageAccessDirectories } from '../providers/image-cli-bridge.js'; import { extractImagePaths } from '../providers/image-paths.js'; import { findGitBashPath } from './claude-agent-win.js'; +import { resolveClaudeMcpConfig } from './claude-mcp-config.js'; import { ClaudeNativeToolBoundaryClassifier } from './claude-native-tool-boundary.js'; import { extractClaudeUsage, isResultErrorEvent, transformClaudeEvent } from './claude-ndjson-parser.js'; import { compileL0ViaSubprocess } from './l0-compiler.js'; @@ -211,16 +203,6 @@ function removeAppendPromptTempDir(path: string | undefined): void { } } -function resolveMcpWorkspaceRoot(workingDirectory?: string): string { - const explicitAllowed = process.env.ALLOWED_WORKSPACE_DIRS?.trim(); - if (explicitAllowed) return explicitAllowed; - const threadWorkspace = workingDirectory?.trim(); - if (threadWorkspace) return resolve(threadWorkspace); - const explicitWorkspace = process.env.CAT_CAFE_WORKSPACE_ROOT?.trim(); - if (explicitWorkspace) return explicitWorkspace; - return process.cwd(); -} - /** * Build env overrides for spawning the `claude` CLI. * @@ -359,7 +341,12 @@ export class ClaudeAgentService implements AgentService { } freshnessCarrierCapability(): AgentFreshnessCarrierCapability { - return { provider: 'anthropic', carrier: 'claude_print_sdk', deliverySemantics: 'unsupported' }; + return { + provider: 'anthropic', + carrier: 'claude_print_sdk', + deliverySemantics: 'unsupported', + activeInvocationGuidance: 'unsupported', + }; } contextCapability(): import('../../types.js').AgentContextCapability { @@ -455,142 +442,38 @@ export class ClaudeAgentService implements AgentService { // #712: Inject ALL enabled MCP servers from capabilities.json at invoke time. // Built-in cat-cafe servers resolve paths from distDir; externals use descriptor values. // On Windows, Claude CLI treats inline JSON as a file path — write to temp file. - if (!readOnly && options?.callbackEnv && this.mcpServerPath) { - const distDir = dirname(this.mcpServerPath); - const binaryProjectRoot = resolve(distDir, '../../..'); - const capabilitiesProjectRoot = binaryProjectRoot; - const catId = options.callbackEnv.CAT_CAFE_CAT_ID; - - const catCafeEnvEntries: Record = { - ALLOWED_WORKSPACE_DIRS: resolveMcpWorkspaceRoot(options.workingDirectory), - }; - for (const key of MCP_CALLBACK_ENV_KEYS) { - const val = options.callbackEnv![key]; - if (val) catCafeEnvEntries[key] = val; - } - - const mcpServers: Record> = {}; - const managedMcpServerNames = new Set(); - let resolved = false; - try { - // F249: Project config is the single truth source for MCP resolution. - // Try project first; fall back to global for uninitialized projects. - let capConfig = null; - let accessScope: 'global' | 'project' = 'global'; - if (options?.workingDirectory && options.workingDirectory !== capabilitiesProjectRoot) { - try { - const projectRaw = readFileSync(join(options.workingDirectory, '.cat-cafe', 'capabilities.json'), 'utf-8'); - const parsed = JSON.parse(projectRaw); - if (parsed?.version === 1 || parsed?.version === 2) { - capConfig = parsed; - accessScope = 'project'; - } - } catch { - /* No project config — fall back to global */ - } - } - if (!capConfig) { - const raw = readFileSync(join(capabilitiesProjectRoot, '.cat-cafe', 'capabilities.json'), 'utf-8'); - const parsed = JSON.parse(raw); - if (parsed?.version === 1 || parsed?.version === 2) capConfig = parsed; - } - if (capConfig && catId) { - for (const s of resolveServersForCat(capConfig, catId, { accessScope })) { - managedMcpServerNames.add(s.name); - if (!s.enabled) continue; - if (s.source === 'cat-cafe' && CAT_CAFE_SPLIT_ENTRYPOINTS.has(s.name)) { - const ep = CAT_CAFE_SPLIT_ENTRYPOINTS.get(s.name)!; - const epPath = join(distDir, ep); - if (existsSync(epPath)) { - mcpServers[s.name] = { - command: resolveCatCafeNodeCommand(), - args: [epPath], - env: catCafeEnvEntries, - }; - } - } else if (s.resolver === 'pencil') { - const pencil = await resolvePencilCommand({ projectRoot: capabilitiesProjectRoot }); - if (pencil) mcpServers[s.name] = { command: pencil.command, args: pencil.args }; - } else if (s.transport === 'streamableHttp' && s.url) { - const entry: Record = { type: 'http', url: s.url }; - if (s.headers && Object.keys(s.headers).length > 0) entry.headers = s.headers; - mcpServers[s.name] = entry; - } else if (s.command) { - const entry: Record = { command: s.command, args: s.args }; - if (s.env && Object.keys(s.env).length > 0) entry.env = s.env; - if (s.workingDir) entry.cwd = s.workingDir; - mcpServers[s.name] = entry; - } - } - resolved = true; - } - } catch { - // best-effort fallback below - } - if (!resolved) { - for (const [name, ep] of CAT_CAFE_SPLIT_ENTRYPOINTS) { - const epPath = join(distDir, ep); - if (existsSync(epPath)) { - mcpServers[name] = { - command: resolveCatCafeNodeCommand(), - args: [epPath], - env: catCafeEnvEntries, - }; - } - } - } - // Merge user project .mcp.json: include user-owned servers (e.g. - // `filesystem`) that are NOT managed by capabilities.json. Our managed - // entries always take precedence — stale user copies are ignored. - // --strict-mcp-config still applies: only the merged set is active. - if (options?.workingDirectory) { - try { - const userMcpPath = join(options.workingDirectory, '.mcp.json'); - if (existsSync(userMcpPath)) { - const userMcp = JSON.parse(readFileSync(userMcpPath, 'utf-8')) as { - mcpServers?: Record; - }; - if (userMcp.mcpServers && typeof userMcp.mcpServers === 'object') { - const excludedMcpServerNames = expandManagedMcpNamesForUserMerge([ - ...managedMcpServerNames, - ...Object.keys(mcpServers), - ]); - for (const [name, entry] of Object.entries(userMcp.mcpServers)) { - if (isRetiredGithubMcpConfigEntry(name, entry)) continue; - if (!excludedMcpServerNames.has(name) && !(name in mcpServers) && entry && typeof entry === 'object') { - mcpServers[name] = entry as Record; - } - } - } + if (!readOnly) { + const mcpResolution = await resolveClaudeMcpConfig({ + callbackEnv: options?.callbackEnv, + workingDirectory: options?.workingDirectory, + mcpServerPath: this.mcpServerPath, + }); + if (mcpResolution) { + const mcpServers = mcpResolution.servers; + log.debug( + summarizeMcpInjection(mcpServers, { + catId: options?.callbackEnv?.CAT_CAFE_CAT_ID, + resolvedFrom: mcpResolution.source, + provider: 'claude', + }), + '#712: MCP invoke-time injection', + ); + // #712: Always pass --mcp-config + --strict-mcp-config in managed invocations. + // --strict-mcp-config ensures only the merged config (capabilities.json + + // user project .mcp.json) is active — no auto-discovered entries leak through. + if (IS_WINDOWS) { + if (!this.mcpConfigFilePath) { + const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-')); + this.mcpConfigFilePath = join(dir, 'mcp-config.json'); } - } catch { - // best-effort: unreadable user config → capabilities-only - } - } - - log.debug( - summarizeMcpInjection(mcpServers, { - catId, - resolvedFrom: resolved ? 'capabilities.json' : 'fallback', - provider: 'claude', - }), - '#712: MCP invoke-time injection', - ); - // #712: Always pass --mcp-config + --strict-mcp-config in managed invocations. - // --strict-mcp-config ensures only the merged config (capabilities.json + - // user project .mcp.json) is active — no auto-discovered entries leak through. - if (IS_WINDOWS) { - if (!this.mcpConfigFilePath) { - const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-')); - this.mcpConfigFilePath = join(dir, 'mcp-config.json'); + writeFileSync(this.mcpConfigFilePath, JSON.stringify({ mcpServers }), 'utf-8'); + args.push('--mcp-config', this.mcpConfigFilePath); + } else { + args.push('--mcp-config', JSON.stringify({ mcpServers })); } - writeFileSync(this.mcpConfigFilePath, JSON.stringify({ mcpServers }), 'utf-8'); - args.push('--mcp-config', this.mcpConfigFilePath); - } else { - args.push('--mcp-config', JSON.stringify({ mcpServers })); + args.push('--strict-mcp-config'); + declaredMcpServerNames = Object.keys(mcpServers).sort(); } - args.push('--strict-mcp-config'); - declaredMcpServerNames = Object.keys(mcpServers).sort(); } const metadata: MessageMetadata = { provider: 'anthropic', model: effectiveModel }; diff --git a/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts b/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts index d0bc9608f6..3fefa79ea0 100644 --- a/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts @@ -44,6 +44,7 @@ import { CAT_CLI_PROCESS_CONTEXT, CLI_PROCESS_CONTEXT_ENV } from '../../../../.. import { CLI_PROCESS_OWNER_ENV } from '../../../../../utils/cli-process-ownership.js'; import { resolveCliCommandOrBare } from '../../../../../utils/cli-resolve.js'; import { buildChildEnv } from '../../../../../utils/cli-spawn.js'; +import { excerptSanitizedStderr } from '../../../../../utils/sanitize-cli-stderr.js'; import type { AgentMessage, AgentService, @@ -564,7 +565,8 @@ export class ClaudeBgCarrierService implements AgentService { if (code !== 0) { // A dispatcher may have persisted a job before exiting nonzero. No // short id means no safe stop target, so retain the pending record. - return finish(new CarrierError(`claude --bg exited code=${code}: ${stderr.slice(0, 300)}`)); + const excerpt = excerptSanitizedStderr(stderr, { edge: 'head', maxLength: 300 }); + return finish(new CarrierError(`claude --bg exited code=${code}: ${excerpt}`)); } const match = SHORT_ID_PATTERN.exec(stdout); if (!match) { diff --git a/packages/api/src/domains/cats/services/agents/providers/ClaudeSdkAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/ClaudeSdkAgentService.ts new file mode 100644 index 0000000000..5dd87d76c3 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeSdkAgentService.ts @@ -0,0 +1,340 @@ +import { randomUUID } from 'node:crypto'; +import { isAbsolute, resolve } from 'node:path'; +import { + type Options as ClaudeSdkOptions, + query as claudeQuery, + type EffortLevel, + type McpServerConfig, + type Query, + type SDKMessage, +} from '@anthropic-ai/claude-agent-sdk'; +import { type CatId, createCatId } from '@cat-cafe/shared'; +import { getCatModel } from '../../../../../config/cat-models.js'; +import { createModuleLogger } from '../../../../../infrastructure/logger.js'; +import { sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; +import type { + AgentClientActiveRunHandle, + AgentFreshnessCarrierCapability, + AgentMessage, + AgentService, + AgentServiceOptions, + MessageMetadata, + PreparedProviderRequestV1, + ToolExecutionPolicy, +} from '../../types.js'; +import { + ANTHROPIC_PROFILE_MODE_KEY, + buildClaudeEnvOverrides, + resolveClaudeEffortLevel, + resolveClaudeModelSelection, + resolveDefaultClaudeMcpServerPath, + SUBSCRIPTION_MODE_DENY_KEYS, +} from './ClaudeAgentService.js'; +import { resolveClaudeMcpConfig } from './claude-mcp-config.js'; +import { extractClaudeUsage, transformClaudeEvent } from './claude-ndjson-parser.js'; +import { ClaudeSdkTurnInputState, createSdkUserMessage } from './claude-sdk-turn-input-state.js'; +import { appendLocalImagePathHints, collectImageAccessDirectories } from './image-cli-bridge.js'; +import { extractImagePaths } from './image-paths.js'; +import { compileL0ViaSubprocess } from './l0-compiler.js'; + +const log = createModuleLogger('claude-sdk-agent'); + +type ClaudeQueryPrompt = Parameters[0]['prompt']; +type ClaudeQueryFn = (params: { prompt: ClaudeQueryPrompt; options?: ClaudeSdkOptions }) => Query; + +interface ClaudeSdkAgentServiceOptions { + catId?: CatId; + model?: string; + mcpServerPath?: string; + l0CompilerFn?: typeof compileL0ViaSubprocess; + queryFn?: ClaudeQueryFn; + activeRunControlTimeoutMs?: number; +} + +const DEFAULT_ACTIVE_RUN_CONTROL_TIMEOUT_MS = 15_000; +const MAX_SDK_STDERR_CHARS = 4_000; + +function toClaudeSdkEffortLevel(effort: string): EffortLevel { + if (effort === 'low' || effort === 'medium' || effort === 'high' || effort === 'xhigh' || effort === 'max') { + return effort; + } + throw new Error(`claude_sdk_effort_unsupported:${effort}`); +} + +async function withActiveRunControlDeadline(operation: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('claude_sdk_active_run_control_timeout')), timeoutMs); + timer.unref(); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function toSdkEnvironment(overrides: Record): Record { + const env: Record = { ...process.env }; + for (const [key, value] of Object.entries(overrides)) { + if (value === null) delete env[key]; + else env[key] = value; + } + return env; +} + +/** + * Claude's official Agent SDK carrier. Unlike `claude -p`, the SDK exposes a + * streaming input channel and an explicit interrupt operation, so Append and + * Steer are delivered to the exact active query without involving MCP tools. + */ +export class ClaudeSdkAgentService implements AgentService { + readonly catId: CatId; + private readonly model: string; + private readonly mcpServerPath: string | undefined; + private readonly l0CompilerFn: typeof compileL0ViaSubprocess; + private readonly queryFn: ClaudeQueryFn; + private readonly activeRunControlTimeoutMs: number; + + constructor(options?: ClaudeSdkAgentServiceOptions) { + this.catId = options?.catId ?? createCatId('opus'); + this.model = options?.model ?? getCatModel(this.catId as string); + this.l0CompilerFn = options?.l0CompilerFn ?? compileL0ViaSubprocess; + this.queryFn = options?.queryFn ?? claudeQuery; + this.activeRunControlTimeoutMs = options?.activeRunControlTimeoutMs ?? DEFAULT_ACTIVE_RUN_CONTROL_TIMEOUT_MS; + const configuredPath = options?.mcpServerPath ?? process.env.CAT_CAFE_MCP_SERVER_PATH; + this.mcpServerPath = configuredPath + ? isAbsolute(configuredPath) + ? configuredPath + : resolve(process.cwd(), configuredPath) + : resolveDefaultClaudeMcpServerPath(); + } + + injectsL0Natively(): boolean { + return true; + } + + supportsToolExecutionPolicy(policy: ToolExecutionPolicy): boolean { + return policy.mode === 'read_only'; + } + + freshnessCarrierCapability(): AgentFreshnessCarrierCapability { + return { + provider: 'anthropic', + carrier: 'claude_agent_sdk', + deliverySemantics: 'queued_internal_turn', + activeInvocationGuidance: 'supported', + }; + } + + contextCapability(): import('../../types.js').AgentContextCapability { + return { + provider: 'anthropic', + carrier: 'agent_sdk', + reportsRuntimeWindow: true, + authoritativeUsage: true, + usageTelemetry: 'available', + nativeWindowControl: false, + nativeCompressionControl: false, + observesCompression: true, + reason: 'Claude Agent SDK streams native usage and compact-boundary events', + }; + } + + async *invoke(prompt: string, options?: AgentServiceOptions): AsyncIterable { + const readOnly = options?.toolExecutionPolicy?.mode === 'read_only'; + const imagePaths = extractImagePaths(options?.contentBlocks, options?.uploadDir); + const effectivePrompt = appendLocalImagePathHints(prompt, imagePaths); + const { effectiveModel, useEnvModelOverride } = resolveClaudeModelSelection(options?.callbackEnv, this.model); + const effort = resolveClaudeEffortLevel(this.catId as string, effectiveModel, options?.reasoningEffortOverride); + const l0 = await this.l0CompilerFn({ catId: this.catId as string, userId: options?.auditContext?.userId }); + const nativeInstructions: PreparedProviderRequestV1['nativeInstructions'] = [ + { body: l0, injectionDecision: 'native_l0_compiled' }, + ...(options?.systemPrompt + ? [{ body: options.systemPrompt, injectionDecision: 'route_append_system_prompt' }] + : []), + ]; + const systemPrompt = nativeInstructions.map((entry) => entry.body).join('\n\n'); + const mcpResolution = readOnly + ? undefined + : await resolveClaudeMcpConfig({ + callbackEnv: options?.callbackEnv, + workingDirectory: options?.workingDirectory, + mcpServerPath: this.mcpServerPath, + }); + const declaredServerNames = Object.keys(mcpResolution?.servers ?? {}); + const preparedRequest: PreparedProviderRequestV1 = Object.freeze({ + v: 1, + message: Object.freeze({ body: effectivePrompt }), + nativeInstructions: Object.freeze(nativeInstructions.map((entry) => Object.freeze(entry))), + runtime: Object.freeze({ + provider: 'anthropic', + carrier: 'agent_sdk', + ...(effectiveModel ? { model: effectiveModel } : {}), + protocol: 'sdk-streaming-input', + reasoningEffort: effort, + ...(readOnly ? { toolExecutionPolicy: 'read_only' as const } : {}), + }), + tools: Object.freeze({ + finalSurface: readOnly ? ('exact' as const) : ('declared_only' as const), + declaredServerNames: Object.freeze(readOnly ? [] : declaredServerNames), + ...(readOnly ? { catCafeSchemas: Object.freeze([]) } : {}), + }), + providerNativeVisibility: 'unknown', + }); + await options?.beforeProviderLaunch?.(preparedRequest); + if (!('body' in preparedRequest.message)) throw new Error('claude_sdk_prepared_message_not_exact'); + + const envOverrides = buildClaudeEnvOverrides(options?.callbackEnv); + if (options?.accountEnv) { + for (const [key, value] of Object.entries(options.accountEnv)) envOverrides[key] = value; + } + if (options?.callbackEnv?.[ANTHROPIC_PROFILE_MODE_KEY] === 'subscription') { + for (const key of SUBSCRIPTION_MODE_DENY_KEYS) envOverrides[key] = null; + } + if (readOnly) envOverrides.CAT_CAFE_READONLY = 'true'; + const abortController = new AbortController(); + const abort = () => abortController.abort(options?.signal?.reason); + options?.signal?.addEventListener('abort', abort, { once: true }); + if (options?.signal?.aborted) abort(); + + const turnInputs = new ClaudeSdkTurnInputState(); + let stderrBuffer = ''; + let activeSessionId = options?.sessionId ?? ''; + const initialMessageId = randomUUID(); + const sdkOptions: ClaudeSdkOptions = { + abortController, + ...(options?.workingDirectory ? { cwd: options.workingDirectory } : {}), + env: toSdkEnvironment(envOverrides), + ...(imagePaths.length > 0 ? { additionalDirectories: collectImageAccessDirectories(imagePaths) } : {}), + ...(useEnvModelOverride || !effectiveModel ? {} : { model: effectiveModel }), + effort: toClaudeSdkEffortLevel(effort), + systemPrompt, + includePartialMessages: true, + permissionMode: readOnly ? 'plan' : 'bypassPermissions', + ...(readOnly ? { tools: [] } : {}), + ...(!readOnly ? { allowDangerouslySkipPermissions: true } : {}), + settingSources: + options?.callbackEnv?.[ANTHROPIC_PROFILE_MODE_KEY] === 'api_key' + ? ['project', 'local'] + : ['user', 'project', 'local'], + ...(options?.sessionId ? { resume: options.sessionId } : {}), + ...(readOnly ? { mcpServers: {}, strictMcpConfig: true } : {}), + ...(!readOnly && mcpResolution + ? { mcpServers: mcpResolution.servers as Record, strictMcpConfig: true } + : {}), + stderr: (data) => { + // Sanitize before bounding the retained window. Truncating raw stderr + // first can cut a provider-token prefix at the boundary and leave the + // secret suffix looking harmless to the later sanitizer. + stderrBuffer = sanitizeCliStderr(`${stderrBuffer}${data}`).slice(-MAX_SDK_STDERR_CHARS); + }, + }; + + const metadata: MessageMetadata = { provider: 'anthropic', model: effectiveModel }; + const streamState = { + currentMessageId: undefined as string | undefined, + partialTextMessageIds: new Set(), + lastTurnInputTokens: undefined as number | undefined, + thinkingBuffer: '', + }; + let query: Query | undefined; + let releaseDispatch: (() => void) | undefined; + let dispatcherRegistered = false; + + const registerDispatcher = () => { + if (dispatcherRegistered || !options?.activeRunDispatch || !options.invocationId || !activeSessionId) return; + dispatcherRegistered = true; + const invocationId = options.activeRunDispatch.invocationId; + const handle: AgentClientActiveRunHandle = { + provider: 'anthropic', + carrier: 'claude_agent_sdk', + threadId: activeSessionId, + turnId: initialMessageId, + }; + const release = options.activeRunDispatch.register({ + invocationId, + capabilities: { append: true, steer: true }, + handle, + dispatch: async (dispatchInput, dispatchOptions) => { + if (dispatchOptions.expectedInvocationId !== invocationId) { + return { accepted: false, reason: 'active_run_mismatch' }; + } + if (!turnInputs.isAccepting || !query) return { accepted: false, reason: 'active_run_closed' }; + const text = appendLocalImagePathHints(dispatchInput.text.trim(), dispatchInput.imagePaths ?? []); + if (!text) return { accepted: false, reason: 'invalid_input' }; + turnInputs.beginDispatch(); + try { + if (dispatchOptions.force) { + await withActiveRunControlDeadline(query.interrupt(), this.activeRunControlTimeoutMs); + } + const accepted = turnInputs.push(createSdkUserMessage(text, activeSessionId)); + return accepted ? { accepted: true, handle } : { accepted: false, reason: 'active_run_closed' }; + } catch (err) { + log.warn({ err, invocationId }, 'Claude SDK active-run dispatch rejected'); + return { accepted: false, reason: 'provider_rejected' }; + } finally { + turnInputs.finishDispatch(); + } + }, + }); + if (typeof release === 'function') releaseDispatch = release; + }; + + try { + query = this.queryFn({ prompt: turnInputs.input, options: sdkOptions }); + turnInputs.push(createSdkUserMessage(preparedRequest.message.body, activeSessionId, initialMessageId)); + for await (const event of query as AsyncIterable) { + const raw = event as unknown as Record; + const isResultTerminal = raw.type === 'result'; + if (typeof raw.session_id === 'string' && raw.session_id) { + activeSessionId = raw.session_id; + metadata.sessionId = raw.session_id; + registerDispatcher(); + } + if (isResultTerminal) { + // The SDK may coalesce several queued sends into one provider turn. + // Its result echoes every consumed user-message uuid, so settle the + // accepted messages by identity instead of assuming one result per + // input. Interrupt receipts are ordered before the interrupted turn's + // result on the clean path; a crash may reverse those two, and the + // identity join remains correct in either order. queued_turn_count + // provides a provider-authored backstop when a result lacks an input + // identity (including an interrupted-turn compatibility edge). + turnInputs.settleResult(raw); + metadata.usage = extractClaudeUsage(raw); + if (streamState.lastTurnInputTokens != null && metadata.usage) { + metadata.usage.lastTurnInputTokens = streamState.lastTurnInputTokens; + } + } + const transformed = transformClaudeEvent(event, this.catId, streamState); + if (transformed) { + for (const message of Array.isArray(transformed) ? transformed : [transformed]) { + yield { ...message, metadata }; + } + } + // A streaming-input query may carry multiple accepted turns. Close at + // the result for the final accepted input, but keep consuming when an + // Append/Steer already promised another result to the caller. Breaking + // performs AsyncIteratorClose (Query.return), terminating the SDK query. + if (isResultTerminal && !turnInputs.isAccepting) break; + } + } catch (err) { + if (!abortController.signal.aborted) { + const stderr = sanitizeCliStderr(stderrBuffer).trim(); + yield { + type: 'error', + catId: this.catId, + error: stderr || (err instanceof Error ? err.message : String(err)), + metadata, + timestamp: Date.now(), + }; + } + } finally { + turnInputs.close(); + releaseDispatch?.(); + options?.signal?.removeEventListener('abort', abort); + } + yield { type: 'done', catId: this.catId, metadata, timestamp: Date.now() }; + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts index c3cbfa6b9a..618acaa8c6 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -17,8 +17,8 @@ */ import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { dirname, isAbsolute, join, parse, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -46,13 +46,12 @@ import { getCatModel } from '../../../../../config/cat-models.js'; import { type CodexCarrierMode, getCodexApprovalPolicy, - getCodexCarrierMode, getCodexOAuthTransport, getCodexSandboxMode, } from '../../../../../config/codex-cli.js'; import { estimateCostFromTokens } from '../../../../../config/model-pricing.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { buildActiveWriterRecoveryDiagnostic, buildCliDiagnostics } from '../../../../../utils/cli-diagnostics.js'; +import { buildCliDiagnostics } from '../../../../../utils/cli-diagnostics.js'; import { formatCliExitError } from '../../../../../utils/cli-format.js'; import { CLI_EXECUTION_ID_ENV, CLI_EXECUTION_OWNER_BINDING_ENV } from '../../../../../utils/cli-process-ownership.js'; import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; @@ -69,7 +68,6 @@ import type { SpawnFn } from '../../../../../utils/cli-types.js'; import { findMonorepoRoot } from '../../../../../utils/monorepo-root.js'; import { sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; import { AuditEventTypes, getEventAuditLog } from '../../orchestration/EventAuditLog.js'; -import { CodexActiveWriterRecoveryError } from '../../runtime-session/CodexSessionReplacementProvenance.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; import type { AgentCarrierSession, @@ -171,6 +169,33 @@ interface CodexProviderRecoveryTracker { lastAttempt?: number; } +const ACTIVE_WRITER_WAIT_NOTICE_MS = 10_000; + +export function buildCodexActiveWriterWaitSignal(input: { + catId: CatId; + metadata: MessageMetadata; + event: CodexAppServerRecoveryEvent; + timestamp?: number; +}): AgentMessage | null { + if (input.event.reason !== 'active_writer_retry' || (input.event.elapsedMs ?? 0) < ACTIVE_WRITER_WAIT_NOTICE_MS) { + return null; + } + return { + type: 'provider_signal', + catId: input.catId, + content: JSON.stringify({ + type: 'warning', + presentation: 'transient_status', + message: '正在等待该成员的原生会话释放,可点 Stop 取消', + }), + metadata: { + ...input.metadata, + diagnostics: { appServerRecovery: input.event }, + }, + timestamp: input.timestamp ?? Date.now(), + }; +} + function codexEventType(event: unknown): string | undefined { if (typeof event !== 'object' || event === null) return undefined; const type = (event as Record).type; @@ -345,6 +370,20 @@ function confirmationUnavailableError(): Error & { reasonCode: 'confirmation_una }); } +export function resolveCodexApiKeyIsolationRoot( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + const userHome = env.HOME || env.USERPROFILE || homedir(); + if (platform === 'win32') { + return join(env.LOCALAPPDATA || join(userHome, 'AppData', 'Local'), 'clowder-ai', 'codex-api-key-homes'); + } + if (platform === 'darwin') { + return join(userHome, 'Library', 'Caches', 'clowder-ai', 'codex-api-key-homes'); + } + return join(env.XDG_CACHE_HOME || join(userHome, '.cache'), 'clowder-ai', 'codex-api-key-homes'); +} + /** * Options for constructing CodexAgentService (dependency injection) * F32-b: catId and model are constructor parameters @@ -1154,6 +1193,7 @@ export class CodexAgentService implements AgentService { private readonly carrierMode: CodexCarrierMode; private readonly approvalSurface: CodexApprovalSurface; private readonly appServerHostPool: CodexAppServerHostPool | undefined; + private apiKeyIsolationHome: string | undefined; private readonly nativeRealtimeCompanionEnabled: boolean; /** F203 Phase C: compiles per-cat L0 → OpenAI developer role (-c). */ private readonly l0CompilerFn: typeof compileL0ViaSubprocess; @@ -1167,7 +1207,7 @@ export class CodexAgentService implements AgentService { this.rawArchive = options?.rawArchive ?? new CliRawArchive(); this.contextSnapshotResolver = options?.contextSnapshotResolver ?? createCodexSessionContextSnapshotResolver(); this.cliCommand = options?.cliCommand ?? 'codex'; - this.carrierMode = options?.carrierMode ?? getCodexCarrierMode(); + this.carrierMode = options?.carrierMode ?? 'exec_json'; // Clowder AI currently has no synchronous approval request/response surface. // Keep this explicit so a future interactive bridge changes provenance rather // than relying on transport names or timing heuristics. @@ -1176,6 +1216,14 @@ export class CodexAgentService implements AgentService { this.nativeRealtimeCompanionEnabled = options?.nativeRealtimeCompanionEnabled ?? false; } + private getApiKeyIsolationHome(): string { + if (this.apiKeyIsolationHome) return this.apiKeyIsolationHome; + const root = resolveCodexApiKeyIsolationRoot(); + mkdirSync(root, { recursive: true, mode: 0o700 }); + this.apiKeyIsolationHome = mkdtempSync(join(root, `${this.catId}-`)); + return this.apiKeyIsolationHome; + } + /** F203 Phase C — this service injects L0 via `-c developer_instructions=` (Task 4). */ injectsL0Natively(): boolean { return true; @@ -1192,8 +1240,18 @@ export class CodexAgentService implements AgentService { freshnessCarrierCapability(): AgentFreshnessCarrierCapability { return this.carrierMode === 'app_server' - ? { provider: 'openai_codex', carrier: 'codex_app_server', deliverySemantics: 'exact_active_turn' } - : { provider: 'openai_codex', carrier: 'codex_exec_json', deliverySemantics: 'unsupported' }; + ? { + provider: 'openai_codex', + carrier: 'codex_app_server', + deliverySemantics: 'exact_active_turn', + activeInvocationGuidance: 'supported', + } + : { + provider: 'openai_codex', + carrier: 'codex_exec_json', + deliverySemantics: 'unsupported', + activeInvocationGuidance: 'unsupported', + }; } async requestNativeCompaction(input: { @@ -1819,6 +1877,7 @@ export class CodexAgentService implements AgentService { const auditContext = options?.auditContext; const recentStreamErrors: string[] = []; let capacityRecoveryBlocked: CodexAppServerRecoveryBlockedEvent | null = null; + let activeWriterWaitNoticeEmitted = false; let providerRecovery: CodexProviderRecoveryTracker | null = null; try { @@ -1837,9 +1896,7 @@ export class CodexAgentService implements AgentService { if (participation) { Object.assign(rawEnv, await prepareCollectiveCodexHome(options!.workingDirectory!, authMode)); } else if (authMode === 'api_key' && customBaseUrl) { - const { mkdtempSync } = await import('node:fs'); - const { tmpdir } = await import('node:os'); - const isolatedHome = mkdtempSync(`${tmpdir()}/codex-apikey-`); + const isolatedHome = this.getApiKeyIsolationHome(); rawEnv.HOME = isolatedHome; if (process.platform === 'win32') { rawEnv.USERPROFILE = isolatedHome; @@ -2088,7 +2145,7 @@ export class CodexAgentService implements AgentService { thread: options?.sessionId ? { kind: 'resume' as const, threadId: options.sessionId } : { kind: 'start' as const }, - model: cliModel, + ...(!customBaseUrl && cliModel ? { model: cliModel } : {}), ...(options?.workingDirectory ? { cwd: options.workingDirectory } : {}), sandbox: sandboxMode, approvalPolicy, @@ -2126,6 +2183,7 @@ export class CodexAgentService implements AgentService { }, } : {}), + ...(options?.activeRunDispatch ? { activeRunDispatch: options.activeRunDispatch } : {}), ...(options?.signal ? { signal: options.signal } : {}), timeoutMs: resolveCliTimeoutMs(parseCliTimeoutMs(codexEnv.CLI_TIMEOUT_MS ?? undefined)), interruptGraceMs: KILL_GRACE_MS, @@ -2197,12 +2255,18 @@ export class CodexAgentService implements AgentService { // (compaction retry, turn.failed then new turn.started + turn.completed) // are handled canonically at spawn layer via localFinalTerminal tracking. const catConfig = catRegistry.tryGet(this.catId as string)?.config; - const signatureIdentity = catConfig?.nickname?.trim() || catConfig?.displayName?.trim(); + const nickname = catConfig?.nickname?.trim(); + const displayName = catConfig?.displayName?.trim(); + const signatureIdentity = nickname || displayName; + const signatureIdentityAliases = [...new Set([nickname, displayName])].filter((identity): identity is string => + Boolean(identity && identity !== signatureIdentity), + ); const codexStreamState: CodexStreamState = { hadPriorTextTurn: false, ...(signatureIdentity ? { signatureIdentity, + ...(signatureIdentityAliases.length > 0 ? { signatureIdentityAliases } : {}), canonicalSignature: `[${signatureIdentity}/${effectiveModel}🐾]`, } : {}), @@ -2259,6 +2323,13 @@ export class CodexAgentService implements AgentService { }, timestamp: Date.now(), }; + if (!activeWriterWaitNoticeEmitted) { + const waitSignal = buildCodexActiveWriterWaitSignal({ catId: this.catId, metadata, event }); + if (waitSignal) { + activeWriterWaitNoticeEmitted = true; + yield waitSignal; + } + } continue; } if (isCodexAppServerRecoveryBlockedEvent(event)) { @@ -2671,9 +2742,7 @@ export class CodexAgentService implements AgentService { } const visibleError = capacityRecoveryBlocked ? `自动续跑已安全停止(${capacityRecoveryBlocked.reason});系统已保留并显示本轮断点,没有猜测或切换任务。` - : err instanceof CodexActiveWriterRecoveryError - ? '原生会话恢复仍被 active writer 拒绝;系统已拒绝自动替换或封存当前会话。请稍后重试。' - : rawError; + : rawError; const errorMetadata = carrierMode === 'app_server' ? { @@ -2687,31 +2756,17 @@ export class CodexAgentService implements AgentService { }, } : {}), - cliDiagnostics: - err instanceof CodexActiveWriterRecoveryError - ? buildActiveWriterRecoveryDiagnostic({ - state: - err.detection.diagnostics.classification === 'external_or_unknown' - ? 'external_or_unknown' - : 'owner_busy', - debugRef: { - command: 'codex app-server', - exitCode: null, - signal: null, - ...(options?.invocationId ? { invocationId: options.invocationId } : {}), - }, - }) - : buildCliDiagnostics({ - rawText: rawError, - // `structuredErrorText` is reserved for Claude result events. Raw Codex - // transport failures must stay on provider-neutral classifier/unknown paths. - debugRef: { - command: 'codex app-server', - exitCode: null, - signal: null, - ...(options?.invocationId ? { invocationId: options.invocationId } : {}), - }, - }), + cliDiagnostics: buildCliDiagnostics({ + rawText: rawError, + // `structuredErrorText` is reserved for Claude result events. Raw Codex + // transport failures must stay on provider-neutral classifier/unknown paths. + debugRef: { + command: 'codex app-server', + exitCode: null, + signal: null, + ...(options?.invocationId ? { invocationId: options.invocationId } : {}), + }, + }), } : metadata; yield { diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts index 4520011bec..8d05c3ffe5 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts @@ -3,9 +3,14 @@ import type { ActiveInvocationFreshnessController, PreparedFreshnessNotice, } from '../../freshness/FreshnessNoticeBroker.js'; -import type { CodexNativeResumeReplacementProvenance } from '../../runtime-session/CodexSessionReplacementProvenance.js'; +import { + CodexActiveWriterRecoveryError, + type CodexNativeResumeReplacementProvenance, +} from '../../runtime-session/CodexSessionReplacementProvenance.js'; import type { AgentCarrierSession, + AgentClientActiveRunDispatchRegistration, + AgentClientActiveRunHandle, PreparedProviderRequestV1, ProviderCompactionObservation, ProviderContinuityEvidence, @@ -117,6 +122,8 @@ export interface CodexAppServerRunInput { interruptGraceMs?: number; /** Zero-based transport recovery attempt, projected with lifecycle events. */ recoveryAttempt?: number; + /** Zero-based ownership retry count; only the first conflict may retire a stale affinity host. */ + activeWriterAttempt?: number; /** Internal fence: a dead resume carrier is being replaced by this fresh start. */ resumeReplacement?: CodexNativeResumeReplacementProvenance; /** @@ -135,6 +142,8 @@ export interface CodexAppServerRunInput { beforeProviderLaunch?: (request: PreparedProviderRequestV1) => Promise; /** F306: one provider-neutral interaction surface bound to this exact invocation. */ runtimeInteraction?: Omit; + /** #1354: lifecycle-owned registration for this exact accepted provider turn. */ + activeRunDispatch?: AgentClientActiveRunDispatchRegistration; } export interface CodexAppServerClientDeps { @@ -215,6 +224,8 @@ export class CodexAppServerClient { let latestUsage: JsonObject | null = null; let activeThreadId: string | null = null; let activeTurnId: string | null = null; + let activeRunDispatchOpen = false; + let releaseActiveRunDispatch: (() => void) | undefined; let transportDisposition: 'release' | 'evict' = 'release'; const timeoutMs = Math.max(0, input.timeoutMs ?? 0); const interruptGraceMs = Math.max(0, input.interruptGraceMs ?? DEFAULT_INTERRUPT_GRACE_MS); @@ -249,7 +260,10 @@ export class CodexAppServerClient { startParams: buildCodexAppServerThreadParams(input), requireExactResume: Boolean(recoveryInstruction), ...(input.resumeReplacement ? { resumeReplacement: input.resumeReplacement } : {}), - localLiveLease: this.deps.wire.reusedSessionHost === true, + // A reused affinity host reaches this point only after its local lease + // was released. A writer conflict here is therefore provider-side stale + // ownership, not this process concurrently writing the same turn. + localLiveLease: false, request: (method, params) => this.request(method, params), now: this.deps.now ?? Date.now, }); @@ -351,6 +365,50 @@ export class CodexAppServerClient { this.lifecycle.transition('turn_accepted', { threadId, turnId: activeTurnId, turnAccepted: true }), ); this.lifecycle.armInactivityTimeout(timeoutMs, timeoutHandler); + if (input.activeRunDispatch) { + const invocationId = input.activeRunDispatch.invocationId; + const handle: AgentClientActiveRunHandle = { + provider: 'openai_codex', + carrier: 'codex_app_server', + threadId, + turnId: activeTurnId, + }; + activeRunDispatchOpen = true; + const release = input.activeRunDispatch.register({ + invocationId, + capabilities: { append: true, steer: true }, + handle, + dispatch: async (dispatchInput, options) => { + if (options.expectedInvocationId !== invocationId) { + return { accepted: false, reason: 'active_run_mismatch' }; + } + if (!activeRunDispatchOpen || activeThreadId !== threadId || activeTurnId !== handle.turnId) { + return { accepted: false, reason: 'active_run_closed' }; + } + const text = dispatchInput.text.trim(); + const imagePaths = dispatchInput.imagePaths?.filter((path) => path.length > 0) ?? []; + if (!text && imagePaths.length === 0) return { accepted: false, reason: 'invalid_input' }; + try { + const result = asCodexAppServerRecord( + await this.request('turn/steer', { + threadId, + expectedTurnId: handle.turnId, + input: [ + ...(text ? [{ type: 'text', text: dispatchInput.text }] : []), + ...imagePaths.map((path) => ({ type: 'localImage', path })), + ], + }), + ); + return result?.turnId === handle.turnId + ? { accepted: true, handle } + : { accepted: false, reason: 'active_run_mismatch' }; + } catch { + return { accepted: false, reason: 'provider_rejected' }; + } + }, + }); + if (typeof release === 'function') releaseActiveRunDispatch = release; + } if (input.signal?.aborted) { void this.lifecycle.interrupt(threadId, activeTurnId, 'user_cancel', interruptGraceMs); } @@ -430,6 +488,7 @@ export class CodexAppServerClient { if (mapped?.type === 'turn.completed' && latestUsage) mapped.usage = latestUsage; if (mapped) yield mapped; if (isExactCodexRootTurnCompletion(envelope, { threadId, turnId: activeTurnId })) { + activeRunDispatchOpen = false; runtimeInteraction?.close('provider_cancelled'); try { await this.deps.freshnessController?.markTurnCompleted(activeTurnId); @@ -464,6 +523,13 @@ export class CodexAppServerClient { } catch (error) { runtimeInteraction?.close('transport_lost'); const failure = error instanceof Error ? error : new Error(String(error)); + if ( + error instanceof CodexActiveWriterRecoveryError && + input.activeWriterAttempt === 0 && + this.deps.wire.reusedSessionHost === true + ) { + transportDisposition = 'evict'; + } if (activeNotice && this.deps.freshnessController) { try { await this.deps.freshnessController.markMissed(activeNotice, 'transport_failed'); @@ -475,6 +541,8 @@ export class CodexAppServerClient { if (failed) yield this.lifecycle.event(failed); throw failure; } finally { + activeRunDispatchOpen = false; + releaseActiveRunDispatch?.(); runtimeInteraction?.close('provider_cancelled'); const { closing, closed } = await closeCodexAppServerTransport( this.deps.wire, diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAppServerRunner.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAppServerRunner.ts index 382a9e529e..b2bd72f6db 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAppServerRunner.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAppServerRunner.ts @@ -1,3 +1,4 @@ +import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import { codexAppServerRecovery } from '../../../../../infrastructure/telemetry/instruments.js'; import { buildCodexActiveWriterDiagnostics, @@ -35,8 +36,11 @@ export interface CodexAppServerRecoveryEvent { type: 'app_server.recovery'; reason: 'pre_turn_transport' | 'active_writer_retry' | 'model_capacity'; attempt: number; - retryBudget: number; + /** null means an ownership wait that remains retryable until explicit cancellation. */ + retryBudget: number | null; delayMs?: number; + /** Time since the first ownership conflict in this invocation. */ + elapsedMs?: number; threadId?: string; phase?: 'pre_tool' | 'post_tool'; /** Present only for active_writer_retry; evidence never authorizes a replacement. */ @@ -68,6 +72,7 @@ const DEFAULT_MODEL_CAPACITY_RETRY_DELAYS_MS = [1_000, 3_000, 8_000, 15_000] as const DEFAULT_ACTIVE_WRITER_RETRY_DELAY_MS = 250; const MAX_ACTIVE_WRITER_RETRY_DELAY_MS = 5_000; const MAX_RECOVERY_STEP_CHARS = 160; +const log = createModuleLogger('codex-app-server-runner'); function buildModelCapacityRecoveryInstruction(checkpoint: CodexCapacityRecoveryCheckpoint): string { const step = checkpoint.nextIncompleteStep()?.step.slice(0, MAX_RECOVERY_STEP_CHARS); @@ -102,12 +107,15 @@ async function waitForRecoveryDelay(delayMs: number, signal: AbortSignal | undef } /** - * Owns bounded recovery for Codex app-server. + * Owns recovery for Codex app-server. * - * Transport recovery stays fenced before turn/start. The one accepted-turn - * exception is the provider's exact model-capacity terminal: it may resume the - * same thread only with exact Clowder AI task coordinates. After tool surface, - * it additionally requires recorded progress and a fully terminal tool ledger. + * Transport recovery stays bounded and fenced before turn/start. Provider + * ownership contention is different: an active writer is an internal wait on + * the same native thread until that writer releases it or the invocation is + * explicitly cancelled. The one accepted-turn recovery exception is the + * provider's exact model-capacity terminal: it may resume the same thread only + * with exact Clowder AI task coordinates. After tool surface, it additionally + * requires recorded progress and a fully terminal tool ledger. */ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunnerOptions): AsyncGenerator { const retryBudget = Math.max(0, options.retryBudget ?? 1); @@ -120,6 +128,7 @@ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunn ); let transportAttempt = 0; let activeWriterAttempt = 0; + let activeWriterWaitStartedAt: number | undefined; let capacityAttempt = 0; let recoveryAttempt = 0; let currentThread = options.runInput.thread; @@ -133,6 +142,8 @@ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunn for (;;) { let lifecycle: CodexAppServerLifecycleSnapshot | null = null; const terminalBuffer: unknown[] = []; + const preTurnLifecycleTerminalBuffer: unknown[] = []; + const preTurnLifecycleProjectionBuffer: CodexAppServerLifecycleSnapshot[] = []; let capacityTerminalObserved = false; let pendingCapacityNotice: unknown; let successfulTerminalObserved = false; @@ -149,18 +160,30 @@ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunn wire, onLifecycle: (snapshot) => { lifecycle = snapshot; - options.clientDeps?.onLifecycle?.(snapshot); + if (isPreTurnLifecycleTerminalSnapshot(snapshot)) { + preTurnLifecycleProjectionBuffer.push(snapshot); + } else { + projectLifecycle(options.clientDeps?.onLifecycle, snapshot); + } }, }); const runInput: CodexAppServerRunInput = { ...options.runInput, thread: currentThread, recoveryAttempt, + activeWriterAttempt, ...(resumeReplacement ? { resumeReplacement } : { resumeReplacement: undefined }), ...(recoveryInstruction ? { recoveryInstruction } : { recoveryInstruction: undefined }), ...(imagePaths ? { imagePaths } : { imagePaths: undefined }), }; for await (const event of client.run(runInput)) { + if (isPreTurnLifecycleTerminal(event)) { + // Ownership conflicts are provider preflight state. Do not project a + // discarded probe's failed/closed lifecycle onto the invocation that + // is still waiting to resume the same native thread. + preTurnLifecycleTerminalBuffer.push(event); + continue; + } checkpoint.observe(event); if (isModelCapacityNotice(event)) { // The provider can announce an error before retrying it itself or @@ -189,6 +212,10 @@ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunn yield event; } if (pendingCapacityNotice) yield pendingCapacityNotice; + for (const snapshot of preTurnLifecycleProjectionBuffer) { + projectLifecycle(options.clientDeps?.onLifecycle, snapshot); + } + for (const event of preTurnLifecycleTerminalBuffer) yield event; for (const event of terminalBuffer) yield event; if (capacityAttempt > 0 && successfulTerminalObserved) { codexAppServerRecovery.add(1, { status: 'recovered' }); @@ -230,14 +257,17 @@ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunn activeWriterThreadId && isActiveWriterError(error) && !capacityTerminalObserved && - activeWriterAttempt < 1 && - transportAttempt < retryBudget && canRetryBeforeTurn(failedAt, options.runInput.signal) ) { activeWriterAttempt++; - transportAttempt++; recoveryAttempt++; const detectedAt = Date.now(); + activeWriterWaitStartedAt ??= detectedAt; + const elapsedMs = Math.max(0, detectedAt - activeWriterWaitStartedAt); + const retryDelayMs = Math.min( + MAX_ACTIVE_WRITER_RETRY_DELAY_MS, + activeWriterRetryDelayMs * 2 ** Math.min(activeWriterAttempt - 1, 6), + ); const detection = error instanceof CodexActiveWriterRecoveryError ? error.detection @@ -251,17 +281,28 @@ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunn threadRead: { outcome: 'failed' }, }), }; + log.warn( + { + threadIdPrefix: activeWriterThreadId.slice(0, 8), + attempt: activeWriterAttempt, + elapsedMs, + retryDelayMs, + classification: detection.diagnostics.classification, + }, + '[codex-app-server] native thread writer remains busy; waiting on the same session', + ); codexAppServerRecovery.add(1, { status: 'active_writer_retry' }); yield { type: 'app_server.recovery', reason: 'active_writer_retry', - attempt: transportAttempt, - retryBudget, - delayMs: activeWriterRetryDelayMs, + attempt: activeWriterAttempt, + retryBudget: null, + delayMs: retryDelayMs, + elapsedMs, threadId: activeWriterThreadId, activeWriter: detection, } satisfies CodexAppServerRecoveryEvent; - await waitForRecoveryDelay(activeWriterRetryDelayMs, options.runInput.signal); + await waitForRecoveryDelay(retryDelayMs, options.runInput.signal); continue; } @@ -333,6 +374,10 @@ export async function* runCodexAppServerWithRecovery(options: CodexAppServerRunn } if (pendingCapacityNotice) yield pendingCapacityNotice; + for (const snapshot of preTurnLifecycleProjectionBuffer) { + projectLifecycle(options.clientDeps?.onLifecycle, snapshot); + } + for (const event of preTurnLifecycleTerminalBuffer) yield event; for (const event of terminalBuffer) yield event; throw error; } @@ -344,3 +389,32 @@ function isThreadStartedEvent(value: unknown): value is { type: 'thread.started' const record = value as { type?: unknown; thread_id?: unknown }; return record.type === 'thread.started' && typeof record.thread_id === 'string'; } + +function isPreTurnLifecycleTerminal(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const event = value as { + type?: unknown; + lifecycle?: { stage?: unknown; turnAccepted?: unknown }; + }; + return event.type === 'app_server.lifecycle' && isPreTurnLifecycleTerminalSnapshot(event.lifecycle); +} + +function isPreTurnLifecycleTerminalSnapshot( + lifecycle: { stage?: unknown; turnAccepted?: unknown } | undefined, +): boolean { + return ( + lifecycle?.turnAccepted === false && + (lifecycle.stage === 'failed' || lifecycle.stage === 'closing' || lifecycle.stage === 'closed') + ); +} + +function projectLifecycle( + onLifecycle: CodexAppServerClientDeps['onLifecycle'] | undefined, + snapshot: CodexAppServerLifecycleSnapshot, +): void { + try { + onLifecycle?.(snapshot); + } catch { + // Lifecycle projection is observational; it cannot abort provider work. + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexUnixWebSocketSession.ts b/packages/api/src/domains/cats/services/agents/providers/CodexUnixWebSocketSession.ts index 37eb30445d..e1c64890db 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexUnixWebSocketSession.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexUnixWebSocketSession.ts @@ -9,7 +9,7 @@ import WebSocket from 'ws'; import { MCP_CALLBACK_ENV_KEYS } from '../../../../../config/capabilities/mcp-constants.js'; import { buildChildEnv } from '../../../../../utils/cli-spawn.js'; import { buildUnixSupervisedSpawnPlan } from '../../../../../utils/cli-supervised-process.js'; -import { sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; +import { excerptSanitizedStderr, sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; import type { AgentCarrierSession, AgentCarrierSessionOptions } from '../../types.js'; const SOCKET_READY_TIMEOUT_MS = 10_000; @@ -192,7 +192,7 @@ class SpawnedCodexAppServerHost implements CodexAppServerHostProcess { } diagnostic(): string { - return sanitizeCliStderr(this.stderr.join('').slice(-1_000)); + return excerptSanitizedStderr(this.stderr.join(''), { edge: 'tail', maxLength: 1_000 }); } } diff --git a/packages/api/src/domains/cats/services/agents/providers/DirectAgentCarrierSession.ts b/packages/api/src/domains/cats/services/agents/providers/DirectAgentCarrierSession.ts index 41065d22d6..60a442a6ae 100644 --- a/packages/api/src/domains/cats/services/agents/providers/DirectAgentCarrierSession.ts +++ b/packages/api/src/domains/cats/services/agents/providers/DirectAgentCarrierSession.ts @@ -3,6 +3,7 @@ import { once } from 'node:events'; import { buildChildEnv } from '../../../../../utils/cli-spawn.js'; import { buildUnixSupervisedSpawnPlan } from '../../../../../utils/cli-supervised-process.js'; import { isParseError, parseNDJSON } from '../../../../../utils/ndjson-parser.js'; +import { excerptSanitizedStderr } from '../../../../../utils/sanitize-cli-stderr.js'; import type { AgentCarrierSession, AgentCarrierSessionFactory, AgentCarrierSessionOptions } from '../../types.js'; function normalizeEnv(input: Record | undefined, workingDirectory: string): NodeJS.ProcessEnv { @@ -50,7 +51,7 @@ class DirectAgentCarrierSession implements AgentCarrierSession { } const exit = await this.waitForExit(); if (exit.code !== 0 && !this.options.signal?.aborted) { - const excerpt = this.stderr.join('').trim().slice(-1000); + const excerpt = excerptSanitizedStderr(this.stderr.join(''), { edge: 'tail', maxLength: 1_000 }); throw new Error(`Codex app-server exited with code ${String(exit.code)}${excerpt ? `: ${excerpt}` : ''}`); } } diff --git a/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts index 3be475c25b..d498d7e977 100644 --- a/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts @@ -127,7 +127,12 @@ export class KimiAgentService implements AgentService { } freshnessCarrierCapability(): AgentFreshnessCarrierCapability { - return { provider: 'kimi', carrier: 'kimi_stream_json', deliverySemantics: 'unsupported' }; + return { + provider: 'kimi', + carrier: 'kimi_stream_json', + deliverySemantics: 'unsupported', + activeInvocationGuidance: 'unsupported', + }; } contextCapability(): import('../../types.js').AgentContextCapability { diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/AcpAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/acp/AcpAgentService.ts index 2facca649f..a5c4798c00 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/AcpAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/AcpAgentService.ts @@ -945,6 +945,7 @@ function makeCapacityWarning( catId, content: JSON.stringify({ type: 'warning', + presentation: 'transient_status', message: `${providerName} 服务端容量不足,正在重试 (${signal.message.slice(0, 100)})`, }), metadata, diff --git a/packages/api/src/domains/cats/services/agents/providers/antigravity/AntigravityAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/antigravity/AntigravityAgentService.ts index 3319de410a..00ae22e22b 100644 --- a/packages/api/src/domains/cats/services/agents/providers/antigravity/AntigravityAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/antigravity/AntigravityAgentService.ts @@ -274,6 +274,7 @@ function buildRetrySignal( catId, content: JSON.stringify({ type: 'warning', + presentation: 'transient_status', message: `${reason},正在自动重试(${attempt}/${totalAttempts}),${seconds} 后继续`, }), metadata, diff --git a/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-event-transformer.ts b/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-event-transformer.ts index 48bf43342f..e08158ebe9 100644 --- a/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-event-transformer.ts +++ b/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-event-transformer.ts @@ -460,6 +460,7 @@ export function transformTrajectorySteps( catId, content: JSON.stringify({ type: 'warning', + presentation: 'transient_status', message: humanErrorMessage(upstreamError.kind), }), metadata: { ...metadata, upstreamError }, diff --git a/packages/api/src/domains/cats/services/agents/providers/claude-carrier-factory.ts b/packages/api/src/domains/cats/services/agents/providers/claude-carrier-factory.ts index 75d7ad0d31..0f61b23a8d 100644 --- a/packages/api/src/domains/cats/services/agents/providers/claude-carrier-factory.ts +++ b/packages/api/src/domains/cats/services/agents/providers/claude-carrier-factory.ts @@ -187,6 +187,7 @@ export class FallbackCarrierWrapper implements AgentService { provider: 'anthropic', carrier: 'other', deliverySemantics: 'unsupported', + activeInvocationGuidance: 'unsupported', } ); } diff --git a/packages/api/src/domains/cats/services/agents/providers/claude-mcp-config.ts b/packages/api/src/domains/cats/services/agents/providers/claude-mcp-config.ts new file mode 100644 index 0000000000..730a2da087 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/claude-mcp-config.ts @@ -0,0 +1,152 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import type { CapabilitiesConfig } from '@cat-cafe/shared'; +import { + CAT_CAFE_SPLIT_ENTRYPOINTS, + expandManagedMcpNamesForUserMerge, + MCP_CALLBACK_ENV_KEYS, + resolveCatCafeNodeCommand, + resolvePencilCommand, + resolveServersForCat, +} from '../../../../../config/capabilities/capability-orchestrator.js'; +import { isRetiredGithubMcpConfigEntry } from '../../../../../config/capabilities/retired-github-mcp.js'; + +export type ClaudeMcpServerConfig = Record; + +export interface ClaudeMcpConfigResolution { + readonly servers: Record; + readonly source: 'capabilities.json' | 'fallback'; +} + +function resolveWorkspaceRoot(workingDirectory?: string): string { + const explicitAllowed = process.env.ALLOWED_WORKSPACE_DIRS?.trim(); + if (explicitAllowed) return explicitAllowed; + const threadWorkspace = workingDirectory?.trim(); + if (threadWorkspace) return resolve(threadWorkspace); + const explicitWorkspace = process.env.CAT_CAFE_WORKSPACE_ROOT?.trim(); + if (explicitWorkspace) return explicitWorkspace; + return process.cwd(); +} + +/** + * Resolve the exact per-invocation Claude MCP surface once for both supported + * Claude carriers. This is intentionally independent from CLI argument + * assembly: SDK and CLI consume the same server map and callback credentials. + */ +export async function resolveClaudeMcpConfig(input: { + readonly callbackEnv?: Record; + readonly workingDirectory?: string; + readonly mcpServerPath?: string; +}): Promise { + if (!input.callbackEnv || !input.mcpServerPath) return undefined; + + const distDir = dirname(input.mcpServerPath); + const capabilitiesProjectRoot = resolve(distDir, '../../..'); + const catId = input.callbackEnv.CAT_CAFE_CAT_ID; + const callbackEnv: Record = { + ALLOWED_WORKSPACE_DIRS: resolveWorkspaceRoot(input.workingDirectory), + }; + for (const key of MCP_CALLBACK_ENV_KEYS) { + const value = input.callbackEnv[key]; + if (value) callbackEnv[key] = value; + } + + const servers: Record = {}; + const managedNames = new Set(); + let resolved = false; + try { + let capabilityConfig: CapabilitiesConfig | null = null; + let accessScope: 'global' | 'project' = 'global'; + if (input.workingDirectory && input.workingDirectory !== capabilitiesProjectRoot) { + try { + const parsed = JSON.parse( + readFileSync(join(input.workingDirectory, '.cat-cafe', 'capabilities.json'), 'utf8'), + ) as Partial; + if ((parsed.version === 1 || parsed.version === 2) && Array.isArray(parsed.capabilities)) { + capabilityConfig = parsed as CapabilitiesConfig; + accessScope = 'project'; + } + } catch { + // No project capability config; use the installation-owned config. + } + } + if (!capabilityConfig) { + const parsed = JSON.parse( + readFileSync(join(capabilitiesProjectRoot, '.cat-cafe', 'capabilities.json'), 'utf8'), + ) as Partial; + if ((parsed.version === 1 || parsed.version === 2) && Array.isArray(parsed.capabilities)) { + capabilityConfig = parsed as CapabilitiesConfig; + } + } + if (capabilityConfig && catId) { + for (const server of resolveServersForCat(capabilityConfig, catId, { accessScope })) { + managedNames.add(server.name); + if (!server.enabled) continue; + if (server.source === 'cat-cafe' && CAT_CAFE_SPLIT_ENTRYPOINTS.has(server.name)) { + const entrypoint = CAT_CAFE_SPLIT_ENTRYPOINTS.get(server.name)!; + const entrypointPath = join(distDir, entrypoint); + if (existsSync(entrypointPath)) { + servers[server.name] = { + command: resolveCatCafeNodeCommand(), + args: [entrypointPath], + env: callbackEnv, + }; + } + } else if (server.resolver === 'pencil') { + const pencil = await resolvePencilCommand({ projectRoot: capabilitiesProjectRoot }); + if (pencil) servers[server.name] = { command: pencil.command, args: pencil.args }; + } else if (server.transport === 'streamableHttp' && server.url) { + servers[server.name] = { + type: 'http', + url: server.url, + ...(server.headers && Object.keys(server.headers).length > 0 ? { headers: server.headers } : {}), + }; + } else if (server.command) { + servers[server.name] = { + command: server.command, + args: server.args, + ...(server.env && Object.keys(server.env).length > 0 ? { env: server.env } : {}), + ...(server.workingDir ? { cwd: server.workingDir } : {}), + }; + } + } + resolved = true; + } + } catch { + // Fall through to the installation-owned Cat Cafe entrypoints. + } + + if (!resolved) { + for (const [name, entrypoint] of CAT_CAFE_SPLIT_ENTRYPOINTS) { + const entrypointPath = join(distDir, entrypoint); + if (existsSync(entrypointPath)) { + servers[name] = { + command: resolveCatCafeNodeCommand(), + args: [entrypointPath], + env: callbackEnv, + }; + } + } + } + + if (input.workingDirectory) { + try { + const projectConfig = JSON.parse(readFileSync(join(input.workingDirectory, '.mcp.json'), 'utf8')) as { + mcpServers?: Record; + }; + if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { + const excludedNames = expandManagedMcpNamesForUserMerge([...managedNames, ...Object.keys(servers)]); + for (const [name, entry] of Object.entries(projectConfig.mcpServers)) { + if (isRetiredGithubMcpConfigEntry(name, entry)) continue; + if (!excludedNames.has(name) && !(name in servers) && entry && typeof entry === 'object') { + servers[name] = entry as ClaudeMcpServerConfig; + } + } + } + } catch { + // Project MCP config is optional; the managed surface remains authoritative. + } + } + + return { servers, source: resolved ? 'capabilities.json' : 'fallback' }; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/claude-sdk-turn-input-state.ts b/packages/api/src/domains/cats/services/agents/providers/claude-sdk-turn-input-state.ts new file mode 100644 index 0000000000..51087aabbd --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/claude-sdk-turn-input-state.ts @@ -0,0 +1,176 @@ +import { randomUUID } from 'node:crypto'; +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; + +export type IdentifiedSdkUserMessage = SDKUserMessage & { uuid: string }; + +class AsyncInputQueue implements AsyncIterable { + private readonly values: Array<{ value: T; onDequeued?: () => void }> = []; + private readonly waiters: Array<(result: IteratorResult) => void> = []; + private closed = false; + + push(value: T, onDequeued?: () => void): boolean { + if (this.closed) return false; + const waiter = this.waiters.shift(); + if (waiter) { + onDequeued?.(); + waiter({ value, done: false }); + } else { + this.values.push({ value, ...(onDequeued ? { onDequeued } : {}) }); + } + return true; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.values.length = 0; + for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const entry = this.values.shift(); + if (entry !== undefined) { + entry.onDequeued?.(); + return Promise.resolve({ value: entry.value, done: false }); + } + if (this.closed) return Promise.resolve({ value: undefined, done: true }); + return new Promise>((resolveNext) => this.waiters.push(resolveNext)); + }, + }; + } +} + +export function createSdkUserMessage(text: string, sessionId: string, uuid = randomUUID()): IdentifiedSdkUserMessage { + return { + type: 'user', + uuid, + session_id: sessionId, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text }] }, + }; +} + +function resultConsumedInputIds(result: Record): string[] { + if (Array.isArray(result.user_message_uuids)) { + return result.user_message_uuids.filter((value): value is string => typeof value === 'string'); + } + return typeof result.user_message_uuid === 'string' ? [result.user_message_uuid] : []; +} + +function resultQueuedTurnCount(result: Record): number | undefined { + const count = result.queued_turn_count; + return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : undefined; +} + +function deleteOldestPendingInput(pendingInputIds: Set, inputOrdinals: Map): void { + const oldestPendingId = pendingInputIds.values().next().value; + if (oldestPendingId !== undefined) { + pendingInputIds.delete(oldestPendingId); + inputOrdinals.delete(oldestPendingId); + } +} + +function settleProviderOwnedTurnInputs( + providerOwnedInputIds: Set, + inputOrdinals: Map, + result: Record, +): boolean { + const consumedIds = resultConsumedInputIds(result); + const snapshotUpperBound = consumedIds.reduce((latest, id) => { + const ordinal = inputOrdinals.get(id); + if (ordinal === undefined) return latest; + return latest === undefined ? ordinal : Math.max(latest, ordinal); + }, undefined); + for (const id of consumedIds) providerOwnedInputIds.delete(id); + + const queuedTurnCount = resultQueuedTurnCount(result); + if (queuedTurnCount !== undefined && snapshotUpperBound !== undefined) { + // SDK 0.3.267 defines queued_turn_count as the command-queue snapshot at + // result time. The echoed input identity bounds which accepted sends that + // snapshot can describe, so later prefetched inputs cannot be consumed by + // an older result. + const snapshotPendingIds = [...providerOwnedInputIds].filter( + (id) => (inputOrdinals.get(id) ?? Number.POSITIVE_INFINITY) <= snapshotUpperBound, + ); + while (snapshotPendingIds.length > queuedTurnCount) { + const id = snapshotPendingIds.shift(); + if (id === undefined) break; + providerOwnedInputIds.delete(id); + inputOrdinals.delete(id); + } + } else if (consumedIds.length === 0) { + deleteOldestPendingInput(providerOwnedInputIds, inputOrdinals); + } + for (const id of consumedIds) inputOrdinals.delete(id); + return providerOwnedInputIds.size === 0; +} + +/** Exact locally-queued → provider-owned → result-settled turn state. */ +export class ClaudeSdkTurnInputState { + readonly input: AsyncIterable; + private readonly queue = new AsyncInputQueue(); + private readonly locallyQueuedIds = new Set(); + private readonly providerOwnedIds = new Set(); + private readonly ordinals = new Map(); + private nextOrdinal = 0; + private activeDispatches = 0; + private terminalWaitingForDispatch = false; + private accepting = true; + + constructor() { + this.input = this.queue; + } + + get isAccepting(): boolean { + return this.accepting; + } + + push(message: IdentifiedSdkUserMessage): boolean { + this.locallyQueuedIds.add(message.uuid); + this.ordinals.set(message.uuid, ++this.nextOrdinal); + const accepted = this.queue.push(message, () => { + this.locallyQueuedIds.delete(message.uuid); + this.providerOwnedIds.add(message.uuid); + }); + if (accepted) this.terminalWaitingForDispatch = false; + else { + this.locallyQueuedIds.delete(message.uuid); + this.ordinals.delete(message.uuid); + } + return accepted; + } + + beginDispatch(): void { + this.activeDispatches += 1; + } + + finishDispatch(): boolean { + this.activeDispatches -= 1; + return this.closeAfterTerminalIfIdle(); + } + + settleResult(result: Record): boolean { + this.terminalWaitingForDispatch = settleProviderOwnedTurnInputs(this.providerOwnedIds, this.ordinals, result); + return this.closeAfterTerminalIfIdle(); + } + + closeAfterTerminalIfIdle(): boolean { + if ( + !this.terminalWaitingForDispatch || + this.locallyQueuedIds.size !== 0 || + this.providerOwnedIds.size !== 0 || + this.activeDispatches !== 0 + ) { + return false; + } + this.close(); + return true; + } + + close(): void { + this.accepting = false; + this.queue.close(); + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/codex-event-transform.ts b/packages/api/src/domains/cats/services/agents/providers/codex-event-transform.ts index 457a2032bb..5e6fbb7f63 100644 --- a/packages/api/src/domains/cats/services/agents/providers/codex-event-transform.ts +++ b/packages/api/src/domains/cats/services/agents/providers/codex-event-transform.ts @@ -36,8 +36,10 @@ const MAX_BASE64_LENGTH = 5 * 1024 * 1024; */ export interface CodexStreamState { hadPriorTextTurn: boolean; - /** Cat nickname/display name used to distinguish this cat's signature from quoted teammate signatures. */ + /** Preferred cat identity used for the runtime-canonical signature. */ signatureIdentity?: string; + /** Other configured identities that the provider may use for the same cat. */ + signatureIdentityAliases?: readonly string[]; /** Runtime-derived signature appended once after the provider stream ends normally. */ canonicalSignature?: string; /** Latest provider-authored own signature, used only when runtime config cannot provide one. */ @@ -58,7 +60,7 @@ const MARKDOWN_LEADING_CONTAINER_RE = /^[ \t]{0,3}(?:(?:(?:[-+*]|\d{1,9}[.)])[ \ const FENCE_RUN_RE = /(`{3,}|~{3,})/u; const PAW_SIGNATURE_RE = /^\[([^[\]/\n]+)\/([^[\]\n]+)🐾\]$/u; -const TRAILING_PAW_SIGNATURE_RE = /`?(\[([^[\]/\n]+)\/([^[\]\n]+)🐾\])`?[ \t]*$/u; +const TRAILING_PAW_SIGNATURE_RE = /`?(\[([^[\]/\n]+)\/([^[\]\n]+)🐾(?:@([^[\]\n]+))?\])`?[ \t]*$/u; function isOwnSignatureIdentity(candidate: string, expected: string): boolean { const normalizedCandidate = candidate.trim(); @@ -66,6 +68,17 @@ function isOwnSignatureIdentity(candidate: string, expected: string): boolean { return normalizedCandidate === normalizedExpected || normalizedCandidate.endsWith(`·${normalizedExpected}`); } +function isConfiguredOwnSignatureIdentity( + candidate: string, + expectedIdentity: string, + expectedIdentityAliases: readonly string[], +): boolean { + return ( + isOwnSignatureIdentity(candidate, expectedIdentity) || + expectedIdentityAliases.some((alias) => isOwnSignatureIdentity(candidate, alias)) + ); +} + function normalizeSignatureModel(model: string): string { return model .normalize('NFKC') @@ -77,10 +90,13 @@ function normalizeSignatureModel(model: string): string { function isCanonicalOwnSignature( candidateIdentity: string, candidateModel: string, + candidateCatId: string | undefined, expectedIdentity: string, + expectedIdentityAliases: readonly string[], canonicalSignature: string | undefined, + expectedCatId: CatId, ): boolean { - if (!isOwnSignatureIdentity(candidateIdentity, expectedIdentity)) return false; + if (!isConfiguredOwnSignatureIdentity(candidateIdentity, expectedIdentity, expectedIdentityAliases)) return false; if (!canonicalSignature) return false; const canonical = PAW_SIGNATURE_RE.exec(canonicalSignature.trim()); @@ -89,7 +105,12 @@ function isCanonicalOwnSignature( if (!canonicalIdentity || !canonicalModel || !isOwnSignatureIdentity(canonicalIdentity, expectedIdentity)) { return false; } - return normalizeSignatureModel(candidateModel) === normalizeSignatureModel(canonicalModel); + const normalizedCandidateModel = normalizeSignatureModel(candidateModel); + const normalizedCanonicalModel = normalizeSignatureModel(canonicalModel); + if (normalizedCandidateModel === normalizedCanonicalModel) { + return candidateCatId === undefined || candidateCatId.trim() === expectedCatId; + } + return candidateCatId?.trim() === expectedCatId && normalizedCandidateModel.endsWith(normalizedCanonicalModel); } interface MarkdownFence { @@ -171,17 +192,28 @@ function isMarkdownSignatureSampleContext(text: string, candidateIndex: number): function stripOwnTrailingTurnSignature( text: string, signatureIdentity: string | undefined, + signatureIdentityAliases: readonly string[] | undefined, canonicalSignature: string | undefined, + catId: CatId, ): StrippedTurnSignature { if (!signatureIdentity) return { content: text }; const match = TRAILING_PAW_SIGNATURE_RE.exec(text); if (!match || match.index === undefined) return { content: text }; const candidateIdentity = match[2]; const candidateModel = match[3]; + const candidateCatId = match[4]; if ( !candidateIdentity || !candidateModel || - !isCanonicalOwnSignature(candidateIdentity, candidateModel, signatureIdentity, canonicalSignature) + !isCanonicalOwnSignature( + candidateIdentity, + candidateModel, + candidateCatId, + signatureIdentity, + signatureIdentityAliases ?? [], + canonicalSignature, + catId, + ) ) { return { content: text }; } @@ -515,7 +547,13 @@ export function transformCodexEvent( } if (item?.type === 'agent_message' && typeof item.text === 'string' && item.text.trim().length > 0) { - const stripped = stripOwnTrailingTurnSignature(item.text, state?.signatureIdentity, state?.canonicalSignature); + const stripped = stripOwnTrailingTurnSignature( + item.text, + state?.signatureIdentity, + state?.signatureIdentityAliases, + state?.canonicalSignature, + catId, + ); if (state && stripped.signature) state.observedSignature = stripped.signature; if (stripped.content.trim().length === 0) return null; const prefix = state?.hadPriorTextTurn ? '\n\n' : ''; diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts index c8194cc58b..ef45d29d9a 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -18,13 +18,12 @@ * 虽然参数可选(兼容测试),但生产代码必须显式传入。 */ -import type { CatId, CatRoutingError, MessageContent } from '@cat-cafe/shared'; +import type { CatId, CatRoutingError, MessageContent, MessageFrom } from '@cat-cafe/shared'; import { catRegistry, escapeRegExp } from '@cat-cafe/shared'; import type { SessionStore } from '@cat-cafe/shared/utils'; import { context as ctxApi, SpanStatusCode, trace } from '@opentelemetry/api'; import { getDefaultCatId, isCatAvailable } from '../../../../../config/cat-config-loader.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import type { CallerTraceContext } from '../../../../../infrastructure/telemetry/genai-semconv.js'; import { ROUTING_INTENT, ROUTING_STRATEGY, @@ -40,6 +39,7 @@ import { SessionManager } from '../../session/SessionManager.js'; import type { ISessionSealer } from '../../session/SessionSealer.js'; import type { TranscriptReader } from '../../session/TranscriptReader.js'; import type { TranscriptWriter } from '../../session/TranscriptWriter.js'; +import { messageFrom } from '../../stores/message-from.js'; import { DeliveryCursorStore } from '../../stores/ports/DeliveryCursorStore.js'; import type { IDraftStore } from '../../stores/ports/DraftStore.js'; import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; @@ -48,7 +48,7 @@ import type { ITaskStore } from '../../stores/ports/TaskStore.js'; import type { IThreadStore, ThreadRoutingPolicyV1 } from '../../stores/ports/ThreadStore.js'; import { DEFAULT_THREAD_ID } from '../../stores/ports/ThreadStore.js'; import type { IWorkflowSopStore } from '../../stores/ports/WorkflowSopStore.js'; -import { getTimelineOrderTime, SYSTEM_USER_IDS } from '../../stores/visibility.js'; +import { getTimelineOrderTime } from '../../stores/visibility.js'; import type { AgentMessage, AgentRouteIntent, AgentService } from '../../types.js'; import type { InvocationRegistry } from '../invocation/InvocationRegistry.js'; import { @@ -58,17 +58,11 @@ import { import type { TaskProgressStore } from '../invocation/TaskProgressStore.js'; import type { AgentRegistry } from '../registry/AgentRegistry.js'; import type { AgentRegistrationFailure } from '../registry/AgentServiceUnavailableError.js'; -import type { - A2ASlotTrackingOptions, - PersistenceContext, - RouteOptions, - RouteStrategyDeps, -} from '../routing/route-helpers.js'; +import type { RouteExecutionOptions, RouteOptions, RouteStrategyDeps } from '../routing/route-helpers.js'; import { routeParallel } from '../routing/route-parallel.js'; import { routeSerial } from '../routing/route-serial.js'; import { resolveCatTarget } from './cat-target-resolver.js'; import { appendContextAttachmentsToPrompt } from './context-attachment-prompt.js'; -import type { HumanDispositionInvocationOrigin } from './human-disposition-invocation-origin.js'; const log = createModuleLogger('agent-router'); const routeTracer = trace.getTracer('cat-cafe-api', '0.1.0'); @@ -537,6 +531,8 @@ export interface AgentRouterOptions { draftStore?: IDraftStore; /** F065: Task store for bootstrap task snapshot injection */ taskStore?: ITaskStore; + /** RFC A79: canonical exact-run situation projection for agent context. */ + threadExecutionSituationSource?: import('../invocation/thread-execution-situation.js').ThreadExecutionSituationSource; /** F073 P4: Workflow SOP store for stage hint injection */ workflowSopStore?: IWorkflowSopStore; /** F070 Phase 3a: Execution digest store for dispatch backflow */ @@ -595,11 +591,6 @@ export interface AgentRouterOptions { cloudInvokeBridge?: import('../../cloud-bridge/types.js').ICloudInvokeBridge; /** F247: shared server-custodied source-bound return authorization. */ cloudReturnGrantStore?: import('../../cloud-bridge/cloud-return-grant.js').CloudReturnGrantStore; - /** F247/F167: server-owned terminal producer for the exact cloud A2A carrier. */ - a2aDispatchDispositionService?: Pick< - import('../../../../ball-custody/A2ADispatchDispositionService.js').A2ADispatchDispositionService, - 'complete' - >; /** F254 B3: freshnessReinvokeCheck for invoke-single-cat terminal hook */ freshnessReinvokeCheck?: import('../invocation/invoke-single-cat.js').InvocationDeps['freshnessReinvokeCheck']; /** Durable per-child execution lifecycle; independent from callback-auth registry TTL. */ @@ -655,6 +646,7 @@ export class AgentRouter { private draftStore: IDraftStore | undefined; private taskProgressStore: TaskProgressStore | undefined; private taskStore: ITaskStore | undefined; + private threadExecutionSituationSource?: import('../invocation/thread-execution-situation.js').ThreadExecutionSituationSource; private workflowSopStore: IWorkflowSopStore | undefined; private executionDigestStore: | import('../../../../projects/execution-digest-store.js').ExecutionDigestStore @@ -702,10 +694,6 @@ export class AgentRouter { /** F247 AC-B1c-3 PR-C */ private cloudInvokeBridge?: import('../../cloud-bridge/types.js').ICloudInvokeBridge; private cloudReturnGrantStore?: import('../../cloud-bridge/cloud-return-grant.js').CloudReturnGrantStore; - private a2aDispatchDispositionService?: Pick< - import('../../../../ball-custody/A2ADispatchDispositionService.js').A2ADispatchDispositionService, - 'complete' - >; /** F254 B3 */ private freshnessReinvokeCheck?: import('../invocation/invoke-single-cat.js').InvocationDeps['freshnessReinvokeCheck']; private turnExecutionStore?: import('../../stores/ports/TurnExecutionStore.js').ITurnExecutionStore; @@ -735,7 +723,7 @@ export class AgentRouter { * from a thread using Z5-style reverse-iterate paging, then run keyword detection. * Shared by both route() and routeExecution() (cloud P1 fix). * - * Uses SYSTEM_USER_IDS (not just 'system') to exclude scheduler/connector noise (cloud P2 fix). + * Uses MessageFrom to exclude scheduler/connector noise (cloud P2 fix). */ private async collectAndDetectTextFrustration( threadId: string, @@ -746,12 +734,14 @@ export class AgentRouter { const MAX_PAGES = 10; const userMsgs: string[] = []; type MsgLike = { + from?: MessageFrom; catId?: string | null; userId?: string; content?: string; id?: string; timestamp?: number; deliveredAt?: number; + timelineOrderAt?: number; }; let cursorScore = Infinity; let cursorId: string | undefined; @@ -763,17 +753,14 @@ export class AgentRouter { isFirstPage = false; if (batch.length === 0) break; const first = batch[0]!; - const dAt = typeof first.deliveredAt === 'number' ? first.deliveredAt : 0; - const ts = typeof first.timestamp === 'number' ? first.timestamp : 0; - const firstScore = dAt > 0 ? dAt : ts; + const firstScore = getTimelineOrderTime(first as StoredMessage); if (firstScore > 0 && firstScore < cursorScore) { cursorScore = firstScore; cursorId = first.id; } for (let i = batch.length - 1; i >= 0 && userMsgs.length < TEXT_FRUSTRATION_WINDOW; i--) { const m = batch[i]!; - // Cloud P2 fix: exclude all system users (scheduler, system, etc.), not just 'system' - if (!m.catId && !SYSTEM_USER_IDS.has(m.userId ?? '')) { + if (messageFrom(m as StoredMessage).kind === 'user') { userMsgs.push(typeof m.content === 'string' ? m.content : ''); } } @@ -829,6 +816,7 @@ export class AgentRouter { this.draftStore = options.draftStore; this.taskProgressStore = options.taskProgressStore; this.taskStore = options.taskStore; + this.threadExecutionSituationSource = options.threadExecutionSituationSource; this.workflowSopStore = options.workflowSopStore; this.executionDigestStore = options.executionDigestStore; this.socketManager = options.socketManager; @@ -853,7 +841,6 @@ export class AgentRouter { this.conciergeTriagePlanStore = options.conciergeTriagePlanStore; this.cloudInvokeBridge = options.cloudInvokeBridge; this.cloudReturnGrantStore = options.cloudReturnGrantStore; - this.a2aDispatchDispositionService = options.a2aDispatchDispositionService; this.freshnessReinvokeCheck = options.freshnessReinvokeCheck; this.turnExecutionStore = options.turnExecutionStore; this.freshnessStateStore = options.freshnessStateStore; @@ -880,6 +867,7 @@ export class AgentRouter { provider: 'other', carrier: 'other', deliverySemantics: 'undeclared', + activeInvocationGuidance: 'undeclared', } ); } @@ -948,6 +936,37 @@ export class AgentRouter { return resolved; } + /** + * Canonical target resolution for public conversation work. + * + * New no-mention user input calls this before the atomic source + Queue + * admission so its pending target is never an empty client-side guess. + * Queue admission calls the same resolver again to revalidate persisted or + * recovered target intent before a client effect. Still-routable explicit + * members are preserved in their original order. If none remain, the most + * recent completed lifecycle response is the only history-derived fallback; + * non-terminal and non-completed bubbles are deliberately ignored. The + * configured default is consulted only after that history lookup. + */ + async resolveConversationTargetsAtAdmission(requestedCatIds: readonly string[], threadId: string): Promise { + const explicit = this.filterRoutableCats(requestedCatIds); + if (explicit.length > 0) return explicit; + + if (this.messageStore) { + const recent = await Promise.resolve(this.messageStore.getByThread(threadId, 100)); + for (let index = recent.length - 1; index >= 0; index -= 1) { + const lifecycle = recent[index]?.lifecycle; + if (lifecycle?.kind !== 'response' || lifecycle.status !== 'completed') continue; + const target = this.filterRoutableCats([lifecycle.targetId]); + if (target.length > 0) return target; + break; + } + } + + const fallback = this.pickFallbackCat(new Set()); + return fallback ? [fallback] : []; + } + /** * F194 Phase Z5 AC-Z16: 无 @ fallback 优先用上一条 user message 的 mentions, * 不让 thread 里其他猫的发言(如 vision guard cross-post)抢路由 fallback。 @@ -955,10 +974,10 @@ export class AgentRouter { * 用户心智模型:"no @ = 继续刚才 @ 的猫里的一只",不是"thread 里最近发言的猫", * 也不是把上一轮 parallel mentions 全量延续成新一轮并发。 * - * user message 严格定义:`userId !== null && catId === null`。 + * user message 严格定义:`from.kind === 'user'`。 * - cat-to-cat handoff (A2A) 有 catId → NOT a user message * - vision guard cross-post 有 catId → NOT a user message - * - 系统消息 (userId === null && catId === null) → NOT a user message + * - system/external/plugin sender → NOT a user message * * 时间窗口:回看最近 N=5 条 user messages,防止远古 mentions 主导 fallback。 * 在 N 条窗口内找到的最近一条 user message 后,取第一个 routable mention @@ -996,12 +1015,9 @@ export class AgentRouter { for (let i = page.length - 1; i >= 0; i -= 1) { const m = page[i] as StoredMessage; - // F194 Phase Z5 R5 + R6 (cloud Codex round-1+2 P1): system-authored notices 不算 user message。 - // R5 只排除了 'system';R6 改用 visibility.ts 的 SYSTEM_USER_IDS(含 scheduler + system + 未来扩展) - // — 与 message store 的 isSystemUserMessage 同口径,scheduler 触发的通知一并排除。 + // MessageFrom keeps scheduler/system notices out of the user count. // 否则一串 system/scheduler notice 会塞满 USER count limit,把真正的 user @ 挤出窗口外。 - const userIdStr = typeof m?.userId === 'string' ? m.userId : null; - const isUserMessage = userIdStr != null && !SYSTEM_USER_IDS.has(userIdStr) && m?.catId == null; + const isUserMessage = messageFrom(m).kind === 'user'; if (!isUserMessage) continue; // F194 Phase Z5 R8 (cloud Codex round-4 P1) + R9 (砚砚 R8 P1): 1h cutoff 在 isUserMessage 之后 // 判断,且用 effectiveOrderTime(与 cursor 同口径)。Redis markDelivered 把 thread zset score @@ -1136,7 +1152,6 @@ export class AgentRouter { const mentions: ParsedMention[] = []; const seenCats = new Set(); const routing_warnings: CatRoutingError[] = []; - // Route-line grammar handles markdown/list wrappers before the broader inline scan. recordRouteLineMentions(lowerMessage, allPatterns, seenCats, mentions, routing_warnings); @@ -1150,7 +1165,9 @@ export class AgentRouter { // P2 (codex review 6949db49): an explicit @handle that matched NO registered cat is an // unknown handle (e.g. @kimi). Without this, parseAllMentions returns empty mentions + empty // warnings, so the caller silently falls back to the default cat with zero user feedback. - if (hasDomainSuffixedMentionPatternAt(lowerMessage, pos, allPatterns)) return; + if (hasDomainSuffixedMentionPatternAt(lowerMessage, pos, allPatterns)) { + return; + } recordUnknownMentionWarning(lowerMessage, pos, seenCats, routing_warnings); }); @@ -1164,9 +1181,37 @@ export class AgentRouter { return { mentions, routing_warnings }; } + private buildGroupMentionKeywordPatterns(): string[] { + const keywords = ['@全体参与者', '@thread', '@本帖', '@全体', '@all']; + for (const [breedId, info] of this.collectBreedGroups()) { + keywords.push(`@全体${info.displayName}`, `@all-${breedId}`); + } + return keywords.map((keyword) => keyword.toLowerCase()).sort((a, b) => b.length - a.length); + } + private parseMentions(message: string): { mentions: CatId[]; routing_warnings: CatRoutingError[] } { const raw = this.parseMentionsRaw(message); - return { mentions: raw.mentions.map((m) => m.catId), routing_warnings: raw.routing_warnings }; + return { + mentions: raw.mentions.map((mention) => mention.catId), + routing_warnings: raw.routing_warnings, + }; + } + + private collectBreedGroups(): Map { + const breedMap = new Map(); + for (const [catId, config] of Object.entries(catRegistry.getAllConfigs())) { + if (!config.breedId || !Object.hasOwn(this.services, catId)) continue; + const existing = breedMap.get(config.breedId); + if (existing) { + existing.catIds.push(catId as CatId); + } else { + breedMap.set(config.breedId, { + displayName: config.breedDisplayName ?? config.displayName, + catIds: [catId as CatId], + }); + } + } + return breedMap; } /** @@ -1182,6 +1227,7 @@ export class AgentRouter { private async parseGroupMentions( message: string, threadId: string, + options?: { allowFallback?: boolean }, ): Promise<{ cats: CatId[]; matchPosition: number } | null> { const lowerMessage = this.normalizeSpeechMentions(message).toLowerCase(); @@ -1214,6 +1260,7 @@ export class AgentRouter { const valid = this.filterRoutableCats(participants); if (valid.length > 0) return valid as CatId[]; } + if (options?.allowFallback === false) return []; const fallback = this.pickFallbackCat(new Set()); return fallback ? [fallback] : []; }, @@ -1221,22 +1268,7 @@ export class AgentRouter { } // Breed-scoped patterns: @全体{displayName} and @all-{breedId} - const allConfigs = catRegistry.getAllConfigs(); - const breedMap = new Map(); - for (const [catId, config] of Object.entries(allConfigs)) { - if (!config.breedId) continue; - if (!Object.hasOwn(this.services, catId)) continue; - const existing = breedMap.get(config.breedId); - if (existing) { - existing.catIds.push(catId as CatId); - } else { - breedMap.set(config.breedId, { - displayName: config.breedDisplayName ?? config.displayName, - catIds: [catId as CatId], - }); - } - } - for (const [breedId, info] of breedMap) { + for (const [breedId, info] of this.collectBreedGroups()) { const catIds = info.catIds; patterns.push({ pattern: `@全体${info.displayName}`, resolve: async () => this.filterRoutableCats(catIds) }); patterns.push({ pattern: `@all-${breedId}`, resolve: async () => this.filterRoutableCats(catIds) }); @@ -1248,6 +1280,7 @@ export class AgentRouter { resolve: async () => { const allCats = this.filterRoutableCats(Object.keys(this.services)); if (allCats.length > 0) return allCats; + if (options?.allowFallback === false) return []; const fallback = this.pickFallbackCat(new Set()); return fallback ? [fallback] : []; }, @@ -1257,6 +1290,7 @@ export class AgentRouter { resolve: async () => { const allCats = this.filterRoutableCats(Object.keys(this.services)); if (allCats.length > 0) return allCats; + if (options?.allowFallback === false) return []; const fallback = this.pickFallbackCat(new Set()); return fallback ? [fallback] : []; }, @@ -1292,8 +1326,11 @@ export class AgentRouter { private async parseAllMentions( message: string, threadId: string, + options?: { allowGroupFallback?: boolean }, ): Promise<{ mentions: CatId[]; routing_warnings: CatRoutingError[] }> { - const groupResult = await this.parseGroupMentions(message, threadId); + const groupResult = await this.parseGroupMentions(message, threadId, { + allowFallback: options?.allowGroupFallback, + }); if (groupResult !== null) { // Position-aware union: merge individual mentions around group based on message position const individual = this.parseMentionsRaw(message); @@ -1417,7 +1454,6 @@ export class AgentRouter { } return mentionedCats; } - if (this.threadStore) { const thread = await this.threadStore.get(threadId); @@ -1525,9 +1561,6 @@ export class AgentRouter { ...(this.conciergeTriagePlanStore ? { conciergeTriagePlanStore: this.conciergeTriagePlanStore } : {}), ...(this.cloudInvokeBridge ? { cloudInvokeBridge: this.cloudInvokeBridge } : {}), ...(this.cloudReturnGrantStore ? { cloudReturnGrantStore: this.cloudReturnGrantStore } : {}), - ...(this.a2aDispatchDispositionService - ? { a2aDispatchDispositionService: this.a2aDispatchDispositionService } - : {}), ...(this.freshnessReinvokeCheck ? { freshnessReinvokeCheck: this.freshnessReinvokeCheck } : {}), ...(this.freshnessStateStore ? { freshnessStateStore: this.freshnessStateStore } : {}), ...(this.providerNativeFreshnessFactory @@ -1542,6 +1575,9 @@ export class AgentRouter { messageStore: this.messageStore, deliveryCursorStore: this.deliveryCursorStore, ...(this.taskStore ? { taskStore: this.taskStore } : {}), + ...(this.threadExecutionSituationSource + ? { threadExecutionSituationSource: this.threadExecutionSituationSource } + : {}), ...(this.draftStore ? { draftStore: this.draftStore } : {}), ...(this.socketManager ? { socketManager: this.socketManager } : {}), ...(this.packStore ? { packStore: this.packStore } : {}), @@ -1577,18 +1613,33 @@ export class AgentRouter { async resolveTargetsAndIntent( message: string, threadId?: string, - options?: { persist?: boolean }, - ): Promise<{ targetCats: CatId[]; intent: IntentResult; hasMentions: boolean; routing_warnings: CatRoutingError[] }> { + options?: { persist?: boolean; allowFallback?: boolean }, + ): Promise<{ + targetCats: CatId[]; + intent: IntentResult; + hasMentions: boolean; + routing_warnings: CatRoutingError[]; + }> { const resolvedThreadId = threadId ?? DEFAULT_THREAD_ID; // Capture both valid mentions AND routing_warnings (for disabled/not-found cats). // routing_warnings lets callers (e.g. messages.ts) surface explicit feedback when // a user's @mention silently fell back to a different cat (Thread 1 bug: @kimi→opus). - const allMentions = await this.parseAllMentions(message, resolvedThreadId); + const allMentions = await this.parseAllMentions(message, resolvedThreadId, { + allowGroupFallback: options?.allowFallback !== false, + }); const hasMentions = allMentions.mentions.length > 0; const routing_warnings = allMentions.routing_warnings; - const targetCats = options?.persist - ? await this.resolveTargets(message, resolvedThreadId) - : await this.peekTargets(message, resolvedThreadId); + let targetCats: CatId[]; + if (options?.allowFallback === false) { + targetCats = allMentions.mentions; + if (options.persist && targetCats.length > 0 && this.threadStore) { + await this.threadStore.addParticipants(resolvedThreadId, targetCats); + } + } else { + targetCats = options?.persist + ? await this.resolveTargets(message, resolvedThreadId) + : await this.peekTargets(message, resolvedThreadId); + } const intent = parseIntent(message, targetCats.length); return { targetCats, intent, hasMentions, routing_warnings }; } @@ -1605,11 +1656,12 @@ export class AgentRouter { contentBlocks?: readonly MessageContent[], uploadDir?: string, signal?: AbortSignal, - a2aOptions?: A2ASlotTrackingOptions & Pick, + a2aOptions?: Pick, ): AsyncIterable { const resolvedThreadId = threadId ?? DEFAULT_THREAD_ID; - const targetCats = await this.resolveTargets(message, resolvedThreadId); - const intent = parseIntent(message, targetCats.length); + const { targetCats, intent } = await this.resolveTargetsAndIntent(message, resolvedThreadId, { + persist: true, + }); const strategy = intent.intent === 'ideate' && targetCats.length > 1 ? 'parallel' : 'serial'; const cleanMessage = appendContextAttachmentsToPrompt(stripIntentTags(message), contentBlocks); @@ -1632,8 +1684,8 @@ export class AgentRouter { } const storedUserMessage = await this.messageStore.append({ + from: { kind: 'user', userId }, userId, - catId: null, content: message, // Store original (with tags) for audit mentions: targetCats, timestamp: Date.now(), @@ -1738,70 +1790,11 @@ export class AgentRouter { userMessageId: string, targetCats: CatId[], intent: IntentResult, - options: A2ASlotTrackingOptions & { - /** Authentication-grade owner provenance; legacy/system producers pass unknown. */ - ownerAuthProvenance: NonNullable; - /** F167 Phase T: turn-scoped protocol carrier for the structured stop gate. */ - turnCustodyWake?: RouteOptions['turnCustodyWake']; - turnCustodyWakeForCat?: RouteOptions['turnCustodyWakeForCat']; - contentBlocks?: readonly MessageContent[]; - uploadDir?: string; - signal?: AbortSignal; - /** F-parallel-cancel: per-cat signal resolver — route-parallel gives each concurrent - * cat its own slot signal so canceling one cat does not abort its siblings. */ - signalForCat?: (catId: CatId) => AbortSignal | undefined; - queueHasQueuedMessages?: (threadId: string) => boolean; - getQueuedFreshnessMessagesForCat?: RouteOptions['getQueuedFreshnessMessagesForCat']; - hasQueuedOrActiveAgentForCat?: (threadId: string, catId: string) => boolean; - hasPendingForCat?: (threadId: string, userId: string, catId: string) => boolean; - /** F185 Phase B: deferred A2A enqueue when fairness gate blocks text-scan expansion */ - deferA2AEnqueue?: RouteOptions['deferA2AEnqueue']; - /** ADR-008 S3: pass a Map to collect cursor boundaries; caller acks after succeeded */ - cursorBoundaries?: Map; - /** P1-2: pass to track persistence failures across generator boundary */ - persistenceContext?: PersistenceContext; - /** F167 Phase S: generation/terminal fence immediately before route output commit. */ - beforeOutputCommit?: RouteOptions['beforeOutputCommit']; - /** F108: parentInvocationId for WorklistRegistry concurrent isolation */ - parentInvocationId?: string; - /** Required for every direct invocation path so prompt exposure cannot silently bypass Queue custody. */ - onPromptMessagesExposed: NonNullable; - /** Exact persisted bodies already folded into `message` by the queue caller. */ - persistedPromptMessageIds?: RouteOptions['persistedPromptMessageIds']; - /** Per-message ownership for partial incremental Queue windows. */ - persistedPromptMessages?: RouteOptions['persistedPromptMessages']; - /** F281: required on typed first-party ingress; only direct_owner is injectable. */ - humanDispositionInvocationOrigin: HumanDispositionInvocationOrigin; - routingQueueSource?: RouteOptions['routingQueueSource']; - /** F153: caller trace context for cross-route A2A propagation */ - callerTraceContext?: CallerTraceContext; - /** Explicit A2A trigger message ID for queue-dispatched stream reply threading */ - a2aTriggerMessageId?: string; - /** Server-owned caller identity paired with the exact A2A trigger. */ - a2aCallerCatId?: string; - /** Exact per-target cloud source carrier from callback/Queue custody. */ - cloudDispatchProvenance?: RouteOptions['cloudDispatchProvenance']; - /** Fail cloud dispatch visibly when the exact multi-mention carrier is absent. */ - requiresExactCloudDispatchProvenance?: RouteOptions['requiresExactCloudDispatchProvenance']; - /** F222 P1: Whether this route is eligible for frustration auto-issue detection. - * true/undefined = user-origin (eligible, default for backward compat). - * false = agent/connector-origin (A2A handoff) — suppress detection. */ - frustrationAutoIssueEligible?: boolean; - /** #949 P2: Whether verdict-without-pass warning fires at route end. - * true/undefined = warn (default). false = suppress for connector-sourced flows only. */ - verdictPassWarningEnabled?: boolean; - /** F254 B3: Freshness re-invoke enqueue for routing layer consumption */ - freshnessReinvokeEnqueue?: RouteOptions['freshnessReinvokeEnqueue']; - freshnessSupplementId?: RouteOptions['freshnessSupplementId']; - freshnessSupplementRequiredMessageIds?: RouteOptions['freshnessSupplementRequiredMessageIds']; - toolExecutionPolicy?: RouteOptions['toolExecutionPolicy']; - executionScope?: RouteOptions['executionScope']; - memoryCueOpportunitySeeds?: RouteOptions['memoryCueOpportunitySeeds']; - asrPersonMemoryScenes?: RouteOptions['asrPersonMemoryScenes']; - }, + options: RouteExecutionOptions, ): AsyncIterable { const cleanMessage = appendContextAttachmentsToPrompt(stripIntentTags(message), options.contentBlocks); - const strategy = intent.intent === 'ideate' && targetCats.length > 1 ? 'parallel' : 'serial'; + const strategy = + options.targetDispatchMode ?? (intent.intent === 'ideate' && targetCats.length > 1 ? 'parallel' : 'serial'); // F153: Reconstruct remote parent context for cross-route A2A trace propagation const parentCtx = options?.callerTraceContext @@ -1889,60 +1882,17 @@ export class AgentRouter { } const strategyDeps = this.getStrategyDeps(); - const routingContextIntent = inferRoutingContextIntent(cleanMessage); - const routeOptions = { - routeIntent: projectAgentRouteIntent(intent), - ...(routingContextIntent ? { routingContextIntent } : {}), - ownerAuthProvenance: options.ownerAuthProvenance, - ...(options?.turnCustodyWake ? { turnCustodyWake: options.turnCustodyWake } : {}), - ...(options?.turnCustodyWakeForCat ? { turnCustodyWakeForCat: options.turnCustodyWakeForCat } : {}), - contentBlocks: options?.contentBlocks, - uploadDir: options?.uploadDir, - signal: options?.signal, - signalForCat: options?.signalForCat, - queueHasQueuedMessages: options?.queueHasQueuedMessages, - getQueuedFreshnessMessagesForCat: options?.getQueuedFreshnessMessagesForCat, - hasQueuedOrActiveAgentForCat: options?.hasQueuedOrActiveAgentForCat, - hasPendingForCat: options?.hasPendingForCat, - deferA2AEnqueue: options?.deferA2AEnqueue, - freshnessReinvokeEnqueue: options?.freshnessReinvokeEnqueue, - freshnessSupplementId: options?.freshnessSupplementId, - freshnessSupplementRequiredMessageIds: options?.freshnessSupplementRequiredMessageIds, - toolExecutionPolicy: options?.toolExecutionPolicy, - executionScope: options?.executionScope, - memoryCueOpportunitySeeds: options?.memoryCueOpportunitySeeds, - asrPersonMemoryScenes: options?.asrPersonMemoryScenes, - invocationController: options?.invocationController, - trackA2ASlot: options?.trackA2ASlot, - completeA2ASlots: options?.completeA2ASlots, + const { callerTraceContext: _callerTraceContext, ...strategyInputOptions } = options; + const routeOptions: RouteOptions = { + ...strategyInputOptions, promptTags: intent.promptTags, currentUserMessageId: userMessageId, - persistedPromptMessageIds: options?.persistedPromptMessageIds, persistedPromptMessages: options?.persistedPromptMessages?.map((persisted) => ({ ...persisted, content: stripIntentTags(persisted.content), })), - a2aTriggerMessageId: options?.a2aTriggerMessageId, - a2aCallerCatId: options?.a2aCallerCatId, - cloudDispatchProvenance: options?.cloudDispatchProvenance, - requiresExactCloudDispatchProvenance: options?.requiresExactCloudDispatchProvenance, - humanDispositionInvocationOrigin: options.humanDispositionInvocationOrigin, - ...(options.routingQueueSource ? { routingQueueSource: options.routingQueueSource } : {}), thinkingMode, - ...(options?.cursorBoundaries ? { cursorBoundaries: options.cursorBoundaries } : {}), - ...(options?.persistenceContext ? { persistenceContext: options.persistenceContext } : {}), - ...(options?.beforeOutputCommit ? { beforeOutputCommit: options.beforeOutputCommit } : {}), - ...(options?.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}), - ...(options?.onPromptMessagesExposed ? { onPromptMessagesExposed: options.onPromptMessagesExposed } : {}), routeSpan, - // F222 P1: thread provenance flag so route-serial/route-parallel can gate detection - ...(options?.frustrationAutoIssueEligible !== undefined - ? { frustrationAutoIssueEligible: options.frustrationAutoIssueEligible } - : {}), - // #949 P2: connector-sourced verdict-pass warning suppression - ...(options?.verdictPassWarningEnabled !== undefined - ? { verdictPassWarningEnabled: options.verdictPassWarningEnabled } - : {}), }; try { diff --git a/packages/api/src/domains/cats/services/agents/routing/WorklistRegistry.ts b/packages/api/src/domains/cats/services/agents/routing/WorklistRegistry.ts index 3be265a3f2..0c6b7532a0 100644 --- a/packages/api/src/domains/cats/services/agents/routing/WorklistRegistry.ts +++ b/packages/api/src/domains/cats/services/agents/routing/WorklistRegistry.ts @@ -1,68 +1,27 @@ /** - * WorklistRegistry — per-invocation worklist for A2A unification (F27 + F108) + * WorklistRegistry — live per-invocation A2A guard state. * - * When routeSerial is running, it registers its worklist here. - * Callback A2A triggers (MCP post_message with @mention) push - * targets into the worklist instead of spawning independent invocations. + * A2A execution itself is owned by the durable message_wake Queue path. This + * registry only lets callbacks in the currently running invocation share the + * depth/ping-pong projection rebuilt from persisted causal history. * - * F108: Registry key is `parentInvocationId` (unique per invocation) when - * provided, falling back to `threadId` for backward compatibility. - * A reverse index (threadId → Set) enables thread-level - * lookups (`hasWorklist(threadId)`) for routing decisions. + * Registry key is `parentInvocationId` (unique per invocation) when provided, + * with `threadId` as the direct-callback coordinate. A reverse index lets a + * fresh user input reset all live streak projections for its thread. * - * All A2A chains share the parent's AbortController, isFinal semantics, - * and MAX_A2A_DEPTH limit. + * It does not own or extend an execution worklist. */ import type { CatId } from '@cat-cafe/shared'; -import { l1StreakBreakCount, l1StreakWarnCount } from '../../../../../infrastructure/telemetry/instruments.js'; - -/** F122: Structured result from pushToWorklist — reason explains empty adds */ -export type PushReason = - | 'not_found' - | 'depth_limit' - | 'caller_mismatch' - | 'caller_admission_closed' - | 'all_duplicate' - | 'pingpong_terminated'; -export interface PushResult { - added: CatId[]; - reason?: PushReason; - /** F167 L1: streak >= 2 but < 4 — downstream must inject ping-pong warning prompt */ - warnPingPong?: boolean; - /** F167 L1: streak >= 4 — push was rejected; caller must emit pingpong_terminated system msg */ - blockPingPong?: boolean; - /** F167 L1: current pair count (present when warnPingPong / blockPingPong is set) */ - pairCount?: number; -} export interface WorklistEntry { - /** The mutable worklist array — push to extend */ - list: CatId[]; - /** Number of original user-selected targets at registration time */ - originalCount: number; - /** A2A depth counter — incremented on each push */ + /** A2A depth reconstructed from durable response ancestry. */ a2aCount: number; /** Max allowed A2A depth */ maxDepth: number; - /** Index of the cat currently being executed (updated by routeSerial). - * Used for dedup: cats already executed can be re-enqueued. */ - executedIndex: number; - /** False between the final admission drain and the next route turn. */ - callerAdmissionOpen: boolean; - /** - * A2A sender mapping — for each enqueued target, record who @mentioned it. - * Used to inject "Direct message from ...; reply to ..." into the target's prompt. - */ - a2aFrom: Map; - /** - * A2A trigger message mapping — for each enqueued target, record which message triggered it. - * Used by auto-replyTo to thread replies back to the triggering @mention message. - */ - a2aTriggerMessageId: Map; /** * F167 L1: ping-pong streak tracking — records the last same-pair (A↔B) push run. - * Incremented on every 1-target A2A push where {caller, target} matches this pair + * Incremented on every admitted 1-target A2A wake where {caller, target} matches this pair * (order-insensitive). Reset to {new pair, count=1} when the pair changes, or * cleared by `resetStreak()` (called on user messages). See `streakPair.count` * thresholds: >=2 warn, >=4 block. @@ -102,7 +61,7 @@ function samePair(a: { from: CatId; to: CatId }, bFrom: CatId, bTo: CatId): bool return (a.from === bFrom && a.to === bTo) || (a.from === bTo && a.to === bFrom); } -/** F167 L1: shared streak check — used by both pushToWorklist (callback path) and route-serial (inline enqueue). */ +/** Shared streak verdict used by callback admission and completed-response admission. */ export interface StreakResult { warnPingPong: boolean; blockPingPong: boolean; @@ -151,7 +110,7 @@ function streakVerdict(count: number): StreakResult { * different/new pair → 1. */ function predictStreakCount( - entry: WorklistEntry, + entry: Pick, callerCatId: CatId, target: CatId, activity?: CallerActivity, @@ -163,13 +122,12 @@ function predictStreakCount( } /** - * F216 c1.1: read-only streak prediction — computes the verdict updateStreakOnPush WOULD return, - * without mutating entry.streakPair. Lets resolveRoutingDecisions stay a pure decision function - * (砚砚 OQ3); the actual mutation happens in the execution layer via updateStreakOnPush. + * Read-only streak prediction. Durable admission must decide before it mutates + * either the live guard state or Queue custody. * `wouldBlock` mirrors StreakResult.blockPingPong (parity test guards this). */ export function peekStreakOnPush( - entry: WorklistEntry, + entry: Pick, callerCatId: CatId, target: CatId, activity?: CallerActivity, @@ -181,7 +139,7 @@ export function peekStreakOnPush( /** F167 L1: ping-pong streak record — see WorklistEntry.streakPair. Mutates entry.streakPair. */ export function updateStreakOnPush( - entry: WorklistEntry, + entry: Pick, callerCatId: CatId, target: CatId, activity?: CallerActivity, @@ -213,25 +171,21 @@ function registryKey(threadId: string, parentInvocationId?: string): string { * Register a worklist for an invocation. Called by routeSerial at start. * Returns the entry for routeSerial to read a2aCount updates. * - * @param parentInvocationId - F108: unique invocation ID for concurrent isolation. - * When omitted, threadId is used as the key (backward compat). + * @param parentInvocationId - unique invocation ID for concurrent isolation. + * When omitted, the direct callback is coordinated by thread ID. */ export function registerWorklist( threadId: string, - worklist: CatId[], + _worklist: readonly CatId[], maxDepth: number, parentInvocationId?: string, + initialLineage?: Pick, ): WorklistEntry { const key = registryKey(threadId, parentInvocationId); const entry: WorklistEntry = { - list: worklist, - originalCount: worklist.length, - a2aCount: 0, + a2aCount: initialLineage?.a2aCount ?? 0, maxDepth, - executedIndex: 0, - callerAdmissionOpen: true, - a2aFrom: new Map(), - a2aTriggerMessageId: new Map(), + ...(initialLineage?.streakPair ? { streakPair: { ...initialLineage.streakPair } } : {}), }; registry.set(key, entry); @@ -246,11 +200,6 @@ export function registerWorklist( return entry; } -/** WorklistRegistry owns authorization; routeSerial supplies the lifecycle boundary. */ -export function setWorklistCallerAdmissionOpen(entry: WorklistEntry, open: boolean): void { - entry.callerAdmissionOpen = open; -} - /** * Unregister worklist for an invocation. Called by routeSerial on exit. * Owner check: only removes if the stored entry matches the caller's entry. @@ -273,114 +222,6 @@ export function unregisterWorklist(threadId: string, owner?: WorklistEntry, pare } } -/** - * Push cats to an invocation's worklist (callback A2A path). - * Dedup only against pending (not-yet-executed) portion — cats that already - * ran can be re-enqueued for another round (e.g. A→B→A review ping-pong). - * - * Caller guard (cloud Codex P1): if `callerCatId` is provided, only the cat - * currently being executed by routeSerial may push to the worklist. This - * prevents stale callbacks from a preempted invocation from injecting targets - * into a newer invocation's worklist. - * - * @param parentInvocationId - F108: target specific invocation's worklist. - * When omitted, uses threadId as key (backward compat). - * - * F122: Returns structured PushResult with reason explaining empty adds. - */ -export function pushToWorklist( - threadId: string, - cats: CatId[], - callerCatId?: CatId, - parentInvocationId?: string, - triggerMessageId?: string, - callerActivity?: CallerActivity, -): PushResult { - const key = registryKey(threadId, parentInvocationId); - const entry = registry.get(key); - if (!entry) return { added: [], reason: 'not_found' }; - - // Caller authorization: only the currently-executing cat may push - if (callerCatId !== undefined) { - if (!entry.callerAdmissionOpen) { - return { added: [], reason: 'caller_admission_closed' }; - } - const currentCat = entry.list[entry.executedIndex]; - if (currentCat !== callerCatId) return { added: [], reason: 'caller_mismatch' }; - } - - // Only dedup against pending tail (from executedIndex onward) — computed - // first so streak check can peek at the outcome (cloud Codex P1). - const pending = entry.list.slice(entry.executedIndex); - - // F167 L1 + Phase D: ping-pong streak — only 1:1 A2A pushes count (caller + single target). - // Fan-out (multi-target) and non-A2A (no caller) pushes never update or consult streak. - // Streak mutation MUST be gated on actual enqueue (post-dedup, post-depth) — - // otherwise a push that gets skipped could still reset or increment the counter, - // which would let repeated dedup'd long-content callbacks "clear" a near-blocked - // streak without a real handoff (cloud Codex P1 on PR #1349). - let warnPingPong = false; - let pairCount = 0; - if (callerCatId !== undefined && cats.length === 1) { - const target = cats[0]; - const wouldEnqueue = entry.a2aCount < entry.maxDepth && !pending.includes(target); - if (wouldEnqueue) { - const streak = updateStreakOnPush(entry, callerCatId, target, callerActivity); - if (streak.blockPingPong) { - l1StreakBreakCount.add(1); - return { - added: [], - reason: 'pingpong_terminated', - blockPingPong: true, - pairCount: streak.count, - }; - } - warnPingPong = streak.warnPingPong; - if (streak.warnPingPong) l1StreakWarnCount.add(1); - pairCount = streak.count; - } - // else: dedup or depth would skip → do not touch streak state - } - - const added: CatId[] = []; - let hitDepthLimit = false; - for (const cat of cats) { - if (entry.a2aCount >= entry.maxDepth) { - hitDepthLimit = true; - break; - } - if (!pending.includes(cat)) { - entry.list.push(cat); - entry.a2aCount++; - added.push(cat); - pending.push(cat); // Keep local dedup view in sync - if (callerCatId !== undefined) { - entry.a2aFrom.set(cat, callerCatId); - } - if (triggerMessageId !== undefined) { - entry.a2aTriggerMessageId.set(cat, triggerMessageId); - } - } else if (callerCatId !== undefined) { - // Target already pending: - // - original targets must keep replying to user (no A2A sender override) - // - A2A-enqueued targets may update to latest sender before execution - const existingIndex = entry.list.findIndex((id, idx) => idx >= entry.executedIndex && id === cat); - const isOriginalPendingTarget = existingIndex !== -1 && existingIndex < entry.originalCount; - if (!isOriginalPendingTarget) { - entry.a2aFrom.set(cat, callerCatId); - // F121: Keep triggerMessageId in sync with a2aFrom (same "latest sender" semantics) - if (triggerMessageId !== undefined) { - entry.a2aTriggerMessageId.set(cat, triggerMessageId); - } - } - } - } - if (added.length === 0) { - return { added: [], reason: hitDepthLimit ? 'depth_limit' : 'all_duplicate' }; - } - return warnPingPong ? { added, warnPingPong: true, pairCount } : { added }; -} - /** * F167 L1: Reset streak for a thread's active worklist (user-message hook). * Called when a user message arrives — fresh turn, streak shouldn't carry over. @@ -405,16 +246,10 @@ export function resetStreak(threadId: string, parentInvocationId?: string): void } } -/** Check if a thread has any active worklist (any invocation running). */ -export function hasWorklist(threadId: string): boolean { - const keys = threadIndex.get(threadId); - return keys !== undefined && keys.size > 0; -} - /** * Get the worklist entry for a specific invocation or thread. - * @param parentInvocationId - F108: get specific invocation's worklist. - * When omitted, uses threadId as key (backward compat / legacy single-invocation). + * @param parentInvocationId - get a specific invocation's live guard state. + * When omitted, uses the direct callback's thread coordinate. */ export function getWorklist(threadId: string, parentInvocationId?: string): WorklistEntry | undefined { const key = registryKey(threadId, parentInvocationId); diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-handoff-label.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-handoff-label.ts index 062f53c5bc..80f1301031 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-handoff-label.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-handoff-label.ts @@ -8,24 +8,14 @@ export function formatA2AHandoffCatLabel(catId: CatId | string, config?: Handoff return qualifier ? `${displayName}(${qualifier})` : displayName; } -/** - * F086/F216: the arrow glyph IS part of the honesty contract. - * `→` reads as "the ball moved here"; drawing it twice at the same millisecond for a - * sequential worklist is what made #1291's two-target dispatch look like a fan-out. - * A queued serial leg uses `⇢` (dashed = not started yet); parallel legs use `⇉`. - */ function handoffArrow(projection: A2ARoutingProjection | undefined): string { if (!projection || projection.total <= 1) return '→'; - if (projection.mode === 'parallel') return '⇉'; - return projection.index <= 1 ? '→' : '⇢'; + return '⇉'; } function handoffSuffix(projection: A2ARoutingProjection | undefined): string { if (!projection || projection.total <= 1) return ''; - if (projection.mode === 'parallel') return `(并行 ${projection.index}/${projection.total})`; - return projection.index <= 1 - ? `(串行 ${projection.index}/${projection.total})` - : `(串行 ${projection.index}/${projection.total}·排队中)`; + return `(并行 ${projection.index}/${projection.total})`; } export function formatA2AHandoffContent( @@ -39,22 +29,3 @@ export function formatA2AHandoffContent( const to = formatA2AHandoffCatLabel(toCatId, toConfig); return `${from} ${handoffArrow(projection)} ${to}${handoffSuffix(projection)}`; } - -/** - * Non-silent diagnostic for the legacy "several plain line-start @" shape. - * - * Requirement 4 of the field report: legacy multi-@ must not keep silently impersonating - * parallel. We do NOT reject it (the @-chain relay convention depends on it) and we do NOT - * read the prose for the word "并行" — we normalize it to the semantics the runtime already - * executes (serial) and say so out loud, with the structured escape hatch named. - */ -export function formatSerialMultiTargetNotice( - order: Array<{ catId: CatId | string; config?: HandoffCatConfig }>, -): string { - const legs = order.map((t, i) => `第 ${i + 1} 棒 ${formatA2AHandoffCatLabel(t.catId, t.config)}`).join(' → '); - return [ - `本回合有 ${order.length} 个行首 @ 目标,已按 **串行(serial)** 调度:${legs}。`, - '行首 @mention 在 runtime 里是一条有序 worklist —— 第 2 棒要等第 1 棒整个回合结束才会拿到 invocation,正文写"并行"不会改变调度。', - '需要真正的并行 fan-out(每个目标各自拿到 invocation/Queue custody、互不阻塞)请显式调用 `cat_cafe_multi_mention(mode="parallel", parallelIntent=...)`。', - ].join('\n'); -} diff --git a/packages/api/src/domains/cats/services/agents/routing/cloud-outbound-receipt-provenance.ts b/packages/api/src/domains/cats/services/agents/routing/cloud-outbound-receipt-provenance.ts index 45f9bd84b1..e03cfdab4d 100644 --- a/packages/api/src/domains/cats/services/agents/routing/cloud-outbound-receipt-provenance.ts +++ b/packages/api/src/domains/cats/services/agents/routing/cloud-outbound-receipt-provenance.ts @@ -89,11 +89,18 @@ async function resolveReceiptSource( isInternalNonQuotableParent(queued) || queued.userId === 'scheduler' || !canQuoteInPublicReply(queued) || - !targetCatId || - !queued.queueCustody?.bodyExposures?.some( - (exposure) => exposure.targetCatId === targetCatId && exposure.invocationId === dispatchInvocationId, - ) + !targetCatId ) return null; + const dispatch = queued.lifecycle?.dispatchRefs?.find((ref) => ref.targetId === targetCatId); + if (!dispatch) return null; + const response = await store.getById(dispatch.statusMessageId); + if ( + response?.lifecycle?.kind !== 'response' || + response.lifecycle.targetId !== targetCatId || + response.lifecycle.producerInvocationId !== dispatchInvocationId + ) { + return null; + } return queued; } diff --git a/packages/api/src/domains/cats/services/agents/routing/durable-a2a-lineage.ts b/packages/api/src/domains/cats/services/agents/routing/durable-a2a-lineage.ts new file mode 100644 index 0000000000..a686e3b6a8 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/routing/durable-a2a-lineage.ts @@ -0,0 +1,84 @@ +import type { CatId } from '@cat-cafe/shared'; +import { messageFrom } from '../../stores/message-from.js'; +import type { AppendMessageInput, IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; +import type { CallerActivity } from './WorklistRegistry.js'; +import { isSubstantiveTool } from './WorklistRegistry.js'; + +export interface DurableA2ALineage { + /** Number of already-completed A2A edges before the receiving turn. */ + depth: number; + /** Last unordered pair streak, replayed from persisted response ancestry. */ + streakPair?: { from: CatId; to: CatId; count: number }; +} + +const MAX_CAUSAL_SCAN = 256; +const SUBSTANTIVE_OUTPUT_LENGTH = 200; + +function samePair(a: { from: CatId; to: CatId }, from: CatId, to: CatId): boolean { + return (a.from === from && a.to === to) || (a.from === to && a.to === from); +} + +export function callerActivityFromMessage( + message: Pick, +): CallerActivity { + return { + hadSubstantiveToolCall: + message.toolEvents?.some( + (event) => event.type === 'tool_use' && isSubstantiveTool(event.toolName ?? event.label), + ) ?? false, + outputLength: message.content.length, + }; +} + +function isSubstantiveActivity(activity: CallerActivity): boolean { + return activity.hadSubstantiveToolCall || activity.outputLength > SUBSTANTIVE_OUTPUT_LENGTH; +} + +/** + * Rebuild A2A depth and ping-pong state from the canonical response causal chain. + * Each persisted response points at the message that woke it, so the guard survives + * Queue drains, process restarts, and the removal of the old mutable inline worklist. + */ +export async function readDurableA2ALineage( + store: Pick, + triggerMessageId: string, + receivingCatId: CatId, +): Promise { + const newestFirst: Array<{ + from: CatId; + to: CatId; + activity: CallerActivity; + }> = []; + const visited = new Set(); + let cursorId: string | undefined = triggerMessageId; + let recipient = receivingCatId; + + while (cursorId && newestFirst.length < MAX_CAUSAL_SCAN && !visited.has(cursorId)) { + visited.add(cursorId); + const message = await store.getById(cursorId); + const from = message ? messageFrom(message) : null; + const authorCatId = from?.kind === 'agent' ? from.catId : null; + if (!message || message.lifecycle?.kind !== 'response' || !authorCatId) break; + newestFirst.push({ + from: authorCatId as CatId, + to: recipient, + activity: callerActivityFromMessage(message), + }); + recipient = authorCatId as CatId; + cursorId = message.extra?.causal?.triggerMessageId; + } + + let streakPair: DurableA2ALineage['streakPair']; + for (const edge of newestFirst.reverse()) { + const count = + streakPair && samePair(streakPair, edge.from, edge.to) && !isSubstantiveActivity(edge.activity) + ? streakPair.count + 1 + : 1; + streakPair = { from: edge.from, to: edge.to, count }; + } + + return { + depth: newestFirst.length, + ...(streakPair ? { streakPair } : {}), + }; +} diff --git a/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts b/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts index 2f9e366a78..2d2d736c0d 100644 --- a/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts +++ b/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts @@ -10,7 +10,7 @@ * - 豁免只走结构边界(fenced code / blockquote / URL),禁止动作词表 / 语义豁免表 * * 与 verdict-detect.ts(AC-C7)同构,但更严格的纯机械判定,命中时 route-serial - * 会 suppress AC-C7 + 既有 #417 inline-mention-hint(格式错是根因)。 + * 会 suppress 后续重复 correction signal(格式错是根因)。 */ export interface ValidationInput { diff --git a/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts b/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts index 3f30ef80ef..1870437202 100644 --- a/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts +++ b/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts @@ -258,9 +258,9 @@ export function buildBriefingMessage( const rich: RichMessageExtra = { v: 1, blocks: [card] }; return { + from: { kind: 'system', service: 'context-briefing' }, threadId, userId: 'system', - catId: null, content: navTitle, mentions: [], timestamp: Date.now(), diff --git a/packages/api/src/domains/cats/services/agents/routing/guards/event-backed-routing-exit.ts b/packages/api/src/domains/cats/services/agents/routing/guards/event-backed-routing-exit.ts new file mode 100644 index 0000000000..f53a340dd2 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/routing/guards/event-backed-routing-exit.ts @@ -0,0 +1,60 @@ +import { resolveTypedWaitContinuation } from '../../../../../ball-custody/TypedWaitContinuation.js'; +import type { TypedWaitReference } from '../../../../../ball-custody/TypedWaitRegistration.js'; +import type { ITaskStore } from '../../../stores/ports/TaskStoreContract.js'; + +export type EventBackedRoutingExitRejectReason = + | 'state_source_unavailable' + | 'missing_identity' + | 'no_candidate' + | 'query_failed'; + +export type EventBackedRoutingExitResolution = + | { kind: 'bypass'; reference: TypedWaitReference } + | { kind: 'reject'; reason: EventBackedRoutingExitRejectReason }; + +export interface EventBackedRoutingSourceIdentity { + sourceMessageId: string; + holdTaskId?: string; +} + +interface ResolveEventBackedRoutingExitInput { + taskStore: Pick | undefined; + userId: string; + threadId: string; + catId: string; + invocationId: string | undefined; + sources: readonly EventBackedRoutingSourceIdentity[]; + now?: number; +} + +/** + * An event-backed routing exit is authoritative only when one current private + * Task registration binds this exact child invocation and one of its exact + * lifecycle input messages. Public Task fields alone never grant the bypass. + */ +export async function resolveEventBackedRoutingExit( + input: ResolveEventBackedRoutingExitInput, +): Promise { + if (!input.taskStore?.getWaitRegistration) return { kind: 'reject', reason: 'state_source_unavailable' }; + if (!input.invocationId || input.sources.length === 0) return { kind: 'reject', reason: 'missing_identity' }; + + let observedReject: EventBackedRoutingExitRejectReason = 'no_candidate'; + for (const source of input.sources) { + const resolution = await resolveTypedWaitContinuation({ + taskStore: input.taskStore, + invocationId: input.invocationId, + userId: input.userId, + catId: input.catId, + threadId: input.threadId, + sourceMessageId: source.sourceMessageId, + ...(source.holdTaskId ? { holdTaskId: source.holdTaskId } : {}), + ...(input.now === undefined ? {} : { now: input.now }), + }); + if (resolution.kind === 'bypass') return resolution; + if (resolution.reason === 'query_failed' || resolution.reason === 'state_source_unavailable') { + return resolution; + } + observedReject = resolution.reason; + } + return { kind: 'reject', reason: observedReject }; +} diff --git a/packages/api/src/domains/cats/services/agents/routing/navigation-context.ts b/packages/api/src/domains/cats/services/agents/routing/navigation-context.ts index 0c0a551687..b20fb68758 100644 --- a/packages/api/src/domains/cats/services/agents/routing/navigation-context.ts +++ b/packages/api/src/domains/cats/services/agents/routing/navigation-context.ts @@ -2,6 +2,7 @@ import { getCoCreatorConfig } from '../../../../../config/cat-config-loader.js'; import { getSenderName } from '../../context/ContextAssembler.js'; import { renderSegment } from '../../context/prompt-template-loader.js'; import { formatPromptTime } from '../../format-time.js'; +import type { ThreadExecutionSituation } from '../invocation/thread-execution-situation.js'; import { formatThreadContextDrill } from './thread-drill-pointer.js'; export interface BatonContext { @@ -98,8 +99,10 @@ export interface TruthSourceInfo { export interface NavigationContext { threadId?: string; + currentCatId?: string; baton: BatonContext | null; tasks: TaskSummary[]; + executionSituation?: ThreadExecutionSituation; artifacts?: NavigationArtifact[]; truthSource?: TruthSourceInfo | null; bestNextSource?: string; @@ -109,6 +112,21 @@ export interface NavigationFormatOptions { nowMs?: number; } +function situationSourceLabel(from: import('@cat-cafe/shared').MessageFrom): string { + switch (from.kind) { + case 'user': + return 'co-creator'; + case 'agent': + return getSenderName(from.catId); + case 'external': + return `external:${from.connectorId}`; + case 'plugin': + return `plugin:${from.instanceId}`; + case 'system': + return `system:${from.service}`; + } +} + /** * Build navigation inner content (conditional blocks). * Template envelope ([导航]...[/导航]) is in n1-navigation.md. @@ -143,6 +161,25 @@ function buildNavigationInner(ctx: NavigationContext, options?: NavigationFormat } } + if (ctx.executionSituation) { + const visibleRuns = ctx.executionSituation.activeRuns.filter((run) => run.targetId !== ctx.currentCatId); + if (!ctx.executionSituation.complete) { + lines.push('成员运行态: 当前无法完整验证(不按最近发言推断)'); + } else if (visibleRuns.length === 0) { + lines.push('成员运行态: 当前无其他成员执行'); + } else { + lines.push('成员运行态(生命周期真相):'); + for (const run of visibleRuns) { + const sources = run.sources + .map((source) => `${source.messageId} ← ${situationSourceLabel(source.from)}`) + .join(', '); + lines.push( + ` - [processing] ${getSenderName(run.targetId)} (${run.targetId}) · 来源 ${sources} · response=${run.responseMessageId} · invocation=${run.invocationId}`, + ); + } + } + } + if (ctx.artifacts && ctx.artifacts.length > 0) { lines.push('最近产物:'); for (const a of ctx.artifacts) { diff --git a/packages/api/src/domains/cats/services/agents/routing/persist-system-info-warnings.ts b/packages/api/src/domains/cats/services/agents/routing/persist-system-info-warnings.ts index ffa97966cc..83f9cc6aa5 100644 --- a/packages/api/src/domains/cats/services/agents/routing/persist-system-info-warnings.ts +++ b/packages/api/src/domains/cats/services/agents/routing/persist-system-info-warnings.ts @@ -1,98 +1,17 @@ import { type CloudBridgeOutboundReceiptV1, type CloudBridgeRecoveryV1, + createCatId, isCloudBridgeOutboundReceiptV1, } from '@cat-cafe/shared'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; +import { messageFrom } from '../../stores/message-from.js'; import type { IMessageStore } from '../../stores/ports/MessageStore.js'; -import { validateOutboundReceipt } from './cloud-outbound-receipt-provenance.js'; +import { resolveVisibleReplyParent } from '../../stores/visibility.js'; import type { PersistenceContext } from './route-helpers.js'; const log = createModuleLogger('route-system-info-persistence'); -const SESSION_ROLLOVER_STATUSES = new Set(['pending', 'succeeded', 'failed']); -const SESSION_ROLLOVER_REASONS = new Set(['oversized_retire', 'resume_rejected']); -const SESSION_ROLLOVER_FAILURE_STAGES = new Set([ - 'seal_request', - 'seal_finalize', - 'replacement_create', - 'replacement_bind', -]); - -interface SessionRolloverNoticeMetadata { - readonly rolloverId: string; - readonly status: 'pending' | 'succeeded' | 'failed'; - readonly reason: 'oversized_retire' | 'resume_rejected'; - readonly failureStage?: 'seal_request' | 'seal_finalize' | 'replacement_create' | 'replacement_bind'; -} - -function parseSessionRolloverNotice(parsed: { - type?: unknown; - v?: unknown; - rolloverId?: unknown; - status?: unknown; - reason?: unknown; - failureStage?: unknown; -}): - | { - content: string; - connector: string; - label: string; - icon: string; - tone: 'info' | 'warning'; - idempotencyKey: string; - sessionRollover: SessionRolloverNoticeMetadata; - } - | undefined { - if (parsed.type !== 'session_rollover_lifecycle' || parsed.v !== 1) return undefined; - if ( - typeof parsed.rolloverId !== 'string' || - parsed.rolloverId.length === 0 || - parsed.rolloverId.length > 160 || - !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(parsed.rolloverId) - ) { - return undefined; - } - if (typeof parsed.status !== 'string' || !SESSION_ROLLOVER_STATUSES.has(parsed.status)) return undefined; - if (typeof parsed.reason !== 'string' || !SESSION_ROLLOVER_REASONS.has(parsed.reason)) return undefined; - if (parsed.status === 'failed') { - if (typeof parsed.failureStage !== 'string' || !SESSION_ROLLOVER_FAILURE_STAGES.has(parsed.failureStage)) { - return undefined; - } - } else if (parsed.failureStage !== undefined) { - return undefined; - } - - const status = parsed.status as SessionRolloverNoticeMetadata['status']; - const reason = parsed.reason as SessionRolloverNoticeMetadata['reason']; - const failureStage = parsed.failureStage as SessionRolloverNoticeMetadata['failureStage']; - const content = - status === 'pending' - ? reason === 'oversized_retire' - ? '正在封存上下文载荷过大的原生会话,并准备冷启动替代会话…' - : '正在封存无法恢复的原生会话,并准备冷启动替代会话…' - : status === 'succeeded' - ? reason === 'oversized_retire' - ? '原生会话因上下文载荷过大已自动封存;已切换到新的冷启动会话。' - : '无法恢复的原生会话已自动封存;已切换到新的冷启动会话。' - : '原生会话自动封存与冷切换失败;本轮已在发送新 prompt 前停止。'; - - return { - content, - connector: 'session-rollover-lifecycle', - label: '会话冷切换', - icon: status === 'failed' ? '⚠️' : '♻️', - tone: status === 'failed' ? 'warning' : 'info', - idempotencyKey: `session-rollover:${parsed.rolloverId}:${status}`, - sessionRollover: { - rolloverId: parsed.rolloverId, - status, - reason, - ...(failureStage ? { failureStage } : {}), - }, - }; -} - function projectOutboundReceipt(value: unknown): CloudBridgeOutboundReceiptV1 | undefined { if (!isCloudBridgeOutboundReceiptV1(value)) return undefined; return { @@ -130,7 +49,6 @@ function parseVisibleNotice( outboundReceipt?: CloudBridgeOutboundReceiptV1; needsBindingRecovery?: boolean; idempotencyKey?: string; - sessionRollover?: SessionRolloverNoticeMetadata; } | undefined { try { @@ -142,13 +60,15 @@ function parseVisibleNotice( rolloverId?: unknown; failureStage?: unknown; message?: unknown; + presentation?: unknown; outboundReceipt?: unknown; }; - if (parsed.type === 'session_rollover_lifecycle') { - return parseSessionRolloverNotice(parsed); - } + // Session rollover is provider execution detail. The response lifecycle + // bubble owns its processing/terminal state, so never append a parallel + // system row for pending, success, or failure. + if (parsed.type === 'session_rollover_lifecycle') return undefined; if (typeof parsed.message !== 'string') return undefined; - if (parsed.type === 'warning') { + if (parsed.type === 'warning' && parsed.presentation === 'user_action_required') { return { content: parsed.message ? `⚠️ ${parsed.message}` : '⚠️ Warning', connector: 'system-warning', @@ -198,6 +118,18 @@ export function userFacingSystemInfoNoticeContent(content: string, catId: string return parseVisibleNotice(content, catId)?.content; } +function normalizedFailureText(value: string): string { + return value.replace(/^(?:⚠️\s*|Error:\s*)+/u, '').trim(); +} + +function duplicatesTerminalFailure(notice: VisibleNotice, terminalFailureText: string | undefined): boolean { + return ( + notice.connector === 'system-warning' && + typeof terminalFailureText === 'string' && + normalizedFailureText(terminalFailureText).includes(normalizedFailureText(notice.content)) + ); +} + async function appendVisibleNotice( messageStore: IMessageStore, threadId: string, @@ -230,8 +162,8 @@ async function appendVisibleNotice( } : undefined; await messageStore.append({ + from: { kind: 'system', service: 'system-info-warning' }, userId: 'system', - catId: null, threadId, content: notice.content, mentions: [], @@ -245,7 +177,6 @@ async function appendVisibleNotice( meta: { presentation: 'system_notice', noticeTone: notice.tone, - ...(notice.sessionRollover ? { sessionRollover: notice.sessionRollover } : {}), ...(outboundReceipt ? { cloudBridgeOutboundReceipt: outboundReceipt } : {}), ...(cloudBridgeRecovery ? { cloudBridgeRecovery } : {}), }, @@ -253,6 +184,57 @@ async function appendVisibleNotice( }); } +async function validateOutboundReceipt(args: { + messageStore: IMessageStore; + threadId: string; + catId: string; + expectedSourceMessageId: string | undefined; + expectedDispatchInvocationId: string | undefined; + receipt: CloudBridgeOutboundReceiptV1; +}): Promise { + const { receipt } = args; + if ( + !args.expectedSourceMessageId || + receipt.sourceMessageId !== args.expectedSourceMessageId || + !args.expectedDispatchInvocationId || + receipt.dispatchInvocationId !== args.expectedDispatchInvocationId || + receipt.targetCatId !== args.catId + ) { + log.warn( + { + threadId: args.threadId, + catId: args.catId, + sourceMessageId: receipt.sourceMessageId, + dispatchInvocationId: receipt.dispatchInvocationId, + }, + 'Dropping cloud outbound receipt with mismatched server dispatch context', + ); + return undefined; + } + const source = await resolveVisibleReplyParent(args.messageStore, receipt.sourceMessageId, { + threadId: args.threadId, + viewer: { type: 'cat', catId: createCatId(args.catId) }, + publicReply: true, + }); + if (!source) return undefined; + + const from = messageFrom(source); + const senderMatches = + receipt.sourceSender.kind === 'user' + ? from.kind === 'user' && from.userId === receipt.sourceSender.id + : from.kind === 'agent' && from.catId === createCatId(receipt.sourceSender.id); + if (!senderMatches) return undefined; + if (receipt.sourceSender.invocationId) { + const storedInvocationIds = new Set( + [source.extra?.stream?.turnInvocationId, source.extra?.stream?.invocationId].filter((value): value is string => + Boolean(value), + ), + ); + if (!storedInvocationIds.has(receipt.sourceSender.invocationId)) return undefined; + } + return receipt; +} + function recordPersistenceFailure( catId: string, persistErr: unknown, @@ -274,6 +256,8 @@ export async function persistUserFacingSystemInfoNotices(options: { contents: readonly string[]; expectedSourceMessageId?: string; expectedDispatchInvocationId?: string; + /** Exact provider failure already persisted in the lifecycle response body. */ + terminalFailureText?: string; persistenceContext?: PersistenceContext; }): Promise { const { @@ -283,12 +267,14 @@ export async function persistUserFacingSystemInfoNotices(options: { contents, expectedSourceMessageId, expectedDispatchInvocationId, + terminalFailureText, persistenceContext, } = options; for (const content of contents) { const notice = parseVisibleNotice(content, catId); if (notice == null) continue; + if (duplicatesTerminalFailure(notice, terminalFailureText)) continue; try { await appendVisibleNotice( diff --git a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts index cd74befadf..9343bd6e1a 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts @@ -3,11 +3,12 @@ * Shared types, interfaces, and helper functions for route-serial and route-parallel. */ -import type { CatId, MessageContent, RichBlock, RichBlockBase } from '@cat-cafe/shared'; +import type { CatId, MessageContent, MessageFrom, RichBlock, RichBlockBase } from '@cat-cafe/shared'; import { catRegistry, isCrossThreadProvenance } from '@cat-cafe/shared'; import { resolveUnboundHistoryContextTokenCeiling } from '../../../../../config/context-capacity.js'; import { DEFAULT_HIERARCHICAL_CONTEXT } from '../../../../../config/hierarchical-context-config.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; +import type { CallerTraceContext } from '../../../../../infrastructure/telemetry/genai-semconv.js'; import { visibilityCursorDeferredBoundaryRejected } from '../../../../../infrastructure/telemetry/instruments.js'; import { assertCanonicalVisibilityCursor, @@ -41,13 +42,23 @@ import { withSurfaceShape, } from '../../session/context-surface-projection.js'; import { cursorFor } from '../../stores/cursor.js'; +import { messageFrom } from '../../stores/message-from.js'; import { DeliveryCursorStore } from '../../stores/ports/DeliveryCursorStore.js'; import type { IDraftStore } from '../../stores/ports/DraftStore.js'; -import type { IMessageStore, StoredMessage, StoredToolEvent } from '../../stores/ports/MessageStore.js'; +import type { + AppendMessageInput, + IMessageStore, + StoredMessage, + StoredToolEvent, +} from '../../stores/ports/MessageStore.js'; import type { Thread } from '../../stores/ports/ThreadStore.js'; -import { canViewMessage, isTimelinePublished, resolveVisibleReplyParent } from '../../stores/visibility.js'; +import { + canViewMessage, + isAgentReadableManagedHoldMessage, + isTimelinePublished, + resolveVisibleReplyParent, +} from '../../stores/visibility.js'; import type { AgentMessage, AgentRouteIntent, AgentService, ToolExecutionPolicy } from '../../types.js'; -import type { InvocationTracker } from '../invocation/InvocationTracker.js'; import type { InvocationDeps } from '../invocation/invoke-single-cat.js'; import type { OwnerAuthProvenance } from '../invocation/owner-auth-provenance.js'; import { extractRecentArtifacts, mergeLedger } from './artifact-tracking.js'; @@ -132,6 +143,8 @@ export interface RouteStrategyDeps { skillLoadEventLog?: import('../../tool-usage/SkillLoadEventLog.js').SkillLoadEventLog; /** F148 Phase F: Task store for navigation context (optional, fail-open) */ taskStore?: import('../../stores/ports/TaskStore.js').ITaskStore; + /** RFC A79: exact lifecycle situation shared with UI and read-only thread context. */ + threadExecutionSituationSource?: import('../invocation/thread-execution-situation.js').ThreadExecutionSituationSource; /** F222: Frustration auto-issue store (optional, fail-open) */ frustrationIssueStore?: import('../../stores/ports/FrustrationIssueStore.js').IFrustrationIssueStore; /** F093: World context provider for world-building mode (optional, fail-open) */ @@ -204,15 +217,15 @@ export async function hydrateVisibleA2ATriggerPromptMessage( log.warn({ err, threadId, catId: catId as string }, 'A2A trigger hydration failed'); return undefined; } + if (!message) return undefined; + const from = messageFrom(message); if ( - !message || message.threadId !== threadId || message.deletedAt || message._tombstone || - message.userId === 'system' || message.origin === 'briefing' || - message.catId === null || - message.catId === catId || + from.kind !== 'agent' || + from.catId === catId || !isTimelinePublished(message) ) { return undefined; @@ -241,6 +254,9 @@ export function mergePersistedPromptMessages( /** Common options for both strategies */ export interface RouteOptions { + /** Execution-owned fan-out policy. Queue dequeue uses parallel fan-out for one source + * with multiple targets; ordinary routes retain their intent-derived default. */ + targetDispatchMode?: 'serial' | 'parallel' | undefined; /** Route-owned intent plus whether the user explicitly selected it. */ routeIntent?: AgentRouteIntent; /** F293: deterministic scope used to resolve sparse routing cognition. */ @@ -249,8 +265,8 @@ export interface RouteOptions { ownerAuthProvenance?: OwnerAuthProvenance; /** F281 Phase C: explicit first-party ingress provenance; omitted legacy callers fail closed. */ humanDispositionInvocationOrigin?: HumanDispositionInvocationOrigin; - /** F293: server-owned durable queue source; queue replay alone does not prove a human request. */ - routingQueueSource?: import('../invocation/InvocationQueue.js').QueueEntry['source']; + /** Canonical Queue source class for admission/retry policy. */ + routingQueueSource?: 'user' | 'connector' | 'agent' | 'system'; /** F167 Phase T: exact protocol wake carrier for this route, never inferred from response prose. */ turnCustodyWake?: import('../../../../ball-custody/TurnCustodyProjectionService.js').TurnCustodyWakeProvenance; /** Per-cat carrier resolver for multi-holder routes. Takes precedence over turnCustodyWake. */ @@ -290,11 +306,7 @@ export interface RouteOptions { requiresExactCloudDispatchProvenance?: boolean | undefined; /** Max A2A chain depth for routeSerial (default: MAX_A2A_DEPTH env or 2) */ maxA2ADepth?: number | undefined; - /** Queue fairness hook: when true for current thread, routeSerial must stop extending A2A chain. - * F185 Phase B: should use hasQueuedNonAgentForThread (user + connector), not user-only. */ - queueHasQueuedMessages?: ((threadId: string) => boolean) | undefined; - /** F254 D1.1: Target-aware queued freshness input. Unlike queueHasQueuedMessages, - * this must return only queued messages that the current cat would actually process. */ + /** F254 D1.1: Target-aware queued freshness input. */ getQueuedFreshnessMessagesForCat?: | (( threadId: string, @@ -303,41 +315,48 @@ export interface RouteOptions { parentInvocationId?: string, ) => Array<{ entryId?: string; - source: string; + from: MessageFrom; content: string; - callerCatId?: string; messageId?: string | null; - mergedMessageIds?: string[]; sourceCategory?: string; }>) | undefined; - /** A2A dedup hook: skip text-scan @mention if cat already dispatched via callback path. */ - hasQueuedOrActiveAgentForCat?: ((threadId: string, catId: string) => boolean) | undefined; /** F254 D1.1: Any-source same-cat coverage check for stream freshness fallback. */ hasPendingForCat?: ((threadId: string, userId: string, catId: string) => boolean) | undefined; - /** F185 Phase B: deferred A2A enqueue — called when fairness gate blocks text-scan expansion - * but A2A targets were detected. Entry is queued behind non-agent entries instead of being silently dropped. */ - deferA2AEnqueue?: - | ((entry: { + /** Canonical completed-final path: terminalize the existing response and publish one durable message wake. */ + commitCompletedA2AWake?: + | ((input: { + responseMessageId: string; + invocationId: string; + terminal: { status: 'completed'; completedAt: number; reason?: string }; + message: AppendMessageInput; + targetCats: CatId[]; + userId: string; + ownerAuthProvenance: OwnerAuthProvenance; threadId: string; + callerCatId: CatId; + parentInvocationId?: string; + callerTraceContext?: import('../../../../../infrastructure/telemetry/genai-semconv.js').CallerTraceContext; + }) => Promise) + | undefined; + /** Canonical failed-final path: terminalize the response and wake the exact A2A caller once. */ + commitFailedA2AReport?: + | ((input: { + responseMessageId: string; + invocationId: string; + terminal: { status: 'failed'; completedAt: number; reason?: string }; + message: AppendMessageInput; userId: string; ownerAuthProvenance: OwnerAuthProvenance; - content: string; - source: 'agent'; - sourceCategory: 'a2a'; - targetCats: string[]; - callerCatId: string; - messageId?: string; - a2aTriggerMessageId?: string; - autoExecute: true; - priority: 'normal'; - intent: 'execute'; - /** F153 Phase I: trace context of the mention_dispatch span, so the dispatched - * route picked up by QueueProcessor reuses it as the parent — preserving cross-route - * causality through the fairness-gate deferred path. */ + threadId: string; + reporterCatId: CatId; + predecessorCatId: CatId; + parentInvocationId?: string; callerTraceContext?: import('../../../../../infrastructure/telemetry/genai-semconv.js').CallerTraceContext; - }) => { outcome: 'enqueued' | 'full' | string } | undefined) + }) => Promise) | undefined; + /** Failure-control carriers never recursively wake the caller that reported them. */ + a2aFailureReport?: boolean | undefined; /** ADR-008 S3: When provided, cursor boundaries are collected here instead of acking immediately. * Caller acks after invocation succeeds. If absent, legacy immediate ack behavior. */ cursorBoundaries?: Map; @@ -364,9 +383,29 @@ export interface RouteOptions { invocationId: string; messageIds: readonly string[]; seenAt: number; - }) => Promise< - readonly import('../../../../ball-custody/TurnCustodyProjectionService.js').TurnCustodyWakeProvenance[] | void - >) + }) => Promise) + | undefined; + /** Create the exact child's durable processing response before provider startup. */ + onLifecycleInvocationStarted?: + | ((input: { + threadId: string; + userId: string; + catId: CatId; + invocationId: string; + parentInvocationId: string; + startedAt: number; + }) => Promise<{ + responseMessageId: string; + priorFrontierMessageId: string | null; + activeRun: import('@cat-cafe/shared').LifecycleActiveRun; + }>) + | undefined; + /** Bind a provider-native exact-turn dispatcher after the client accepts the run. */ + onAgentClientActiveRunReady?: + | ((input: { + catId: CatId; + dispatcher: import('../../types.js').AgentClientActiveRunDispatcher; + }) => (() => void) | undefined) | undefined; /** F254 Phase E stable sibling-exclusion identity for one parallel fan-out. */ parallelBatchId?: string | undefined; @@ -377,16 +416,6 @@ export interface RouteOptions { freshnessSupplementRequiredMessageIds?: readonly string[] | undefined; /** ADR-042 provider/callback hard boundary for an automatic supplement. */ toolExecutionPolicy?: ToolExecutionPolicy | undefined; - executionScope?: 'collective-participation' | 'collective-work' | undefined; - /** Parent invocation controller used to keep A2A worklist slots tied to the same cancel signal. */ - invocationController?: AbortController | undefined; - /** - * Atomically claim an A2A worklist target in the outer invocation tracker. - * False means another live route owns the slot, so the caller must defer instead of invoking inline. - */ - trackA2ASlot?: ((threadId: string, catId: CatId, userId: string, controller: AbortController) => boolean) | undefined; - /** Cleanup registered A2A worklist slots if the route exits before every target emits done. */ - completeA2ASlots?: ((threadId: string, catIds: readonly CatId[], controller: AbortController) => void) | undefined; /** F153 Phase E: Root route span — invocation spans become children of this. */ routeSpan?: import('@opentelemetry/api').Span | undefined; /** F222 P1: Whether this route is eligible for frustration auto-issue detection. @@ -408,10 +437,9 @@ export interface RouteOptions { userId: string; ownerAuthProvenance: OwnerAuthProvenance; content: string; - source: 'agent'; + from: { kind: 'agent'; catId: string }; sourceCategory: 'freshness'; targetCats: string[]; - callerCatId: string; autoExecute: true; priority: 'normal'; intent: 'execute'; @@ -419,7 +447,6 @@ export interface RouteOptions { /** Closure successors must ignore the attempt that is currently processing while still coalescing queued duplicates. */ dedupeProcessing?: boolean; freshnessClosureId?: string; - freshnessRequiredFrontierMessageId?: string; freshnessSupplementId?: string; freshnessSupplementLineageId?: string; freshnessSupplementSeq?: 1 | 2; @@ -430,10 +457,28 @@ export interface RouteOptions { senders: string[]; reason: string; }; - }) => undefined | { outcome?: 'enqueued' | 'full' | string } | undefined) + }) => + | undefined + | { outcome?: 'enqueued' | 'full' | string } + | Promise) | undefined; } +/** + * Public execution boundary owned jointly by QueueProcessor and AgentRouter. + * Keep this derived from RouteOptions so adding a strategy option cannot be + * silently dropped by an intermediate hand-written parameter list. + */ +export type RouteExecutionOptions = Omit< + RouteOptions, + 'promptTags' | 'currentUserMessageId' | 'thinkingMode' | 'routeSpan' | 'humanDispositionInvocationOrigin' +> & { + ownerAuthProvenance: NonNullable; + onPromptMessagesExposed: NonNullable; + humanDispositionInvocationOrigin: HumanDispositionInvocationOrigin; + callerTraceContext?: CallerTraceContext; +}; + const TASTE_JUDGMENT_STAGES = new Set(['quality_gate', 'review']); const TASTE_JUDGMENT_SKILLS = new Set(['writing-plans', 'co-creation-docs', 'fresh-context-review', 'request-review']); @@ -573,44 +618,6 @@ export function operationalKnowledgeCueSeeds(input: { return seeds; } -/** - * Bind every routeExecution ingress to the same atomic A2A slot-admission contract. - * The returned controller is the parent batch gate; trackExternalSlot creates an - * independent per-target controller while preserving exact cleanup ownership. - */ -export type A2ASlotTrackingOptions = { - invocationController: NonNullable; - trackA2ASlot: NonNullable; - completeA2ASlots: NonNullable; -}; - -export function createA2ASlotTrackingBridge( - invocationTracker: - | { - trackExternalSlot?: InvocationTracker['trackExternalSlot']; - completeAll?: InvocationTracker['completeAll']; - } - | undefined, - invocationController: AbortController, - executionId?: string, -): A2ASlotTrackingOptions { - return { - invocationController, - trackA2ASlot: (threadId, catId, userId, controller) => { - if (!invocationTracker?.trackExternalSlot || !invocationTracker.completeAll) { - throw new Error('A2A slot admission unavailable: InvocationTracker bridge missing'); - } - return invocationTracker.trackExternalSlot(threadId, catId, controller, userId, [catId], executionId); - }, - completeA2ASlots: (threadId, catIds, controller) => { - if (!invocationTracker?.completeAll) { - throw new Error('A2A slot cleanup unavailable: InvocationTracker bridge missing'); - } - invocationTracker.completeAll(threadId, [...catIds], controller); - }, - }; -} - function canonicalDeferredBoundary( candidate: string | undefined, source: string, @@ -1026,7 +1033,6 @@ const USER_FACING_SYSTEM_INFO_TYPES = new Set([ 'session_rollover_lifecycle', 'session_seal_requested', 'silent_completion', - 'warning', ]); /** @@ -1036,7 +1042,8 @@ const USER_FACING_SYSTEM_INFO_TYPES = new Set([ */ export function isUserFacingSystemInfoContent(content: string): boolean { try { - const parsed = JSON.parse(content) as { type?: unknown }; + const parsed = JSON.parse(content) as { type?: unknown; presentation?: unknown }; + if (parsed.type === 'warning') return parsed.presentation === 'user_action_required'; return typeof parsed.type === 'string' && USER_FACING_SYSTEM_INFO_TYPES.has(parsed.type); } catch { return true; @@ -1257,6 +1264,36 @@ export function sanitizeInjectedContent(content: string): string { return stripLeakedToolCallPayload(kept.join('\n')).trim(); } +function projectLifecycleResponsePromptContent(message: StoredMessage, content: string): string { + if (content.trim() || message.lifecycle?.kind !== 'response') return content; + switch (message.lifecycle.status) { + case 'completed': + return '已完成,没有返回可显示内容。'; + case 'failed': + return '回复失败。'; + case 'canceled': + if (message.lifecycle.reason === 'user_cancel' || message.lifecycle.reason === 'cancel_all') { + return '用户已取消该回复。'; + } + return '已停止回复。'; + case 'interrupted': + if (message.lifecycle.reason === 'preempted') return '该回复已被后续消息中断。'; + return '回复已中断。'; + case 'processing': + return content; + } +} + +function isEmptyTerminalLifecycleResponse(message: StoredMessage): boolean { + return ( + message.content.trim().length === 0 && + (message.contentBlocks?.length ?? 0) === 0 && + (message.extra?.rich?.blocks?.length ?? 0) === 0 && + message.lifecycle?.kind === 'response' && + message.lifecycle.status !== 'processing' + ); +} + /** * Route content blocks to the target cat. * All cats receive the full content blocks including images — @@ -1798,7 +1835,7 @@ async function assembleIncrementalContextInternal( const filteredReasons = new Map(); const relevant = unseen.filter((m) => { // System-generated messages (persisted error badges) are display-only — never enter prompt - if (m.userId === 'system') { + if (messageFrom(m).kind === 'system' && !isAgentReadableManagedHoldMessage(m)) { filteredReasons.set(m.id, 'system_display_only'); return false; } @@ -1820,7 +1857,13 @@ async function assembleIncrementalContextInternal( // Exclude own messages (only include user messages and other cats' messages). // F052: only distinct source/target provenance earns the same-cat cross-post exemption. const isActualCrossPost = isCrossThreadProvenance(m.extra?.crossPost?.sourceThreadId, m.threadId); - if (!isActualCrossPost && m.catId !== null && m.catId === catId) { + const from = messageFrom(m); + const authorCatId = from.kind === 'agent' ? from.catId : null; + // Native provider memory cannot be trusted to retain a turn that ended + // before producing content. Preserve only the durable empty terminal + // marker so the next invocation can distinguish interrupted/failed/stopped + // work; ordinary own output remains excluded to avoid replaying it. + if (!isActualCrossPost && authorCatId !== null && authorCatId === catId && !isEmptyTerminalLifecycleResponse(m)) { filteredReasons.set(m.id, 'self_output'); return false; } @@ -1854,7 +1897,7 @@ async function assembleIncrementalContextInternal( const batonCandidates = loadedUnseen.filter( (m) => (m.id === currentUserMessageId || (unseenIds.has(m.id) && !deliveryRecovery.answeredSourceIds.has(m.id))) && - (m.userId !== 'system' || m.catId !== null) && + messageFrom(m).kind !== 'system' && m.origin !== 'briefing' && canViewMessage(m, viewer), ); @@ -1868,8 +1911,13 @@ async function assembleIncrementalContextInternal( target: { userId, threadId, catId }, turnExecutionStore: deps.invocationDeps.turnExecutionStore, })) - ) + ) { baton = null; + } + // The current direct request is already rendered verbatim in the delta. It + // is not a prior handoff and repeating it as co-creator→cat navigation adds + // duplicate instructions. Keep genuine cat handoffs and older provenance. + if (baton?.fromSpeaker === 'user' && baton.fromMessageId === currentUserMessageId) baton = null; let activeTasks: import('./navigation-context.js').TaskSummary[] = []; let allThreadTasks: import('./artifact-tracking.js').ArtifactExtractionInput['prTasks'] = []; if (deps.taskStore) { @@ -1917,10 +1965,24 @@ async function assembleIncrementalContextInternal( ); const topSource = selectDirectiveSources(rankedSources)[0] ?? null; const bestNextSource = topSource ? `先看 ${topSource.label}: ${topSource.ref}` : undefined; + let executionSituation: import('../invocation/thread-execution-situation.js').ThreadExecutionSituation | undefined; + if (deps.threadExecutionSituationSource) { + try { + executionSituation = await deps.threadExecutionSituationSource.resolve(threadId); + } catch { + executionSituation = { + kind: 'thread_execution_situation.v1', + complete: false, + activeRuns: [], + }; + } + } const navigationHeader = formatNavigationHeader({ threadId, + currentCatId: catId, baton, tasks: activeTasks, + ...(executionSituation ? { executionSituation } : {}), // Epoch-owned packets admit only a validated truth source. A recency-only // artifact list is not canonical state and must wait for the B3b mapper. artifacts: options?.contextProjection ? [] : recentArtifacts, @@ -2064,7 +2126,7 @@ async function assembleIncrementalContextInternal( : undefined; const lines = capped.map((m) => { // F22: Digest rich blocks into compact summaries for context - const contentWithDigest = digestRichBlocks(m); + const contentWithDigest = projectLifecycleResponsePromptContent(m, digestRichBlocks(m)); const cleanContent = sanitizeInjectedContent(contentWithDigest); const normalized: StoredMessage = cleanContent === m.content ? m : { ...m, content: cleanContent }; const rendered = formatMessage(normalized, { @@ -2269,7 +2331,7 @@ async function assembleSmartWindowContext( const compositeQueryTerms = [threadTitle, currentMsgText] .concat( burst - .filter((m) => m.catId === null && m.userId !== 'system') + .filter((m) => messageFrom(m).kind === 'user') .slice(-2) .map((m) => stripStructuralEnvelope(m.content).slice(0, 200)), ) @@ -2342,7 +2404,7 @@ async function assembleSmartWindowContext( // 3.8 Evidence recall (fail-open) — must run before coverage map so hints are populated const currentMsg = currentUserMessageId ? burst.find((m) => m.id === currentUserMessageId) : undefined; - const nonSystemRecent = burst.filter((m) => m.catId === null && m.userId !== 'system').slice(-2); + const nonSystemRecent = burst.filter((m) => messageFrom(m).kind === 'user').slice(-2); const recalledEvidence = usesLegacyRecall ? await recallEvidenceWithProvenance( deps.evidenceStore, @@ -2416,7 +2478,7 @@ async function assembleSmartWindowContext( }) : undefined; const burstLines = scrubbedBurst.map((m) => { - const contentWithDigest = digestRichBlocks(m); + const contentWithDigest = projectLifecycleResponsePromptContent(m, digestRichBlocks(m)); const cleanContent = sanitizeInjectedContent(contentWithDigest); const normalized: StoredMessage = cleanContent === m.content ? m : { ...m, content: cleanContent }; const rendered = formatMessage(normalized, { diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 8d8f4fc249..f0d12e3ca7 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -11,11 +11,7 @@ import { resolvePromptInputCeilingTokens, } from '../../../../../config/context-capacity.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { - ROUTE_HAS_A2A_HANDOFF, - ROUTE_TOTAL_CATS_INVOKED, - ROUTE_TOTAL_TOKENS, -} from '../../../../../infrastructure/telemetry/genai-semconv.js'; +import { ROUTE_TOTAL_CATS_INVOKED, ROUTE_TOTAL_TOKENS } from '../../../../../infrastructure/telemetry/genai-semconv.js'; import { conciergeVerifiedToolActions, conciergeVerifiedToolTargetsPerReply, @@ -46,11 +42,8 @@ import { sharedEventStore, sharedNudgeCooldown } from '../../../../memory/entity import type { PushRecallPresentation } from '../../../../memory/f200-types.js'; import type { PreparedProactiveMemoryNudge } from '../../../../memory/ProactiveMemoryNudgeService.js'; import { mergePushRecallPresentations, triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { drainCapturedTraces } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; -import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; -// F237: Injection trace (v0 — fire-and-forget observability) -import { buildTraceDetail, buildTraceSummary, collectTrace } from '../../../../prompt-hooks/trace-collector.js'; import { + isUserVisibleRoutingPreflightReceipt, preflightRoutingDispatch, routingDispatchPreflightReceipt, } from '../../../../routing-context/RoutingDispatchPreflightPort.js'; @@ -69,7 +62,9 @@ import { formatDegradationMessage } from '../../orchestration/DegradationPolicy. import { mergePresentationCounts, type PresentationCounts } from '../../session/context-surface-projection.js'; import { buildSessionBootstrap, MAX_SESSION_BOOTSTRAP_TOKENS } from '../../session/SessionBootstrap.js'; import { createMessageDeliveryBoundary } from '../../stores/message-delivery-boundary.js'; +import { messageFrom } from '../../stores/message-from.js'; import type { AppendMessageInput, StoredToolEvent } from '../../stores/ports/MessageStore.js'; +import { commitLifecycleResponseFromAppendInput } from '../../stores/ports/MessageStore.js'; import type { Thread, ThreadRoutingPolicyV1 } from '../../stores/ports/ThreadStore.js'; import { projectTurnExecutionMessage, @@ -118,7 +113,6 @@ import { createIdempotentPendingProjectionQueue, createLeakedToolCallStreamStripper, detectContextDegradation, - explicitApprovedTasteCueSeeds, explicitPromptForIncrementalContext, getService, getThreadBootcampMemberCount, @@ -208,7 +202,7 @@ export async function* routeParallel( routingDispatchPreflightDecision = routingPreflight; for (const targetCatId of requestedTargetCats) { const receipt = routingDispatchPreflightReceipt(routingPreflight, targetCatId); - if (receipt.target.disposition === 'allowed') continue; + if (!isUserVisibleRoutingPreflightReceipt(receipt)) continue; const notice = await routingPreflightNotice(deps, options, routingPreflight, targetCatId, threadId, true); if (notice) yield notice; if (receipt.target.disposition === 'rejected') { @@ -225,8 +219,6 @@ export async function* routeParallel( (catId) => routingPreflight.targets.find((target) => target.targetCatId === catId)?.disposition !== 'rejected', ); if (targetCats.length === 0) { - const terminalCatId = requestedTargetCats[0]; - if (terminalCatId) yield { type: 'done', catId: terminalCatId, isFinal: true, timestamp: Date.now() }; return; } } @@ -261,7 +253,11 @@ export async function* routeParallel( cursorStore: deps.deliveryCursorStore!, messageStore: deps.messageStore, messageFilter: (raw: Record) => { - if (raw.userId === 'system' || raw.origin === 'briefing') return false; + if ( + messageFrom(raw as unknown as Parameters[0]).kind === 'system' || + raw.origin === 'briefing' + ) + return false; const viewer = thinkingMode === 'play' ? ({ type: 'cat' as const, catId } as const) : ({ type: 'user' as const } as const); if ( @@ -300,15 +296,14 @@ export async function* routeParallel( return; } try { - const enqueueResult = options.freshnessReinvokeEnqueue({ + const enqueueResult = await options.freshnessReinvokeEnqueue({ threadId, userId, ownerAuthProvenance, content: `[Freshness Supplement ${supplement.id}]`, - source: 'agent', + from: { kind: 'agent', catId }, sourceCategory: 'freshness', targetCats: [catId], - callerCatId: catId, autoExecute: true, priority: 'normal', intent: 'execute', @@ -539,12 +534,6 @@ export async function* routeParallel( } if (deps.invocationDeps.memoryCuePromptService) { memoryCueOpportunitySeeds.push( - ...explicitApprovedTasteCueSeeds({ - message, - sourceMessageId: currentUserMessageId, - ownerOriginEligible: options.frustrationAutoIssueEligible !== false, - occurredAt: cueOccurredAt, - }), ...judgmentSurfaceCueSeeds({ sopStageHint, promptTags: options.frustrationAutoIssueEligible !== false ? promptTags : undefined, @@ -564,6 +553,7 @@ export async function* routeParallel( // F148 OQ-2: briefing→invocation link per cat (must be before Promise.all — TDZ fix) const catBriefingMessageId = new Map(); const pushRecallPresentationsByCat = new Map(); + const catOutputMessageId = new Map(); // F148 OQ-2: Collect tool names and coverage maps per cat for context eval const catToolNames = new Map(); const catCoverageMap = new Map(); @@ -597,7 +587,6 @@ export async function* routeParallel( } catch (error) { if (!(error instanceof AgentServiceUnavailableError)) throw error; unavailableCats.add(catId); - // Keep registration rejection in the per-cat completion pipeline, without aborting siblings' preparation. return (async function* unavailableMember(): AsyncGenerator { yield { type: 'error', catId, content: error.message, error: error.message, timestamp: Date.now() }; yield { type: 'done', catId, timestamp: Date.now() }; @@ -644,9 +633,6 @@ export async function* routeParallel( const staticIdentity = hasNativeL0 ? buildStaticIdentityPackOnly(catId, { packBlocks }) : buildStaticIdentity(catId, { mcpAvailable, packBlocks }); - // F237: drain session trace IMMEDIATELY — before any await that could let - // another parallel cat overwrite the module-global capture buffer. - drainCapturedTraces(); // F041: inject HTTP callback only when MCP is NOT actually available (fallback) const mcpInstructions = needsMcpInjection(mcpAvailable, catConfig?.clientId) ? buildMcpCallbackInstructions({ @@ -725,13 +711,6 @@ export async function* routeParallel( ] .filter(Boolean) .join('\n\n'); - // F237: drain turn trace IMMEDIATELY — same race-safety as session drain above. - drainCapturedTraces(); - - // F237 Phase 2: Pipeline trace capture drained above (lines 250, 322) to prevent - // stale module-global buffer in concurrent Promise.all execution. Persistence is - // handled by the v0 trace path below (after all route-level content is assembled). - const continuityCapsule = buildCapsuleFromRouteState({ threadId, catId: catId as string, @@ -811,38 +790,6 @@ export async function* routeParallel( } } - // F237: fire-and-forget injection trace persist (v0 — observability only) - // Placed after bootstrapCtx so per-turn trace covers ALL route-level - // injected system/control content (invocation + mode prompt + bootstrap + MCP). - // Skip if cat is already cancelled (avoid phantom trace for turns that never happen). - const preTraceSignal = signalForCat?.(catId) ?? signal; - try { - const traceStore = getTraceStore(); - if (traceStore && !preTraceSignal?.aborted) { - const traceTurnId = crypto.randomUUID(); - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(collected, traceMeta); - const detail = buildTraceDetail(collected, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); - }); - } - // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates - // the module-global capturedSessionTrace without draining. Clear it so the next - // invocation (especially native-L0 pack-only) doesn't persist stale session traces. - if (deps.injectionTraceStore) drainCapturedTraces(); - } catch { - /* F237: trace collection must never break invocation */ - } - let prompt: string; let incrementallyExposedMessageIds: string[] = []; let rebuildPromptWithBootstrap: ((bootstrap: string) => string) | undefined; @@ -1182,7 +1129,6 @@ export async function* routeParallel( ...(options.asrPersonMemoryScenes?.length ? { asrPersonMemoryScenes: options.asrPersonMemoryScenes } : {}), ...(memoryCueLegacyFallbacks.length > 0 ? { memoryCueLegacyFallbacks } : {}), ...(options.toolExecutionPolicy ? { toolExecutionPolicy: options.toolExecutionPolicy } : {}), - ...(options.executionScope ? { executionScope: options.executionScope } : {}), executionKind: turnExecutionKind, executionCausal: { ...(bridgeTriggerMessageId ? { triggerMessageId: bridgeTriggerMessageId } : {}), @@ -1190,6 +1136,12 @@ export async function* routeParallel( }, promptMessageIds: exactPromptMessageIds, ...(options.onPromptMessagesExposed ? { onPromptMessagesExposed: options.onPromptMessagesExposed } : {}), + ...(options.onLifecycleInvocationStarted + ? { onLifecycleInvocationStarted: options.onLifecycleInvocationStarted } + : {}), + ...(options.onAgentClientActiveRunReady + ? { onAgentClientActiveRunReady: options.onAgentClientActiveRunReady } + : {}), isLastCat: false, }); return (async function* withContextProjectionMessages(): AsyncGenerator { @@ -1236,6 +1188,7 @@ export async function* routeParallel( const catActivityUpdated = new Set(); // F22 R2 P1-1: Capture own invocationId per cat from stream const catInvocationId = new Map(); + const catLifecycleResponse = new Map(); const turnExecutionProjectionByInvocation = new Map(); const projectLiveTurnExecution = (event: AgentMessage, invocationId: string | undefined): AgentMessage => { if (!deps.invocationDeps.turnExecutionStore || !invocationId) return event; @@ -1348,6 +1301,17 @@ export async function* routeParallel( parsed.invocationId.length > 0 ) { catInvocationId.set(effectiveMsg.catId, parsed.invocationId); + if ( + typeof effectiveMsg.lifecycleResponseMessageId === 'string' && + effectiveMsg.lifecycleResponseMessageId.length > 0 && + (effectiveMsg.lifecyclePriorFrontierMessageId === null || + typeof effectiveMsg.lifecyclePriorFrontierMessageId === 'string') + ) { + catLifecycleResponse.set(effectiveMsg.catId, { + messageId: effectiveMsg.lifecycleResponseMessageId, + priorFrontierMessageId: effectiveMsg.lifecyclePriorFrontierMessageId, + }); + } if (deps.invocationDeps.turnExecutionStore) { turnExecutionProjectionByInvocation.set(parsed.invocationId, { invocationId: parsed.invocationId, @@ -1688,6 +1652,10 @@ export async function* routeParallel( } } + // A provider error with an admitted lifecycle response belongs to that + // response bubble. Keep accumulating it for terminal persistence, but do + // not project a second live-only system error surface. + if (effectiveMsg.type === 'error' && effectiveMsg.catId && catLifecycleResponse.has(effectiveMsg.catId)) continue; if (effectiveMsg.type === 'text' && !effectiveMsg.content) continue; // F194 Phase Z9 砚砚 R1 P1-1: stamp ownInvocationId on yielded events // (same as route-serial.ts). CLI text/done/tool events don't carry @@ -1761,11 +1729,39 @@ export async function* routeParallel( const actionOutputCommitAllowed = options.beforeOutputCommit ? await options.beforeOutputCommit(msg.catId as CatId) : true; - const targetSucceeded = - actionOutputCommitAllowed && - !catHadError.has(msg.catId) && - !msg.errorCode && - !(signalForCat?.(msg.catId) ?? signal)?.aborted; + const lifecycleAdmission = catLifecycleResponse.get(msg.catId); + const completedSignal = signalForCat?.(msg.catId as CatId) ?? signal; + const abortReason = completedSignal?.reason; + const lifecycleTerminalStatus: 'completed' | 'failed' | 'canceled' | 'interrupted' = !actionOutputCommitAllowed + ? 'interrupted' + : completedSignal?.aborted + ? abortReason === 'user_cancel' || abortReason === 'cancel_all' + ? 'canceled' + : 'interrupted' + : catHadProviderError.has(msg.catId) || (typeof msg.errorCode === 'string' && msg.errorCode.length > 0) + ? 'failed' + : 'completed'; + const lifecycleTerminalReason = + lifecycleTerminalStatus === 'completed' + ? undefined + : !actionOutputCommitAllowed + ? 'output_commit_rejected' + : typeof msg.errorCode === 'string' && msg.errorCode.length > 0 + ? msg.errorCode + : typeof abortReason === 'string' && abortReason.length > 0 + ? abortReason + : lifecycleTerminalStatus === 'failed' + ? 'provider_error' + : lifecycleTerminalStatus; + const lifecycleResponse = + lifecycleAdmission && ownInvId + ? { + ...lifecycleAdmission, + status: lifecycleTerminalStatus, + completedAt: Math.max(Date.now(), invocationStartedAt), + ...(lifecycleTerminalReason ? { reason: lifecycleTerminalReason } : {}), + } + : undefined; const deliveryBoundary = createMessageDeliveryBoundary({ cursor: boundaryByCat.get(msg.catId as CatId), userId, @@ -1773,13 +1769,60 @@ export async function* routeParallel( catId: msg.catId as CatId, turnInvocationId: ownInvId, sourceMessageId: currentUserMessageId ?? options.a2aTriggerMessageId, - succeeded: targetSucceeded, + succeeded: + actionOutputCommitAllowed && !catHadError.has(msg.catId) && !msg.errorCode && !completedSignal?.aborted, }); + const providerFailureText = catErrorText.get(msg.catId); + const terminalFailureContent = lifecycleResponse && providerFailureText ? providerFailureText : undefined; + const failedA2AReportCommit = + lifecycleResponse?.status === 'failed' && + options.a2aTriggerMessageId && + exactA2ACallerCatId && + !options.a2aFailureReport + ? async (message: AppendMessageInput) => { + if (!options.commitFailedA2AReport) { + throw new Error('failed response A2A report admission unavailable'); + } + return options.commitFailedA2AReport({ + responseMessageId: lifecycleResponse.messageId, + invocationId: ownInvId!, + terminal: { + status: 'failed', + completedAt: lifecycleResponse.completedAt, + ...(lifecycleResponse.reason ? { reason: lifecycleResponse.reason } : {}), + }, + message, + userId, + ownerAuthProvenance, + threadId, + reporterCatId: msg.catId as CatId, + predecessorCatId: exactA2ACallerCatId as CatId, + ...(options.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}), + }); + } + : undefined; if (!actionOutputCommitAllowed) { catProducedOutput = Boolean( text || bufferedBlocks.length > 0 || (catToolEvents.get(msg.catId)?.length ?? 0) > 0, ); if (options.persistenceContext) options.persistenceContext.actionOutputCommitRejected = true; + if (lifecycleResponse && ownInvId) { + await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponse.messageId, + ownInvId, + lifecycleResponse, + { + from: { kind: 'agent', catId: msg.catId as CatId }, + userId, + content: '', + mentions: [], + origin: 'stream', + timestamp: invocationStartedAt, + threadId, + }, + ); + } } else if (text) { catProducedOutput = true; const meta = catMeta.get(msg.catId); @@ -1869,8 +1912,8 @@ export async function* routeParallel( // Gap 3: persist separate connector message for ConnectorBubble rendering try { const stored = await deps.messageStore.append({ + from: { kind: 'system', service: 'vote' }, userId, - catId: null, content: `投票结果: ${voteState.question}`, mentions: [], timestamp: Date.now(), @@ -1968,9 +2011,9 @@ export async function* routeParallel( let outputCommitDecision: OutputCommitDecision | undefined; try { const streamMessageInput: AppendMessageInput = { + from: { kind: 'agent', catId: msg.catId as CatId }, userId, - catId: msg.catId as CatId, - content: persistedContent, + content: terminalFailureContent ? `${storedContent}\n\n${terminalFailureContent}` : storedContent, mentions: [], origin: 'stream', timestamp: invocationStartedAt, @@ -2016,6 +2059,8 @@ export async function* routeParallel( freshnessClosureId: options.freshnessClosureId, freshnessSupplementId: options.freshnessSupplementId, message: streamMessageInput, + ...(lifecycleResponse ? { lifecycleResponse } : {}), + ...(failedA2AReportCommit ? { commitLifecycleResponse: failedA2AReportCommit } : {}), replayUnsafeToolNames: findReplayUnsafeToolNames(catToolNames.get(msg.catId) ?? []), commitRecheckLimit: 10 + targetCats.length, evaluateFreshness: (priorFrontierMessageId) => @@ -2034,6 +2079,16 @@ export async function* routeParallel( ) { storedMsg = await deps.messageStore.getById(outputCommitDecision.messageId); } + } else if (lifecycleResponse && ownInvId) { + storedMsg = failedA2AReportCommit + ? await failedA2AReportCommit(streamMessageInput) + : await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponse.messageId, + ownInvId, + lifecycleResponse, + streamMessageInput, + ); } else { storedMsg = await deps.messageStore.append(streamMessageInput); } @@ -2132,11 +2187,11 @@ export async function* routeParallel( catProducedOutput = true; } - if (shouldPersistNoTextMessage) { + if (shouldPersistNoTextMessage || lifecycleResponse) { try { const noTextMessageInput: AppendMessageInput = { + from: { kind: 'agent', catId: msg.catId as CatId }, userId, - catId: msg.catId as CatId, content: '', mentions: [], origin: 'stream', @@ -2195,6 +2250,8 @@ export async function* routeParallel( freshnessClosureId: options.freshnessClosureId, freshnessSupplementId: options.freshnessSupplementId, message: noTextMessageInput, + ...(lifecycleResponse ? { lifecycleResponse } : {}), + ...(failedA2AReportCommit ? { commitLifecycleResponse: failedA2AReportCommit } : {}), replayUnsafeToolNames, commitRecheckLimit: 10 + targetCats.length, evaluateFreshness: (priorFrontierMessageId) => @@ -2217,6 +2274,16 @@ export async function* routeParallel( await enqueueParallelSupplement(decision, msg.catId); } } + } else if (lifecycleResponse && ownInvId) { + storedNoText = failedA2AReportCommit + ? await failedA2AReportCommit(noTextMessageInput) + : await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponse.messageId, + ownInvId, + lifecycleResponse, + noTextMessageInput, + ); } else { // Reviewed read-only tool-only audit remains non-routable and does not become // a normal answer. Unknown or mutating tools enter the freshness gate above. @@ -2288,21 +2355,21 @@ export async function* routeParallel( } else { // hadError but toolEvents exist — persist tool record so refresh shows what was attempted const catTools = catToolEvents.get(msg.catId); - if (catTools && catTools.length > 0) { + if ((catTools && catTools.length > 0) || lifecycleResponse) { const meta = catMeta.get(msg.catId); const thinking = catThinking.get(msg.catId); try { - const storedToolError = await deps.messageStore.append({ + const errorMessageInput: AppendMessageInput = { + from: { kind: 'agent', catId: msg.catId as CatId }, userId, - catId: msg.catId as CatId, - content: '', + content: terminalFailureContent ?? '', mentions: [], origin: 'stream', timestamp: invocationStartedAt, threadId, ...(thinking && thinking.length > 0 ? { thinking: renderThinkingChunks(thinking) } : {}), ...(meta ? { metadata: meta } : {}), - toolEvents: catTools, + ...(catTools && catTools.length > 0 ? { toolEvents: catTools } : {}), ...(persistedInvocationId || turnExecution || msg.tracing ? { extra: { @@ -2320,8 +2387,23 @@ export async function* routeParallel( }, } : {}), - }); - turnStoredMessageId = storedToolError.id; + }; + let storedErrorTools; + if (lifecycleResponse && ownInvId) { + storedErrorTools = failedA2AReportCommit + ? await failedA2AReportCommit(errorMessageInput) + : await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponse.messageId, + ownInvId, + lifecycleResponse, + errorMessageInput, + ); + } else { + storedErrorTools = await deps.messageStore.append(errorMessageInput); + } + catOutputMessageId.set(msg.catId, storedErrorTools.id); + turnStoredMessageId = storedErrorTools.id; // #80: Clean up draft only after successful append if (deps.draftStore && ownInvId) { deps.draftStore.delete(userId, threadId, ownInvId)?.catch?.(noop); @@ -2353,6 +2435,10 @@ export async function* routeParallel( } } + const errorText = catErrorText.get(msg.catId); + const lifecycleErrorOwnedByResponse = + catLifecycleResponse.has(msg.catId) && + catLifecycleResponse.get(msg.catId)?.messageId === catOutputMessageId.get(msg.catId); await persistUserFacingSystemInfoNotices({ messageStore: deps.messageStore, threadId, @@ -2360,22 +2446,27 @@ export async function* routeParallel( contents: catUserFacingSystemInfoContents.get(msg.catId) ?? [], ...(bridgeTriggerMessageId ? { expectedSourceMessageId: bridgeTriggerMessageId } : {}), ...(ownInvId ? { expectedDispatchInvocationId: ownInvId } : {}), + ...(lifecycleErrorOwnedByResponse && terminalFailureContent + ? { terminalFailureText: terminalFailureContent } + : {}), ...(options.persistenceContext ? { persistenceContext: options.persistenceContext } : {}), }); catUserFacingSystemInfoContents.delete(msg.catId); // Persist error as system message so it survives F5 reload but does NOT - // re-enter the prompt as a cat message. Preserve ordinary provider error persistence; - // only registration diagnostics use the output fence, as in route-serial's early rejection path. + // re-enter the prompt as a cat message (aligned with route-serial.ts). // Previously errors were mixed into catText and persisted with userId=user, // which polluted the conversation history and caused "context poisoning". - const errorText = catErrorText.get(msg.catId); - if (errorText && (!unavailableCats.has(msg.catId) || actionOutputCommitAllowed)) { + if ( + errorText && + !lifecycleErrorOwnedByResponse && + (!unavailableCats.has(msg.catId) || actionOutputCommitAllowed) + ) { const cliDiag = catCliDiagnostics.get(msg.catId); try { await deps.messageStore.append({ + from: { kind: 'system', service: 'agent-error' }, userId: 'system', - catId: null, content: `Error: ${errorText}`, mentions: [], origin: 'stream', @@ -2432,7 +2523,8 @@ export async function* routeParallel( } if ( deps.deliveryCursorStore && - (!options.cursorBoundaries || (targetSucceeded && (turnStoredMessageId || !catProducedOutput))) + (!options.cursorBoundaries || + (lifecycleTerminalStatus === 'completed' && (turnStoredMessageId || !catProducedOutput))) ) { // A completed target must commit before done releases its slot, // independently of a hanging sibling or the parent finalizer. @@ -2526,8 +2618,6 @@ export async function* routeParallel( if (options.routeSpan) { options.routeSpan.setAttribute(ROUTE_TOTAL_CATS_INVOKED, completedCount); options.routeSpan.setAttribute(ROUTE_TOTAL_TOKENS, routeTotalTokens); - // Parallel routes never produce A2A handoffs (MVP safety boundary) - options.routeSpan.setAttribute(ROUTE_HAS_A2A_HANDOFF, false); } // F200 AC-A1: fire-and-forget recall correlation after all cats complete. diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index f2c137b952..67c716dba7 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -2,18 +2,13 @@ * Serial Route Strategy * Cats respond one by one, each seeing previous responses. * - * A2A support: after each cat completes, its response is checked for @mentions. - * If a mention is detected and depth allows, the mentioned cat is appended to the - * worklist — extending the chain within the SAME function call. This preserves - * previousResponses continuity and correct isFinal semantics (缅因猫 P1-1, P1-2). - * - * A2A only triggers here in routeSerial; routeParallel never chains (MVP safety boundary). + * A2A successors are admitted only after the current response is durably + * completed. Each admitted successor becomes an independent message_wake Queue + * entry; this function never extends its own execution worklist. */ import crypto from 'node:crypto'; import { - A2A_INLINE_MENTION_MODE, - type A2ARoutingProjection, type CatConfig, type CatId, catRegistry, @@ -34,8 +29,6 @@ import { import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import { AGENT_ID, - type CallerTraceContext, - ROUTE_HAS_A2A_HANDOFF, ROUTE_TOTAL_CATS_INVOKED, ROUTE_TOTAL_TOKENS, ROUTING_EVENT_WAIT_REASON, @@ -43,7 +36,6 @@ import { TRIGGER, } from '../../../../../infrastructure/telemetry/genai-semconv.js'; import { - a2aDispatchCount, c2ExitChecked, c2VerdictHintEmitted, c2VerdictWithoutPassCount, @@ -55,15 +47,16 @@ import { inlineActionDetected, inlineActionFeedbackWriteFailed, inlineActionFeedbackWritten, - inlineActionHintEmitFailed, inlineActionHintEmitted, inlineActionRoutedSetSkip, inlineActionShadowMiss, legacyGuardWithoutActiveCustodyTotal, lineStartDetected, routingEventWaitBypassTotal, + routingEventWaitFalseBypassTotal, routingEventWaitRedundantHoldPreventedTotal, routingEventWaitRejectedTotal, + routingSyntaxCorrectionDetected, routingTerminalReleaseCleanStopTotal, turnCustodyProjectionTotal, turnCustodyShadowComparisonTotal, @@ -96,18 +89,15 @@ import { estimateTokens } from '../../../../../utils/token-counter.js'; import type { IBallCustodyIngest } from '../../../../ball-custody/BallCustodyIngest.js'; import { buildHandedCvoEvent, - buildHandedEvent, buildInvocationHeartbeatEvent, buildInvocationStartedEvent, buildVoidPassEvent, } from '../../../../ball-custody/ball-custody-events.js'; -import { turnCustodyAdoptionRegistry } from '../../../../ball-custody/TurnCustodyAdoptionRegistry.js'; import { compareTurnCustodyShadow, type TurnCustodyProjection, type TurnCustodyWakeProvenance, } from '../../../../ball-custody/TurnCustodyProjectionService.js'; -import { resolveTypedWaitContinuation } from '../../../../ball-custody/TypedWaitContinuation.js'; import { buildA2ADispatchTurnCustodyWake, buildCrossThreadNoObligationWake, @@ -138,11 +128,8 @@ import { sharedEventStore, sharedNudgeCooldown } from '../../../../memory/entity import type { PushRecallPresentation } from '../../../../memory/f200-types.js'; import type { PreparedProactiveMemoryNudge } from '../../../../memory/ProactiveMemoryNudgeService.js'; import { mergePushRecallPresentations, triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { drainCapturedTraces } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; -import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; -// F237: Injection trace (v0 — fire-and-forget observability) -import { buildTraceDetail, buildTraceSummary, collectTrace } from '../../../../prompt-hooks/trace-collector.js'; import { + isUserVisibleRoutingPreflightReceipt, preflightRoutingDispatch, routingDispatchPreflightReceipt, } from '../../../../routing-context/RoutingDispatchPreflightPort.js'; @@ -163,8 +150,10 @@ import { AuditEventTypes, getEventAuditLog } from '../../orchestration/EventAudi import { mergePresentationCounts, type PresentationCounts } from '../../session/context-surface-projection.js'; import { buildSessionBootstrap, MAX_SESSION_BOOTSTRAP_TOKENS } from '../../session/SessionBootstrap.js'; import { createMessageDeliveryBoundary } from '../../stores/message-delivery-boundary.js'; +import { messageFrom } from '../../stores/message-from.js'; import { type AppendMessageInput, + commitLifecycleResponseFromAppendInput, hydrateCrossThreadReplyHint, hydrateReplyPreview, type StoredToolEvent, @@ -175,7 +164,7 @@ import { type TurnExecutionKind, type TurnExecutionMessageProjection, } from '../../stores/ports/TurnExecutionStore.js'; -import { canViewMessage } from '../../stores/visibility.js'; +import { canViewMessage, isOwnerVisibleManagedHoldConnector } from '../../stores/visibility.js'; import { classifyTool } from '../../tool-usage/classify.js'; import { deriveResultSummary } from '../../tool-usage/derive-result-summary.js'; import { normalizeMcpToolName } from '../../tool-usage/normalize-mcp-tool-name.js'; @@ -203,16 +192,8 @@ import { resolveManagedSessionPolicySnapshot } from '../invocation/session-polic import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js'; import { AgentServiceUnavailableError } from '../registry/AgentServiceUnavailableError.js'; import { detectInlineActionMentionsWithShadow, getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js'; -import { - isSubstantiveTool, - peekStreakOnPush, - registerWorklist, - setWorklistCallerAdmissionOpen, - unregisterWorklist, - updateStreakOnPush, -} from '../routing/WorklistRegistry.js'; +import { registerWorklist, unregisterWorklist } from '../routing/WorklistRegistry.js'; import { accumulateTextAggregate } from '../text-aggregation.js'; -import { formatA2AHandoffContent, formatSerialMultiTargetNotice } from './a2a-handoff-label.js'; import { buildCallbackFinalReplacementMetadataPatch, CallbackFinalReplacementTracker, @@ -222,8 +203,10 @@ import { readCallbackStreamDisposition, } from './callback-final-replacement.js'; import { extractContextEvalSignals } from './context-eval.js'; +import { readDurableA2ALineage } from './durable-a2a-lineage.js'; import { validateRoutingSyntax } from './final-routing-slot.js'; import { buildBriefingMessage } from './format-briefing.js'; +import { resolveEventBackedRoutingExit } from './guards/event-backed-routing-exit.js'; import { isDirectOwnerDispositionOrigin } from './human-disposition-invocation-origin.js'; import { persistUserFacingSystemInfoNotices } from './persist-system-info-warnings.js'; import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js'; @@ -236,7 +219,6 @@ import { createIdempotentPendingProjectionQueue, createLeakedToolCallStreamStripper, detectContextDegradation, - explicitApprovedTasteCueSeeds, explicitPromptForIncrementalContext, getService, getThreadBootcampMemberCount, @@ -253,7 +235,6 @@ import { toStoredToolEvent, upsertMaxBoundary, } from './route-helpers.js'; -import { resolveRoutingDecisions } from './routing-decision.js'; import { isRoutingOwnerAttempt } from './routing-owner-attempt.js'; import { routingPreflightNotice } from './routing-preflight-notice.js'; import { appendThinkingChunk, renderThinkingChunks } from './thinking-chunks.js'; @@ -268,42 +249,7 @@ const log = createModuleLogger('route-serial'); const BALL_CUSTODY_INVOCATION_HEARTBEAT_MIN_INTERVAL_MS = 30_000; -/** - * F086/F216: single builder for the serial-normalization notice payload. - * - * `message` is the human-readable line — it is what `formatVisibleSystemInfo` renders live and what - * `persistUserFacingSystemInfoNotices` writes for F5 hydration. `mode`/`order` stay machine-readable. - * Emitting a payload with no registered visible/persistent consumer would ship a raw JSON blob that - * vanishes on refresh (砚砚 R1 P1) — the notice must be wired end to end or it is not a notice. - */ -function buildSerialMultiTargetNoticePayload( - fromCatId: CatId, - legs: Array<{ catId: CatId; config?: CatConfig }>, -): string { - return JSON.stringify({ - type: 'a2a_multi_target_serialized', - fromCatId, - mode: A2A_INLINE_MENTION_MODE, - order: legs.map((leg) => leg.catId), - message: formatSerialMultiTargetNotice(legs), - }); -} - -export function buildTurnCustodyStopGateRemedialPrompt(wake: TurnCustodyWakeProvenance): string { - if (wake.kind === 'structured' && wake.protocol === 'hold') { - return ( - '[F167 球权停止门] 当前 managed hold wake 尚未发生可验证状态迁移。\n' + - '若本次 wake 工作已经处理完成,只调用 cat_cafe_complete_managed_hold,并选择 handled 或 completed;工具会从当前 invocation 自动绑定 source message 与 task,不能手填 subject。\n' + - '若工作尚未结束,只使用与下一条件匹配的 hold_ball、已注册 eventWait 或结构化传球。不要用纯文本 @、礼貌 ACK、command exit、测试/merge truth 代替 disposition。' - ); - } - if (wake.kind === 'structured' && wake.protocol === 'dispatch') { - return ( - '[F167 球权停止门] 当前普通 A2A dispatch 尚未发生可验证状态迁移。\n' + - '若本次 A2A 工作已经处理完成,只调用 cat_cafe_complete_a2a_dispatch,并选择 handled 或 completed;工具会从当前 invocation 自动绑定 source message、前手与当前 holder,不能手填 subject。\n' + - '若工作尚未结束,只使用与下一条件匹配的 hold_ball、已注册 eventWait 或结构化传球。不要用纯文本 @、礼貌 ACK、command exit、测试/merge truth 代替 disposition。' - ); - } +export function buildTurnCustodyStopGateRemedialPrompt(_wake: TurnCustodyWakeProvenance): string { return ( '[F167 球权停止门] 本次唤醒携带的协议球尚未发生可验证状态迁移。\n' + '请只完成一次与当前协议球匹配的结构化动作,不要重做或改写刚才的工作:完成候选、结构化传球、returnToPredecessor、hold_ball 或已注册的 eventWait。\n' + @@ -311,30 +257,9 @@ export function buildTurnCustodyStopGateRemedialPrompt(wake: TurnCustodyWakeProv ); } -/** - * F233 Phase B (B2): persist ball.handed at the receiver boundary. - * Phase T opens a dispatch projection from this exact event, so the write must - * settle before its baseline is sampled. Failure stays fail-closed through an - * unknown projection rather than blocking the underlying route. - */ -async function recordBallHanded( - ballCustody: IBallCustodyIngest | undefined, - threadId: string, - fromCatId: string | undefined, - toCatId: string, - messageId: string | undefined, -): Promise { - if (!ballCustody || !messageId) return; - try { - await ballCustody.record(buildHandedEvent({ fromCatId, toCatId, threadId, messageId, at: Date.now() })); - } catch (err) { - log.warn({ threadId, toCat: toCatId, err }, 'ball.handed ingest failed'); - } -} - /** * F233 Phase B (B2): fire-and-forget 旁路写 ball.void_pass(声明持球但无 hold_ball / 无行首 @ / 无 structured 路由)。 - * 紧贴 void-hold-hint sample emit 调用(此时 storedMsgId 已绑定)。 + * 紧贴 void-hold internal signal 的 sample emit 调用(此时 storedMsgId 已绑定)。 */ function emitBallVoidPass( ballCustody: IBallCustodyIngest | undefined, @@ -562,11 +487,6 @@ function consumePendingToolResult( return undefined; } -function isSubstantivePostDispositionProgress(msg: AgentMessage): boolean { - if (msg.type === 'text') return Boolean(msg.content?.trim()); - return msg.type === 'tool_use' || msg.type === 'tool_result' || msg.type === 'a2a_handoff'; -} - export async function* routeSerial( deps: RouteStrategyDeps, targetCats: CatId[], @@ -587,10 +507,9 @@ export async function* routeSerial( a2aTriggerMessageId, modeSystemPrompt, modeSystemPromptByCat, - queueHasQueuedMessages, getQueuedFreshnessMessagesForCat, - hasQueuedOrActiveAgentForCat, - deferA2AEnqueue, + commitCompletedA2AWake, + commitFailedA2AReport, freshnessReinvokeEnqueue, } = options; const ownerAuthProvenance = options.ownerAuthProvenance ?? 'unknown'; @@ -614,15 +533,14 @@ export async function* routeSerial( return; } try { - const enqueueResult = freshnessReinvokeEnqueue({ + const enqueueResult = await freshnessReinvokeEnqueue({ threadId, userId, ownerAuthProvenance, content: `[Freshness Supplement ${supplement.id}]`, - source: 'agent', + from: { kind: 'agent', catId }, sourceCategory: 'freshness', targetCats: [catId], - callerCatId: catId, autoExecute: true, priority: 'normal', intent: 'execute', @@ -655,132 +573,26 @@ export async function* routeSerial( } }; - // Worklist pattern: starts with targetCats, may grow via A2A mentions - // F27: Register worklist so callback A2A can push targets here - // F108: Key by parentInvocationId for concurrent isolation + // The route worklist contains only this admitted batch. Downstream A2A work is + // published through the durable message_wake path after the response completes. const worklist = [...targetCats]; const maxDepth = options.maxA2ADepth ?? getMaxA2ADepth(); - const worklistEntry = registerWorklist(threadId, worklist, maxDepth, options.parentInvocationId); - // No callback caller is active while route-level setup is still running. Each turn opens - // this window only after its abort gate, then the final admission drain closes it. - setWorklistCallerAdmissionOpen(worklistEntry, false); + const durableA2ALineage = + a2aTriggerMessageId && options.a2aCallerCatId && targetCats.length === 1 + ? await readDurableA2ALineage(deps.messageStore, a2aTriggerMessageId, targetCats[0]) + : undefined; + const worklistEntry = registerWorklist( + threadId, + worklist, + maxDepth, + options.parentInvocationId, + durableA2ALineage ? { a2aCount: durableA2ALineage.depth, streakPair: durableA2ALineage.streakPair } : undefined, + ); let index = 0; // done-guarantee: Track whether we yielded a done(isFinal=true) so the finally block can // synthesize one if the loop exits early (e.g. signal.aborted break at top of while). let yieldedFinalDone = false; - // F27: Track how many worklist entries have had a2a_handoff emitted - let handoffEmitted = targetCats.length; // Original targets don't get handoff events - const activeTrackedA2ASlots = new Set(); - const pendingRoutingPreflightNotices: AgentMessage[] = []; - const preflightA2ATarget = async (targetCatId: CatId): Promise<'allowed' | 'warned' | 'rejected'> => { - if (!deps.routingDispatchPreflight) return 'allowed'; - const decision = await preflightRoutingDispatch(deps.routingDispatchPreflight, { - ownerId: userId, - targetCatIds: [targetCatId], - ...(options.routingContextIntent ? { intent: options.routingContextIntent } : {}), - }); - const receipt = routingDispatchPreflightReceipt(decision, targetCatId); - if (receipt.target.disposition !== 'allowed') { - pendingRoutingPreflightNotices.push({ - type: 'system_info', - catId: targetCatId, - content: JSON.stringify(receipt), - timestamp: Date.now(), - }); - } - return receipt.target.disposition; - }; - const claimOrDeferA2ATarget = async ( - pendingCat: CatId, - fromCat: CatId, - fallbackContent?: string, - fallbackTriggerMessageId?: string, - onDurablyDeferred?: (targetCatId: CatId, triggerMessageId: string) => void, - ): Promise => { - if (activeTrackedA2ASlots.has(pendingCat)) { - return true; - } - if ((await preflightA2ATarget(pendingCat)) === 'rejected') return false; - if (!options.invocationController || !options.trackA2ASlot) { - throw new Error('A2A slot admission unavailable: route bridge missing'); - } - - const claimed = options.trackA2ASlot(threadId, pendingCat, userId, options.invocationController); - if (claimed !== false) { - activeTrackedA2ASlots.add(pendingCat); - return true; - } - - const triggerMessageId = fallbackTriggerMessageId ?? worklistEntry.a2aTriggerMessageId.get(pendingCat); - let content = fallbackContent; - if (content === undefined && triggerMessageId) { - try { - content = (await deps.messageStore.getById(triggerMessageId))?.content; - } catch (err) { - log.warn( - { threadId, fromCat, toCat: pendingCat, triggerMessageId, err }, - 'A2A occupied-slot trigger hydration failed', - ); - } - } - - if (!deferA2AEnqueue || content === undefined) { - log.error( - { - threadId, - fromCat, - toCat: pendingCat, - triggerMessageId, - hasDeferredQueue: Boolean(deferA2AEnqueue), - hasContent: content !== undefined, - }, - 'A2A target owned by another active route; inline invocation blocked without deferred custody', - ); - throw new Error( - `durable A2A custody unavailable for ${pendingCat}: ${ - deferA2AEnqueue ? 'trigger content missing' : 'InvocationQueue unavailable' - }`, - ); - } - - const enqueueResult = deferA2AEnqueue({ - threadId, - userId, - ownerAuthProvenance, - content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [pendingCat], - callerCatId: fromCat, - ...(triggerMessageId ? { messageId: triggerMessageId, a2aTriggerMessageId: triggerMessageId } : {}), - autoExecute: true, - priority: 'normal', - intent: 'execute', - }); - if (enqueueResult?.outcome !== 'enqueued') { - log.error( - { - threadId, - fromCat, - toCat: pendingCat, - triggerMessageId, - enqueueOutcome: enqueueResult?.outcome ?? 'missing', - }, - 'A2A target owned by another active route; durable enqueue was not accepted', - ); - throw new Error( - `durable A2A custody unavailable for ${pendingCat}: enqueue outcome ${enqueueResult?.outcome ?? 'missing'}`, - ); - } - - log.info( - { threadId, fromCat, toCat: pendingCat, triggerMessageId }, - 'A2A target owned by another active route; deferred to InvocationQueue', - ); - if (triggerMessageId) onDurablyDeferred?.(pendingCat, triggerMessageId); - return false; - }; // F042 Wave 3: Fetch thread participant activity once before loop (threadId doesn't change). let activeParticipants: { catId: CatId; lastMessageAt: number; messageCount: number }[] = []; if (deps.invocationDeps.threadStore) { @@ -833,9 +645,6 @@ export async function* routeSerial( const bootcampMemberCount = getThreadBootcampMemberCount(routeThread); // F153: Trace propagation — track per-invocation spans and route-level token totals - const catInvocationSpans = new Map(); - const mentionParentSpan = new Map(); - const pendingDispatchSpans: { span: Span; lastChildIndex: number }[] = []; let routeTotalTokens = 0; // F155: Guide interceptor — resume existing guide state only @@ -981,12 +790,6 @@ export async function* routeSerial( } if (deps.invocationDeps.memoryCuePromptService) { memoryCueOpportunitySeeds.push( - ...explicitApprovedTasteCueSeeds({ - message, - sourceMessageId: currentUserMessageId, - ownerOriginEligible: options.frustrationAutoIssueEligible !== false, - occurredAt: cueOccurredAt, - }), ...judgmentSurfaceCueSeeds({ sopStageHint, promptTags: options.frustrationAutoIssueEligible !== false ? promptTags : undefined, @@ -1005,21 +808,11 @@ export async function* routeSerial( const completedCatInvocationIds: Array<[string, string]> = []; const pushRecallPresentationsByInvocation = new Map(); - const pendingTurnCustodyTransitionWrites: Promise[] = []; const pendingTurnCustodyShadowCloses: Array<(checkpoint: 'next_turn_boundary' | 'route_settled') => Promise> = []; - let unregisterTurnCustodyAdoption: (() => Promise) | undefined; - const releaseTurnCustodyAdoption = async (): Promise => { - const unregister = unregisterTurnCustodyAdoption; - unregisterTurnCustodyAdoption = undefined; - await unregister?.(); - }; let keepaliveTimer: ReturnType | undefined; const flushTurnCustodyShadowCloses = async (checkpoint: 'next_turn_boundary' | 'route_settled'): Promise => { - const transitionWrites = pendingTurnCustodyTransitionWrites.splice(0); - if (transitionWrites.length > 0) await Promise.allSettled(transitionWrites); - const closes = pendingTurnCustodyShadowCloses.splice(0); for (const close of closes) { try { @@ -1034,7 +827,6 @@ export async function* routeSerial( while (index < worklist.length) { const catId = worklist[index]!; let stopGateRemedialAttempted = false; - let structuredDispositionMissingCode: string | undefined; let routingDispatchPreflightDecision: RoutingPreflightDecisionV1 | undefined; // F-parallel-cancel: per-cat signal — canceling one cat skips ONLY that cat, not the // whole worklist. force-reset/cancelAll aborts every cat's controller, so all entries @@ -1053,15 +845,20 @@ export async function* routeSerial( ...(options.routingContextIntent ? { intent: options.routingContextIntent } : {}), }); const receipt = routingDispatchPreflightReceipt(routingDispatchPreflightDecision, catId); - const notice = await routingPreflightNotice( - deps, - options, - routingDispatchPreflightDecision, - catId, - threadId, - index < targetCats.length, - ); - if (notice) yield notice; + // Only a rejected send changes user-visible behavior. Warned decisions + // remain available through routing evidence/telemetry without adding a + // synthetic chat message for an unchanged target. + if (isUserVisibleRoutingPreflightReceipt(receipt)) { + const notice = await routingPreflightNotice( + deps, + options, + routingDispatchPreflightDecision, + catId, + threadId, + index < targetCats.length, + ); + if (notice) yield notice; + } if (receipt.target.disposition === 'rejected') { yield { type: 'error', @@ -1074,34 +871,6 @@ export async function* routeSerial( continue; } } - let service: AgentService; - try { - service = getService(deps.services, catId, deps.unavailableServices); - } catch (error) { - if (!(error instanceof AgentServiceUnavailableError)) throw error; - yield { type: 'error', catId, content: error.message, error: error.message, timestamp: Date.now() }; - const commitAllowed = options.beforeOutputCommit ? await options.beforeOutputCommit(catId) : true; - if (commitAllowed) { - try { - await deps.messageStore.append({ - userId: 'system', - catId: null, - content: `Error: ${error.message}`, - mentions: [], - origin: 'stream', - timestamp: Date.now(), - threadId, - }); - } catch (persistError) { - log.error({ catId, err: persistError }, 'messageStore.append (unavailable member) failed'); - } - } else if (options.persistenceContext) { - options.persistenceContext.actionOutputCommitRejected = true; - } - yield { type: 'done', catId, isFinal: false, timestamp: Date.now() }; - worklistEntry.executedIndex = ++index; - continue; - } // F148 OQ-2: briefing→invocation link + context eval let briefingMessageId: string | undefined; let briefingCoverageMap: import('./context-transport.js').CoverageMap | undefined; @@ -1126,7 +895,8 @@ export async function* routeSerial( // Build identity: static goes in -p content (+ systemPrompt as defense-in-depth), dynamic in -p only const catConfig: CatConfig | undefined = catRegistry.tryGet(catId as string)?.config; const teammates = [...new Set(worklist.filter((id) => id !== catId))]; - const directMessageFrom = worklistEntry.a2aFrom.get(catId); + const directMessageFrom = + isOriginalTarget && options.a2aCallerCatId ? createCatId(options.a2aCallerCatId) : undefined; // F167 L1: ping-pong warning — inject when this cat just received the ball // in a same-pair streak >= 2 (streak=4 already blocked upstream, so max is 3 here). const pingPongWarning = @@ -1137,7 +907,7 @@ export async function* routeSerial( } : undefined; const queueTriggerReplyTo = isOriginalTarget ? a2aTriggerMessageId : undefined; - const activeA2ATriggerMessageId = worklistEntry.a2aTriggerMessageId.get(catId); + const activeA2ATriggerMessageId = isOriginalTarget ? a2aTriggerMessageId : undefined; const streamReplyTo = activeA2ATriggerMessageId ?? queueTriggerReplyTo; const turnTriggerMessageId = streamReplyTo ?? currentUserMessageId ?? a2aTriggerMessageId; const streamReplyPreview = streamReplyTo @@ -1165,7 +935,7 @@ export async function* routeSerial( // Fallback chain ensures queue path also gets the hint without changing // streamReplyTo/auto-replyTo behavior (those have different semantics). // Same-thread triggers / agent-key path naturally return null inside the helper. - const crossThreadReplyHintTriggerId = worklistEntry.a2aTriggerMessageId.get(catId) ?? currentUserMessageId; + const crossThreadReplyHintTriggerId = activeA2ATriggerMessageId ?? currentUserMessageId; const crossThreadReplyHintRaw = crossThreadReplyHintTriggerId ? await hydrateCrossThreadReplyHint(deps.messageStore, crossThreadReplyHintTriggerId) : null; @@ -1203,6 +973,34 @@ export async function* routeSerial( const { getActivePackBlocks } = await import('../../../../packs/getActivePackBlocks.js'); packBlocks = await getActivePackBlocks(deps.packStore); } + let service: AgentService; + try { + service = getService(deps.services, catId, deps.unavailableServices); + } catch (error) { + if (!(error instanceof AgentServiceUnavailableError)) throw error; + yield { type: 'error', catId, content: error.message, error: error.message, timestamp: Date.now() }; + const commitAllowed = options.beforeOutputCommit ? await options.beforeOutputCommit(catId) : true; + if (commitAllowed) { + try { + await deps.messageStore.append({ + from: { kind: 'system', service: 'agent-error' }, + userId: 'system', + content: `Error: ${error.message}`, + mentions: [], + origin: 'stream', + timestamp: Date.now(), + threadId, + }); + } catch (persistError) { + log.error({ catId, err: persistError }, 'messageStore.append (unavailable member) failed'); + } + } else if (options.persistenceContext) { + options.persistenceContext.actionOutputCommitRejected = true; + } + yield { type: 'done', catId, isFinal: false, timestamp: Date.now() }; + index++; + continue; + } const resolvedCapacitySnapshot = await resolveInvocationCapacitySnapshot({ catId, service, @@ -1252,42 +1050,23 @@ export async function* routeSerial( fromCatId: directMessageFrom, }) : declaredTurnCustodyWake)); - if (!isFreshnessSupplement && turnCustodyWake.kind !== 'non_obligation') { - const dispatchHandoff = - turnCustodyWake.kind === 'structured' && turnCustodyWake.protocol === 'dispatch' - ? turnCustodyWake.handoff - : undefined; - const handoffWrite = recordBallHanded( - deps.ballCustody, - threadId, - dispatchHandoff?.fromCatId ?? directMessageFrom, - catId as string, - dispatchHandoff?.messageId ?? streamReplyTo ?? currentUserMessageId, - ); - if (deps.turnCustodyProjectionService && turnCustodyWake.kind === 'structured') { - await handoffWrite; - } else { - void handoffWrite; - } - } // Settle the preceding turn at the earliest boundary that can contain its // receiver-side handoff. Closing here prevents a later ping-pong turn by // the same cat from being misattributed to the earlier projection. await flushTurnCustodyShadowCloses('next_turn_boundary'); let turnCustodyProjection: TurnCustodyProjection | undefined; - if (deps.turnCustodyProjectionService) { + // Ordinary messages and managed continuations are already closed by the + // source -> Queue -> response lifecycle. Do not route them through the + // legacy turn-custody gate, even as a covered-empty shadow projection. + // Exact action successors and genuinely legacy wakes retain that gate; + // non-obligation coordination still records its terminal witness below. + if ( + deps.turnCustodyProjectionService && + turnCustodyWake.kind !== 'structured' && + turnCustodyWake.kind !== 'unstructured' + ) { turnCustodyProjection = await deps.turnCustodyProjectionService.open(turnCustodyWake); } - const adoptedTurnCustodyProjections: Array<{ - wake: Extract; - projection: TurnCustodyProjection; - }> = []; - const structuredDispositionPrompt = - turnCustodyWake.kind === 'structured' && - (turnCustodyWake.protocol === 'hold' || turnCustodyWake.protocol === 'dispatch') && - turnCustodyProjection?.state !== 'covered_empty' - ? buildTurnCustodyStopGateRemedialPrompt(turnCustodyWake) - : undefined; let turnCustodyShadowRecorded = false; const turnCustodyTerminalWitnesses: QueueTerminalConsumptionWitness[] = []; const recordTurnCustodyTerminalWitness = (witness: QueueTerminalConsumptionWitness): void => { @@ -1308,41 +1087,10 @@ export async function* routeSerial( }); if (!duplicate) turnCustodyTerminalWitnesses.push(witness); }; - const prepareTurnCustodyAdoption = async (wakes: readonly TurnCustodyWakeProvenance[]): Promise<() => void> => { - if (!deps.turnCustodyProjectionService) return () => undefined; - const prepared: typeof adoptedTurnCustodyProjections = []; - for (const wake of wakes) { - // Prompt/tool adoption currently applies only to command-backed hold - // receipts. Dispatch custody still requires its own routed child. - if (wake.kind !== 'structured' || wake.protocol !== 'hold') continue; - const duplicatesPrimary = - turnCustodyWake.kind === 'structured' && - turnCustodyWake.protocol === 'hold' && - turnCustodyWake.sourceMessageId === wake.sourceMessageId && - turnCustodyWake.taskId === wake.taskId; - const alreadyAdopted = [...adoptedTurnCustodyProjections, ...prepared].some( - (candidate) => - candidate.wake.sourceMessageId === wake.sourceMessageId && candidate.wake.taskId === wake.taskId, - ); - if (duplicatesPrimary || alreadyAdopted) continue; - prepared.push({ - wake, - projection: await deps.turnCustodyProjectionService.open(wake), - }); - } - // Queue/body custody is append-only, so all I/O happens above. The - // registry invokes this synchronous publication only after exposure commits. - return () => { - adoptedTurnCustodyProjections.push(...prepared); - }; - }; const hasNativeL0 = service.injectsL0Natively?.() ?? false; const staticIdentity = hasNativeL0 ? buildStaticIdentityPackOnly(catId, { packBlocks }) : buildStaticIdentity(catId, { mcpAvailable, packBlocks }); - // F237: drain session trace synchronously — before any await between - // buildStaticIdentity and buildInvocationContext (race-safety for parallel reuse). - drainCapturedTraces(); // L0-budget-defense PR-B-impl (ADR-038 件套 ④): staging is NOT prepended // to staticIdentity here. Cloud R2 P1 #2237 L1099: folding staging into // staticIdentity breaks ADR-038 "每轮注入生效" contract on resumed @@ -1464,8 +1212,6 @@ export async function* routeSerial( ] .filter(Boolean) .join('\n\n'); - // F237: drain turn trace synchronously — no yield between build and drain. - drainCapturedTraces(); const continuityCapsule = buildCapsuleFromRouteState({ threadId, catId: catId as string, @@ -1541,36 +1287,6 @@ export async function* routeSerial( } } - // F237: fire-and-forget injection trace persist (v0 — observability only) - // Placed after bootstrapContext so per-turn trace covers ALL route-level - // injected system/control content (invocation + mode prompt + bootstrap + MCP). - try { - const traceStore = getTraceStore(); - if (traceStore) { - const traceTurnId = crypto.randomUUID(); - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const trace = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(trace, traceMeta); - const detail = buildTraceDetail(trace, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); - }); - } - // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates - // the module-global capturedSessionTrace without draining. Clear it so the next - // invocation (especially native-L0 pack-only) doesn't persist stale session traces. - if (deps.injectionTraceStore) drainCapturedTraces(); - } catch { - /* F237: trace collection must never break invocation */ - } - let deliveryBoundaryId: string | undefined; let incrementallyExposedMessageIds: string[] = []; const invocationMessagePrompt = prompt; @@ -1593,9 +1309,6 @@ export async function* routeSerial( proactiveMemoryNudgeFinalized = true; } } - if (structuredDispositionPrompt) { - projectedPrompt = `${projectedPrompt}\n\n---\n\n${structuredDispositionPrompt}`; - } return projectedPrompt; }; const materializeFinalGenerationBriefing = async (): Promise => { @@ -1855,10 +1568,12 @@ export async function* routeSerial( } let textContent = ''; + let persistedDoneContent: string | undefined; const thinkingChunks: string[] = []; let persistedMetadata: MessageMetadata | undefined; let doneMsg: AgentMessage | undefined; - let persistedDoneContent: string | undefined; + let lifecycleResponseMessageId: string | undefined; + let lifecyclePriorFrontierMessageId: string | null | undefined; let hadError = false; /** F155: tracks whether cat produced user-visible output (for guide completion ack). */ let catProducedOutput = false; @@ -1890,18 +1605,6 @@ export async function* routeSerial( }; // #573/#1332: Keep callback replacement state behind one focused boundary. const callbackFinalReplacement = new CallbackFinalReplacementTracker(recordPersistedOutputMessageId); - let dispatchDispositionToolSettled = false; - let postDispatchDispositionProgress = false; - const observePostDispositionProgress = (msg: AgentMessage): void => { - if (dispatchDispositionToolSettled && isSubstantivePostDispositionProgress(msg)) { - postDispatchDispositionProgress = true; - } - }; - const observeSettledTool = (tool: PendingToolResult | undefined): void => { - if (tool && normalizeMcpToolName(tool.toolName) === 'complete_a2a_dispatch') { - dispatchDispositionToolSettled = true; - } - }; const verifiedConciergeToolTargets = new VerifiedConciergeToolTargetCollector(); const pendingCallbackRoutingExits: CallbackContentRoutingExit[] = []; const confirmedCallbackRoutingGuardMentions = new Set(); @@ -1910,24 +1613,6 @@ export async function* routeSerial( let confirmedCallbackRoutingGuardHasCoCreatorLineStartMention = false; let confirmedLocalCallbackRoutingHasCoCreatorLineStartMention = false; const emittedBallHandedCvoMessageIds = new Set(); - const acceptedTurnCustodyHandoffs = new Map(); - const noteAcceptedTurnCustodyHandoff = (targetCatId: CatId, messageId: string): void => { - acceptedTurnCustodyHandoffs.set(`${messageId}:${targetCatId}`, { targetCatId, messageId }); - }; - const emitSingleAcceptedTurnCustodyHandoff = (): void => { - if (acceptedTurnCustodyHandoffs.size !== 1 || isFreshnessSupplement) return; - const accepted = [...acceptedTurnCustodyHandoffs.values()][0]; - if (!accepted) return; - pendingTurnCustodyTransitionWrites.push( - recordBallHanded( - deps.ballCustody, - threadId, - catId as string, - accepted.targetCatId as string, - accepted.messageId, - ), - ); - }; const structuredTargetCats = new Set(); // F060: Collect rich blocks emitted inline via system_info (not MCP buffer) const streamRichBlocks: import('@cat-cafe/shared').RichBlock[] = []; @@ -2045,25 +1730,53 @@ export async function* routeSerial( ); } }; + let eventBackedRoutingExitPromise: ReturnType | undefined; let verifiedEventBackedRoutingExit = false; - const readVerifiedWaitContinuation = async (sourceMessageId: string | undefined, holdTaskId?: string) => { - const resolution = await resolveTypedWaitContinuation({ + const hasVerifiedEventBackedRoutingExit = async (): Promise => { + const lifecycleResponse = lifecycleResponseMessageId + ? await deps.messageStore.getById(lifecycleResponseMessageId) + : null; + const lifecycleInputMessageIds = + lifecycleResponse?.lifecycle?.kind === 'response' && + lifecycleResponse.lifecycle.invocationId === ownInvocationId + ? lifecycleResponse.lifecycle.inputMessageIds + : []; + const sources = + lifecycleInputMessageIds.length > 0 + ? await Promise.all( + lifecycleInputMessageIds.map(async (sourceMessageId) => { + const source = await deps.messageStore.getById(sourceMessageId); + const taskId = source?.source?.meta?.taskId; + return { + sourceMessageId, + ...(source && + isOwnerVisibleManagedHoldConnector(source, userId) && + source.source?.meta?.catId === catId && + typeof taskId === 'string' && + taskId.length > 0 + ? { holdTaskId: taskId } + : {}), + }; + }), + ) + : []; + eventBackedRoutingExitPromise ??= resolveEventBackedRoutingExit({ taskStore: deps.taskStore, - threadId, userId, + threadId, catId: catId as string, invocationId: ownInvocationId, - sourceMessageId, - ...(holdTaskId ? { holdTaskId } : {}), + sources, }); + const resolution = await eventBackedRoutingExitPromise; if (resolution.kind === 'reject') { routingEventWaitRejectedTotal.add(1, { [ROUTING_EVENT_WAIT_REASON]: resolution.reason }); - return undefined; + return false; } routingEventWaitBypassTotal.add(1); routingEventWaitRedundantHoldPreventedTotal.add(1); verifiedEventBackedRoutingExit = true; - return resolution.reference; + return true; }; let visibleContentInvocationIdOverride: string | undefined; // F111 Phase B: Streaming TTS chunker for real-time voice (voiceMode only) @@ -2103,8 +1816,11 @@ export async function* routeSerial( const leakedPayloadStripper = createLeakedToolCallStreamStripper(); const invocationSpanRef: { current?: Span } = {}; const invocationStartedAt = Date.now(); - // F215 AC-C3: flag set when invokeSingleCat emits malformed_toolcall_relay_46 signal - let malformedRelayPending = false; + // F215 AC-C3: a malformed 4.8 turn asks the canonical response lifecycle + // to publish one durable wake for the 4.6 relay. It must never mutate the + // current route worklist: that bypassed Queue custody and made the child + // invocation inseparable from its failed parent route. + let malformedRelayTarget: CatId | undefined; const createVoiceChunker = (invocationId: string): StreamingTtsChunker | undefined => { if (!voiceMode || !deps.socketManager) return undefined; const ttsRegistry = getStreamingTtsRegistry(); @@ -2195,9 +1911,7 @@ export async function* routeSerial( const eventKey = `${eventThreadId}:${messageId}`; if (emittedBallHandedCvoMessageIds.has(eventKey)) return; emittedBallHandedCvoMessageIds.add(eventKey); - pendingTurnCustodyTransitionWrites.push( - emitBallHandedCvo(deps.ballCustody, eventThreadId, catId as string, messageId), - ); + void emitBallHandedCvo(deps.ballCustody, eventThreadId, catId as string, messageId); }; const emitConfirmedCallbackBallHandedCvo = ( confirmed: boolean, @@ -2235,10 +1949,6 @@ export async function* routeSerial( } : projectedMsg; }; - // The caller may extend this worklist only while its route turn is live. Keeping the - // window closed through prompt/session setup also rejects stale callbacks from an earlier - // occurrence of the same cat in this parent chain. - setWorklistCallerAdmissionOpen(worklistEntry, true); for await (const msg of invokeSingleCat(deps.invocationDeps, { ...(options.routeIntent ? { routeIntent: options.routeIntent } : {}), ...(options.routingContextIntent ? { routingContextIntent: options.routingContextIntent } : {}), @@ -2267,7 +1977,6 @@ export async function* routeSerial( ...(options.asrPersonMemoryScenes?.length ? { asrPersonMemoryScenes: options.asrPersonMemoryScenes } : {}), ...(memoryCueLegacyFallbacks.length > 0 ? { memoryCueLegacyFallbacks } : {}), ...(options.toolExecutionPolicy ? { toolExecutionPolicy: options.toolExecutionPolicy } : {}), - ...(options.executionScope ? { executionScope: options.executionScope } : {}), executionKind: initialExecutionKind, executionCausal: { ...((options.cloudDispatchProvenance?.sourceMessageId ?? @@ -2288,14 +1997,16 @@ export async function* routeSerial( ...(options.onPromptMessagesExposed ? { onPromptMessagesExposed: async (input) => { - const adoptedWakes = await options.onPromptMessagesExposed!(input); - if (adoptedWakes && !(await turnCustodyAdoptionRegistry.adopt(input.invocationId, adoptedWakes))) { - throw new Error('turn custody adoption owner unavailable before prompt exposure'); - } - return adoptedWakes; + return options.onPromptMessagesExposed!(input); }, } : {}), + ...(options.onLifecycleInvocationStarted + ? { onLifecycleInvocationStarted: options.onLifecycleInvocationStarted } + : {}), + ...(options.onAgentClientActiveRunReady + ? { onAgentClientActiveRunReady: options.onAgentClientActiveRunReady } + : {}), // F247 AC-B1c-3 PR-C: Plumb raw mention text + mentioning cat for cloud bridge dispatch. // - mentionContent: the raw user/cat message (NOT the orchestrated prompt with system context) // - mentioningCatId: A2A → the cat that @ mentioned; user-initiated → userId as fallback @@ -2312,9 +2023,7 @@ export async function* routeSerial( : {}), // F121/F167: Keep stream threading and callback auth provenance on the same trigger. ...(streamReplyTo ? { a2aTriggerMessageId: streamReplyTo } : {}), - ...((mentionParentSpan.get(index) ?? options.routeSpan) - ? { routeSpan: mentionParentSpan.get(index) ?? options.routeSpan } - : {}), + ...(options.routeSpan ? { routeSpan: options.routeSpan } : {}), invocationSpanRef, isLastCat: false, })) { @@ -2345,7 +2054,6 @@ export async function* routeSerial( } for (const effectiveMsg of effectiveMsgs) { - observePostDispositionProgress(effectiveMsg); // F22 R2 P1-1: Capture invocationId from the initial system_info. // Keep forwarding this boundary event so frontend can reset stale task progress. if (effectiveMsg.type === 'system_info' && effectiveMsg.content && !ownInvocationId) { @@ -2357,10 +2065,18 @@ export async function* routeSerial( parsed.invocationId.length > 0 ) { ownInvocationId = parsed.invocationId; - unregisterTurnCustodyAdoption = turnCustodyAdoptionRegistry.register( - parsed.invocationId, - prepareTurnCustodyAdoption, - ); + if ( + typeof effectiveMsg.lifecycleResponseMessageId === 'string' && + effectiveMsg.lifecycleResponseMessageId.length > 0 + ) { + lifecycleResponseMessageId = effectiveMsg.lifecycleResponseMessageId; + if ( + effectiveMsg.lifecyclePriorFrontierMessageId === null || + typeof effectiveMsg.lifecyclePriorFrontierMessageId === 'string' + ) { + lifecyclePriorFrontierMessageId = effectiveMsg.lifecyclePriorFrontierMessageId; + } + } rememberTurnExecutionProjection(parsed.invocationId, initialExecutionKind); if (!isFreshnessSupplement) { emitBallInvocationStarted(deps.ballCustody, threadId, ownInvocationId, catId as string); @@ -2417,10 +2133,10 @@ export async function* routeSerial( if (parsed.type === 'malformed_toolcall_relay_46') { const relay46CatId = createCatId('opus'); if (catId !== relay46CatId && Object.hasOwn(deps.services, relay46CatId as string)) { - malformedRelayPending = true; + malformedRelayTarget = relay46CatId; log.info( { catId: catId as string, threadId, relay46CatId }, - '[F215] malformed_toolcall_relay_46 signal received — will push opus-4.6 after loop', + '[F215] malformed_toolcall_relay_46 signal received — will publish a durable relay wake', ); } continue; // consume routing signal — never surfaces to user as raw JSON @@ -2431,7 +2147,7 @@ export async function* routeSerial( } // F215 AC-C3: suppress malformed error when relay to 46 is already queued if ( - malformedRelayPending && + malformedRelayTarget && effectiveMsg.type === 'error' && typeof effectiveMsg.error === 'string' && effectiveMsg.error.startsWith('malformed_toolcall:') @@ -2478,7 +2194,6 @@ export async function* routeSerial( callbackResult.confirmed, Boolean(callbackResult.messageId && callbackResult.threadId), ); - observeSettledTool(completedToolName); if (completedToolName && isPostMessageToolName(completedToolName.toolName) && callbackResult.confirmed) { callbackFinalReplacement.recordConfirmedPost( completedToolName.streamDisposition ?? 'independent', @@ -2493,15 +2208,6 @@ export async function* routeSerial( callbackResult.messageId, callbackResult.threadId, ); - if ( - callbackResult.confirmed && - callbackResult.messageId && - settledExit?.scope === 'target' && - settledExit.createsCustodyHandoff && - settledExit.targetCatIds.length === 1 - ) { - noteAcceptedTurnCustodyHandoff(settledExit.targetCatIds[0]!, callbackResult.messageId); - } } // F188 Phase F AC-F10 (砚砚 六审 P1-B: also scope by catId for serial route consistency). // 砚砚 cloud-3 P1: also pass toolUseId for exact match when available; @@ -2668,6 +2374,10 @@ export async function* routeSerial( if (effectiveMsg.type === 'done') { doneMsg = effectiveMsg; // Buffer — yield after A2A detection } else { + // Once the invocation owns a lifecycle response, provider failures + // terminalize that same bubble. Yielding a second error event would + // manufacture a transient system surface that disappears on reload. + if (effectiveMsg.type === 'error' && lifecycleResponseMessageId) continue; const streamEvent = toStreamEvent(effectiveMsg); if (!streamEvent) continue; yield streamEvent; @@ -2687,35 +2397,6 @@ export async function* routeSerial( // broadcast until it succeeds. const actionOutputCommitAllowed = options.beforeOutputCommit ? await options.beforeOutputCommit(catId) : true; - // F215 AC-C3: push opus-4.6 to worklist as relay when 48 炸毛 + fresh retry also failed - if (actionOutputCommitAllowed && malformedRelayPending) { - const relay46CatId = createCatId('opus'); - if ( - catId !== relay46CatId && - Object.hasOwn(deps.services, relay46CatId as string) && - // P2 fix + P1 #1 fix: only check PENDING entries (worklist[index+1..]) not the full - // worklist. worklist[0..index] are already executed; including them would silently skip - // a legitimate relay when opus ran first in the route (e.g. [opus, opus-48]). - !worklist.slice(index + 1).includes(relay46CatId) - ) { - worklist.push(relay46CatId); - worklistEntry.a2aCount++; - worklistEntry.a2aFrom.set(relay46CatId, catId); - log.info( - { catId: catId as string, relay46CatId, threadId, a2aCount: worklistEntry.a2aCount }, - '[F215] Pushed opus-4.6 to worklist for malformed tool-call relay (AC-C3)', - ); - } else if (worklist.slice(index + 1).includes(relay46CatId)) { - log.info( - { catId: catId as string, relay46CatId, threadId }, - '[F215] opus-4.6 already pending in worklist — skipping duplicate relay push (P2 dedup)', - ); - } - malformedRelayPending = false; - } else if (malformedRelayPending) { - malformedRelayPending = false; - } - if (voiceChunker) { // F111 Phase B: Flush remaining buffered text and send voice_stream_end. // Guard-enabled turns do not create this first-pass chunker; their voice is flushed @@ -2733,45 +2414,6 @@ export async function* routeSerial( // F061: Detect @co-creator mentions in agent response for browser notification let mentionsUser = false; - const appendStopGateFailureNotice = async () => { - try { - const failureSource = { - connector: 'routing-guard-failure', - label: '路由守卫失败', - icon: '🏓', - meta: { presentation: 'system_notice', noticeTone: 'warning' }, - }; - const stored = await deps.messageStore.append({ - userId: 'system', - catId: null, - threadId, - content: - structuredDispositionMissingCode === 'managed_hold_disposition_missing' - ? '[F167 球权停止门]: 本轮已读取的持球通知尚未结算;命令结果与通知结算是两件事。已保留通知及其回执,未启动其他回合代替结算。' - : structuredDispositionMissingCode === 'a2a_dispatch_disposition_missing' - ? '[F167 球权停止门]: 本轮 A2A 消息尚未结算;已保留原消息及其回执,未启动其他回合代替结算。' - : '[F167 球权停止门]: 结构化补救后,当前协议球仍没有可验证状态迁移;已停止自动重试并保留原球权真相。', - mentions: [], - timestamp: Date.now(), - source: failureSource, - }); - if (deps.socketManager) { - deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: stored.id, - type: 'connector', - content: stored.content, - source: failureSource, - timestamp: stored.timestamp, - }, - }); - } - } catch { - /* non-blocking stop-gate failure notice */ - } - }; - const observeLegacyRoutingBlock = (input: Parameters[0]): boolean => { if ( hasTerminalCoordinationExit && @@ -2802,16 +2444,9 @@ export async function* routeSerial( ...(crossThreadReplyHint?.effectClass ? { effectClass: crossThreadReplyHint.effectClass } : {}), }); pendingTurnCustodyShadowCloses.push(async (closeCheckpoint) => { - const rawDecision = await deps.turnCustodyProjectionService!.close(projection); - const primaryHold = - turnCustodyWake.kind === 'structured' && turnCustodyWake.protocol === 'hold' ? turnCustodyWake : undefined; const verifiedEventWait = - rawDecision.state === 'covered_active' && !rawDecision.transitionObserved - ? await readVerifiedWaitContinuation( - primaryHold?.sourceMessageId ?? turnTriggerMessageId ?? options.currentUserMessageId, - primaryHold?.taskId, - ) - : undefined; + projection.state === 'covered_active' ? await hasVerifiedEventBackedRoutingExit() : false; + const rawDecision = await deps.turnCustodyProjectionService!.close(projection); const newDecision = verifiedEventWait && rawDecision.state === 'covered_active' ? { @@ -2821,47 +2456,6 @@ export async function* routeSerial( evidenceRefs: [...rawDecision.evidenceRefs, 'event_wait:verified'], } : rawDecision; - if ( - turnCustodyWake.kind === 'structured' && - turnCustodyWake.protocol === 'hold' && - newDecision.transitionObserved && - newDecision.structuredTransitionKind !== 'hold_dispositioned' - ) { - const transition = verifiedEventWait - ? 'event_wait' - : newDecision.structuredTransitionKind === 'held' - ? 'reheld' - : newDecision.structuredTransitionKind === 'handed' - ? 'transferred' - : undefined; - if (transition) { - recordTurnCustodyTerminalWitness({ - kind: 'managed_hold_continued', - sourceMessageId: turnCustodyWake.sourceMessageId, - taskId: turnCustodyWake.taskId, - transition, - ...(transition === 'event_wait' && verifiedEventWait ? { waitRegistration: verifiedEventWait } : {}), - }); - } - } - if ( - turnCustodyWake.kind === 'structured' && - turnCustodyWake.protocol === 'dispatch' && - newDecision.transitionObserved && - newDecision.structuredTransitionKind === 'dispatch_dispositioned' && - newDecision.dispatchDisposition === 'handled' && - dispatchDispositionToolSettled && - !postDispatchDispositionProgress && - newDecision.dispatchDispositionEventId && - newDecision.dispatchDispositionAt !== undefined - ) { - recordTurnCustodyTerminalWitness({ - kind: 'dispatch_handled_continuation', - sourceMessageId: turnCustodyWake.handoff.messageId, - dispositionEventId: newDecision.dispatchDispositionEventId, - dispositionAt: newDecision.dispatchDispositionAt, - }); - } if ( newDecision.state === 'covered_empty' && turnCustodyWake.kind === 'non_obligation' && @@ -2948,55 +2542,8 @@ export async function* routeSerial( 'F167 Phase T turn-custody stop-gate verdict', ); - let adoptedStructuredDispositionBlocked = false; - for (const adopted of adoptedTurnCustodyProjections) { - const rawAdoptedDecision = await deps.turnCustodyProjectionService!.close(adopted.projection); - const adoptedWait = - rawAdoptedDecision.state === 'covered_active' && !rawAdoptedDecision.transitionObserved - ? await readVerifiedWaitContinuation(adopted.wake.sourceMessageId, adopted.wake.taskId) - : undefined; - const adoptedDecision = adoptedWait - ? { - ...rawAdoptedDecision, - shouldBlock: false, - transitionObserved: true, - evidenceRefs: [...rawAdoptedDecision.evidenceRefs, 'event_wait:verified'], - } - : rawAdoptedDecision; - const transition = adoptedWait - ? 'event_wait' - : adoptedDecision.structuredTransitionKind === 'held' - ? 'reheld' - : adoptedDecision.structuredTransitionKind === 'handed' - ? 'transferred' - : undefined; - if (adoptedDecision.transitionObserved && transition) { - recordTurnCustodyTerminalWitness({ - kind: 'managed_hold_continued', - sourceMessageId: adopted.wake.sourceMessageId, - taskId: adopted.wake.taskId, - transition, - ...(transition === 'event_wait' && adoptedWait ? { waitRegistration: adoptedWait } : {}), - }); - } - adoptedStructuredDispositionBlocked = adoptedDecision.shouldBlock || adoptedStructuredDispositionBlocked; - log.info( - { - threadId, - catId: catId as string, - sourceMessageId: adopted.wake.sourceMessageId, - taskId: adopted.wake.taskId, - state: adoptedDecision.state, - closeCheckpoint, - transitionObserved: adoptedDecision.transitionObserved, - evidenceRefs: adoptedDecision.evidenceRefs, - }, - 'F167 adopted managed-hold stop-gate verdict', - ); - } - if ( - (!newDecision.shouldBlock && !adoptedStructuredDispositionBlocked) || + !newDecision.shouldBlock || hadError || !actionOutputCommitAllowed || isFreshnessSupplement || @@ -3005,44 +2552,27 @@ export async function* routeSerial( return; } - // The exact F254 body exposure belongs to the invocation that just - // finished. A routing-guard child has different callback credentials, - // so omission must fail this attempt and restore the same Queue carrier - // instead of letting a second invocation impersonate its disposition. - if ( - turnCustodyWake.kind === 'structured' && - (turnCustodyWake.protocol === 'hold' || turnCustodyWake.protocol === 'dispatch') - ) { - stopGateRemedialAttempted = true; - structuredDispositionMissingCode = - turnCustodyWake.protocol === 'hold' - ? 'managed_hold_disposition_missing' - : 'a2a_dispatch_disposition_missing'; - await appendStopGateFailureNotice(); - return; - } - - if (adoptedStructuredDispositionBlocked) { - stopGateRemedialAttempted = true; - structuredDispositionMissingCode = 'managed_hold_disposition_missing'; - await appendStopGateFailureNotice(); - return; - } - const originalOwnInvocationId = ownInvocationId; const originalDoneMsg = doneMsg; const originalTextContent = textContent; const originalPersistedMetadata = persistedMetadata; try { await runTurnCustodyStopGateRemedial('', [], []); - emitSingleAcceptedTurnCustodyHandoff(); - const remedialTransitionWrites = pendingTurnCustodyTransitionWrites.splice(0); - if (remedialTransitionWrites.length > 0) { - await Promise.allSettled(remedialTransitionWrites); - } const remediatedDecision = await deps.turnCustodyProjectionService!.close(projection); if (remediatedDecision.shouldBlock) { - await appendStopGateFailureNotice(); + // Internal lifecycle diagnostics stay in telemetry. They must not + // manufacture a persisted/broadcast conversation message that + // presentation layers then have to hide. + log.warn( + { + threadId, + catId: catId as string, + state: remediatedDecision.state, + transitionObserved: remediatedDecision.transitionObserved, + evidenceRefs: remediatedDecision.evidenceRefs, + }, + 'F167 Phase T remediation ended without a custody transition', + ); } if (turnStoredMessageId && deps.invocationDeps.turnExecutionStore) { const projections = await readTurnExecutionProjections(originalOwnInvocationId); @@ -3081,13 +2611,15 @@ export async function* routeSerial( ...(options.routingContextIntent ? { intent: options.routingContextIntent } : {}), }); const receipt = routingDispatchPreflightReceipt(remedialRoutingDispatchPreflightDecision, catId); - if (receipt.target.disposition !== 'allowed') { - remedialRoutingPreflightNotice = { - type: 'system_info', + if (isUserVisibleRoutingPreflightReceipt(receipt)) { + remedialRoutingPreflightNotice = await routingPreflightNotice( + deps, + options, + remedialRoutingDispatchPreflightDecision, catId, - content: JSON.stringify(receipt), - timestamp: Date.now(), - }; + threadId, + false, + ); } if (receipt.target.disposition === 'rejected') { stopGateRemedialAttempted = true; @@ -3217,12 +2749,9 @@ export async function* routeSerial( ...(options.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}), continuityCapsule, ...(streamReplyTo ? { a2aTriggerMessageId: streamReplyTo } : {}), - ...((mentionParentSpan.get(index) ?? options.routeSpan) - ? { routeSpan: mentionParentSpan.get(index) ?? options.routeSpan } - : {}), + ...(options.routeSpan ? { routeSpan: options.routeSpan } : {}), invocationSpanRef, ...(options.toolExecutionPolicy ? { toolExecutionPolicy: options.toolExecutionPolicy } : {}), - ...(options.executionScope ? { executionScope: options.executionScope } : {}), executionKind: 'routing_guard', executionCausal: { ...((streamReplyTo ?? currentUserMessageId ?? a2aTriggerMessageId) @@ -3253,7 +2782,6 @@ export async function* routeSerial( } for (const effectiveMsg of remedialMsgs) { - observePostDispositionProgress(effectiveMsg); if (effectiveMsg.type === 'system_info' && effectiveMsg.content && !ownInvocationId) { try { const parsed = JSON.parse(effectiveMsg.content); @@ -3344,7 +2872,6 @@ export async function* routeSerial( callbackResult.confirmed, Boolean(callbackResult.messageId && callbackResult.threadId), ); - observeSettledTool(completedToolName); if (completedToolName && isPostMessageToolName(completedToolName.toolName) && callbackResult.confirmed) { callbackFinalReplacement.recordConfirmedPost( completedToolName.streamDisposition ?? 'independent', @@ -3359,15 +2886,6 @@ export async function* routeSerial( callbackResult.messageId, callbackResult.threadId, ); - if ( - callbackResult.confirmed && - callbackResult.messageId && - settledExit?.scope === 'target' && - settledExit.createsCustodyHandoff && - settledExit.targetCatIds.length === 1 - ) { - noteAcceptedTurnCustodyHandoff(settledExit.targetCatIds[0]!, callbackResult.messageId); - } } } @@ -3561,7 +3079,7 @@ export async function* routeSerial( cursorStore: deps.deliveryCursorStore!, messageStore: deps.messageStore, messageFilter: (msg: Record) => { - if (msg.userId === 'system') return false; + if (messageFrom(msg as unknown as Parameters[0]).kind === 'system') return false; if (msg.origin === 'briefing') return false; const viewer = (thinkingMode ?? 'play') === 'play' @@ -3616,6 +3134,8 @@ export async function* routeSerial( if (!actionOutputCommitAllowed && textContent) await scheduleTurnCustodyStopGate(false); + const terminalFailureContent = lifecycleResponseMessageId && collectedErrorText ? collectedErrorText : undefined; + if (!actionOutputCommitAllowed) { catProducedOutput = Boolean(textContent || bufferedBlocks.length > 0 || collectedToolEvents.length > 0); if (options.persistenceContext) { @@ -3625,6 +3145,27 @@ export async function* routeSerial( } } a2aMentions = []; + if (lifecycleResponseMessageId && ownInvocationId) { + await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponseMessageId, + ownInvocationId, + { + status: 'interrupted', + completedAt: Math.max(Date.now(), invocationStartedAt), + reason: 'output_commit_rejected', + }, + { + from: { kind: 'agent', catId }, + userId, + content: '', + mentions: [], + origin: 'stream', + timestamp: invocationStartedAt, + threadId, + }, + ); + } } else if (textContent) { catProducedOutput = true; const sanitized = sanitizeInjectedContent(textContent); @@ -3694,6 +3235,17 @@ export async function* routeSerial( ); await scheduleTurnCustodyStopGate(textLegacyObservedBlock); a2aMentions = getLocalRoutingLineStartMentions(a2aMentions); + if ( + malformedRelayTarget && + !worklist.slice(index + 1).includes(malformedRelayTarget) && + !a2aMentions.includes(malformedRelayTarget) + ) { + a2aMentions = [...a2aMentions, malformedRelayTarget]; + log.info( + { catId: catId as string, relay46CatId: malformedRelayTarget, threadId }, + '[F215] Publishing opus-4.6 relay through completed-response durable wake', + ); + } // Preserve independent first-pass reasoning inside one serial route. // Only debug mode injects an earlier cat's in-flight response directly; @@ -3704,8 +3256,8 @@ export async function* routeSerial( // F167 Phase H AC-H3/H5 (KD-24): final routing slot validator. // Mechanical slot check with zero intent classifier. Runs BEFORE #417 - // inline-mention-hint and AC-C7 verdict warn; hit suppresses the system_info - // emit on both (but keeps setMentionRoutingFeedback for next-turn correction). + // inline mention and AC-C7 verdict checks; a syntax hit remains the root + // correction signal (and keeps setMentionRoutingFeedback for the next turn). const phaseHRosterHandles: string[] = []; { const allCfg = catRegistry.getAllConfigs(); @@ -3722,38 +3274,11 @@ export async function* routeSerial( }); const phaseHHit = !isFreshnessSupplement && phaseHResult.kind === 'invalid_route_syntax'; if (phaseHHit && phaseHResult.kind === 'invalid_route_syntax') { - try { - const inlineList = phaseHResult.inlineMentions.map((h) => `@${h}`).join(' '); - const hintSource = { - connector: 'routing-syntax-hint', - label: '路由语法提醒', - icon: '⚠️', - meta: { presentation: 'system_notice', noticeTone: 'warning' }, - }; - const stored = await deps.messageStore.append({ - userId: 'system', - catId: null, - threadId, - content: `[路由语法]: ${inlineList} 写在行中不会触发路由 — 把 @句柄 移到最后一行行首独立一行即可。`, - mentions: [], - timestamp: Date.now(), - source: hintSource, - }); - if (deps.socketManager) { - deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: stored.id, - type: 'connector', - content: stored.content, - source: hintSource, - timestamp: stored.timestamp, - }, - }); - } - } catch { - /* non-blocking hint */ - } + routingSyntaxCorrectionDetected.add(1); + log.info( + { catId: catId as string, threadId, inlineMentions: phaseHResult.inlineMentions }, + 'Routing syntax guard recorded an internal correction signal', + ); } // #417 / F064 AC-B3: Write-side feedback for inline action-like @mentions @@ -3784,45 +3309,12 @@ export async function* routeSerial( } catch { inlineActionFeedbackWriteFailed.add(1, agentAttr); } - // #1062: User-visible system message when chain would break - // (inline action detected but no line-start @ = no routing will happen) - // F167 Phase H AC-H5: suppress this legacy hint when Phase H already emitted - // routing-syntax-hint for the same turn (dedupe, single authoritative message). + // Keep agent-formatting correction out of public History. The durable + // next-turn feedback above is the correction channel. if (a2aMentions.length === 0 && !phaseHHit) { - try { - const targets = inlineHits.map((h) => `@${h.catId}`).join(', '); - const hintSource = { - connector: 'inline-mention-hint', - label: '路由提示', - icon: '💡', - meta: { presentation: 'system_notice', noticeTone: 'info' }, - }; - const stored = await deps.messageStore.append({ - userId: 'system', - catId: null, - threadId, - content: `想交接给 ${targets}?把它单独放到新起一行开头,才能触发交接。`, - mentions: [], - timestamp: Date.now(), - source: hintSource, - }); - inlineActionHintEmitted.add(1, agentAttr); - // Broadcast so frontend sees it in real-time (same pattern as vote result) - if (deps.socketManager) { - deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: stored.id, - type: 'connector', - content: stored.content, - source: hintSource, - timestamp: stored.timestamp, - }, - }); - } - } catch { - inlineActionHintEmitFailed.add(1, agentAttr); - } + // Preserve the historical metric name for eval continuity; this now + // counts the internal correction signal, not a public hint row. + inlineActionHintEmitted.add(1, agentAttr); } } } @@ -3831,7 +3323,7 @@ export async function* routeSerial( // (format error is the root cause; verdict-without-pass is the consequence). // 2026-04-25 fix (砚砚 GPT-5.5): pass hasCoCreatorLineStartMention so summary // reports ending with `@co-creator` / `@co-creator` (legitimate escalation to co-creator) - // don't trigger the verdict-no-pass-hint false-positive. parseA2AMentions only + // don't trigger the verdict-without-pass false-positive. parseA2AMentions only // returns cat handles, never co-creator ones. // // C2 denominator (F192 2026-05-29): count every turn the verdict-without-pass @@ -3875,47 +3367,17 @@ export async function* routeSerial( hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, }) ) { - try { - const hintSource = { - connector: 'verdict-no-pass-hint', - label: '球权提醒', - icon: '🏓', - meta: { presentation: 'system_notice', noticeTone: 'warning' }, - }; - const stored = await deps.messageStore.append({ - userId: 'system', - catId: null, - threadId, - content: '[球权提醒]: 结论后直接传球,不要停在结论 — 末尾加一行行首 @句柄 或调用 `cat_cafe_hold_ball`。', - mentions: [], - timestamp: Date.now(), - source: hintSource, - }); - const verdictFireAttr: Record = { - ...c2BaseAttr, - [TRIGGER]: detectMatchedVerdictKeyword(storedContent) ?? 'unknown', - }; - c2VerdictHintEmitted.add(1, verdictFireAttr); - c2VerdictWithoutPassCount.add(1, verdictFireAttr); - // F192 Phase D — capture trigger for deferred sample emission. The actual - // addEvent fires after `storedMsgId` is bound to the cat's verdict message - // (post-storage block) so drilldown lands on real content, not on this hint. - pendingC2SampleTrigger = verdictFireAttr[TRIGGER] as string; - if (deps.socketManager) { - deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: stored.id, - type: 'connector', - content: stored.content, - source: hintSource, - timestamp: stored.timestamp, - }, - }); - } - } catch { - /* non-blocking hint */ - } + const verdictFireAttr: Record = { + ...c2BaseAttr, + [TRIGGER]: detectMatchedVerdictKeyword(storedContent) ?? 'unknown', + }; + // Historical metric name retained for eval continuity. This is an + // internal protocol signal and no longer creates a public History row. + c2VerdictHintEmitted.add(1, verdictFireAttr); + c2VerdictWithoutPassCount.add(1, verdictFireAttr); + // F192 Phase D — capture trigger for deferred sample emission. The actual + // addEvent fires after `storedMsgId` is bound to the cat's verdict message. + pendingC2SampleTrigger = verdictFireAttr[TRIGGER] as string; } // F167 Phase I AC-I1 (KD-25): void hold detection — text says "持球" but @@ -3938,46 +3400,15 @@ export async function* routeSerial( hasVerifiedEventBackedRoutingExit: verifiedEventBackedRoutingExit, }); if (voidHoldEval.shouldEmit) { - try { - const hintSource = { - connector: 'void-hold-hint', - label: '持球提醒', - icon: '🏓', - meta: { presentation: 'system_notice', noticeTone: 'warning' }, - }; - const voidStored = await deps.messageStore.append({ - userId: 'system', - catId: null, - threadId, - content: - '[持球提醒]: 检测到持球声明但未调用 hold_ball MCP — ' + - '文字声明不会设定唤醒计时器,请调用 `cat_cafe_hold_ball` 完成持球或改为传球。', - mentions: [], - timestamp: Date.now(), - source: hintSource, - }); - const voidHoldFireAttr: Record = { - ...c2BaseAttr, - [TRIGGER]: voidHoldEval.matchedPattern ?? 'unknown', - }; - c2VoidHoldHintEmitted.add(1, voidHoldFireAttr); - // F192 Phase D — capture trigger for deferred sample event emission. - pendingC2VoidHoldSampleTrigger = voidHoldFireAttr[TRIGGER] as string; - if (deps.socketManager) { - deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: voidStored.id, - type: 'connector', - content: voidStored.content, - source: hintSource, - timestamp: voidStored.timestamp, - }, - }); - } - } catch { - /* non-blocking hint */ - } + const voidHoldFireAttr: Record = { + ...c2BaseAttr, + [TRIGGER]: voidHoldEval.matchedPattern ?? 'unknown', + }; + // Historical metric name retained for eval continuity. The always-on + // protocol prompt is the correction channel, not a public History row. + c2VoidHoldHintEmitted.add(1, voidHoldFireAttr); + // F192 Phase D — capture trigger for deferred sample event emission. + pendingC2VoidHoldSampleTrigger = voidHoldFireAttr[TRIGGER] as string; } // F079 Phase 2: Vote interception — extract [VOTE:xxx] from cat response @@ -4025,8 +3456,8 @@ export async function* routeSerial( // Gap 3: persist separate connector message for ConnectorBubble rendering try { const stored = await deps.messageStore.append({ + from: { kind: 'system', service: 'vote' }, userId, - catId: null, content: `投票结果: ${voteState.question}`, mentions: [], timestamp: Date.now(), @@ -4160,12 +3591,13 @@ export async function* routeSerial( !hadError && !doneMsg?.errorCode && !catSignal?.aborted && + actionOutputCommitAllowed && visibleTurnInvocationId === ownInvocationId, }); const streamMessageInput: AppendMessageInput = { + from: { kind: 'agent', catId }, userId, - catId, - content: persistedContent, + content: terminalFailureContent ? `${storedContent}\n\n${terminalFailureContent}` : storedContent, mentions: a2aMentions, origin: 'stream', timestamp: storedTimestamp, @@ -4200,6 +3632,85 @@ export async function* routeSerial( ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}), }, }; + const abortReason = catSignal?.reason; + const lifecycleTerminalStatus: 'completed' | 'failed' | 'canceled' | 'interrupted' = catSignal?.aborted + ? abortReason === 'user_cancel' || abortReason === 'cancel_all' + ? 'canceled' + : 'interrupted' + : hadProviderError + ? 'failed' + : 'completed'; + const lifecycleTerminalReason = + lifecycleTerminalStatus === 'completed' + ? undefined + : typeof doneMsg?.errorCode === 'string' && doneMsg.errorCode.length > 0 + ? doneMsg.errorCode + : typeof abortReason === 'string' && abortReason.length > 0 + ? abortReason + : lifecycleTerminalStatus === 'failed' + ? 'provider_error' + : lifecycleTerminalStatus; + const lifecycleResponse = + lifecycleResponseMessageId && lifecyclePriorFrontierMessageId !== undefined && ownInvocationId + ? { + messageId: lifecycleResponseMessageId, + priorFrontierMessageId: lifecyclePriorFrontierMessageId, + status: lifecycleTerminalStatus, + completedAt: Math.max(Date.now(), invocationStartedAt), + ...(lifecycleTerminalReason ? { reason: lifecycleTerminalReason } : {}), + } + : undefined; + const completedA2AWakeCommit = + lifecycleResponse?.status === 'completed' && a2aMentions.length > 0 + ? async (message: AppendMessageInput) => { + if (!commitCompletedA2AWake) { + throw new Error('completed response A2A wake admission unavailable'); + } + return commitCompletedA2AWake({ + responseMessageId: lifecycleResponse.messageId, + invocationId: ownInvocationId!, + terminal: { + status: 'completed', + completedAt: lifecycleResponse.completedAt, + }, + message, + targetCats: a2aMentions, + userId, + ownerAuthProvenance, + threadId, + callerCatId: catId, + ...(options.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}), + }); + } + : undefined; + const failedA2AReportCommit = + lifecycleResponse?.status === 'failed' && + a2aTriggerMessageId && + options.a2aCallerCatId && + !options.a2aFailureReport + ? async (message: AppendMessageInput) => { + if (!commitFailedA2AReport) { + throw new Error('failed response A2A report admission unavailable'); + } + return commitFailedA2AReport({ + responseMessageId: lifecycleResponse.messageId, + invocationId: ownInvocationId!, + terminal: { + status: 'failed', + completedAt: lifecycleResponse.completedAt, + ...(lifecycleResponse.reason ? { reason: lifecycleResponse.reason } : {}), + }, + message, + userId, + ownerAuthProvenance, + threadId, + reporterCatId: catId, + predecessorCatId: options.a2aCallerCatId as CatId, + ...(options.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}), + }); + } + : undefined; + const lifecycleWakeCommit = completedA2AWakeCommit ?? failedA2AReportCommit; let storedMsg = null; if (deps.freshnessOutputCommitCoordinator && deps.deliveryCursorStore && ownInvocationId) { const decision = await deps.freshnessOutputCommitCoordinator.commit({ @@ -4212,6 +3723,8 @@ export async function* routeSerial( freshnessClosureId: options.freshnessClosureId, freshnessSupplementId: options.freshnessSupplementId, message: streamMessageInput, + ...(lifecycleResponse ? { lifecycleResponse } : {}), + ...(lifecycleWakeCommit ? { commitLifecycleResponse: lifecycleWakeCommit } : {}), replayUnsafeToolNames: findReplayUnsafeToolNames(collectedToolNames), evaluateFreshness: evaluateStreamFreshness, }); @@ -4232,6 +3745,16 @@ export async function* routeSerial( await enqueueFreshnessSupplement(decision, catId as string); } } + } else if (lifecycleResponse && ownInvocationId) { + storedMsg = lifecycleWakeCommit + ? await lifecycleWakeCommit(streamMessageInput) + : await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponse.messageId, + ownInvocationId, + lifecycleResponse, + streamMessageInput, + ); } else { storedMsg = await deps.messageStore.append(streamMessageInput); } @@ -4391,487 +3914,6 @@ export async function* routeSerial( }); } } - - if (invocationSpanRef.current) catInvocationSpans.set(index, invocationSpanRef.current); - - // A2A: extend worklist if mention found + depth allows + queue fairness gate - // F27: dedup only against pending (not-yet-executed) tail — cats that already ran - // can be re-enqueued for another round (e.g. A→B→A review ping-pong). - let queuedMessagesPending = false; - if (queueHasQueuedMessages) { - try { - queuedMessagesPending = queueHasQueuedMessages(threadId); - } catch { - queuedMessagesPending = false; - } - } - - // Diagnostic: log when A2A text-scan gate blocks - if (a2aMentions.length > 0) { - if (queuedMessagesPending) { - log.info( - { threadId, catId, a2aMentions, a2aCount: worklistEntry.a2aCount }, - 'A2A text-scan blocked: non-agent messages pending in queue (fairness gate)', - ); - } else if (worklistEntry.a2aCount >= maxDepth) { - log.info( - { threadId, catId, a2aMentions, a2aCount: worklistEntry.a2aCount, maxDepth }, - 'A2A text-scan blocked: depth limit reached', - ); - } else if (catSignal?.aborted) { - log.info({ threadId, catId, a2aMentions }, 'A2A text-scan blocked: signal aborted'); - } - } - - if ( - a2aMentions.length > 0 && - !hadError && - worklistEntry.a2aCount < maxDepth && - !catSignal?.aborted && - !queuedMessagesPending - ) { - // F212 cloud R3 P1: a failed partial turn must not launch downstream cats - // from incomplete content. The structured stop gate independently skips - // provider-error turns and does not revive this text-derived dispatch. - // F153: mention_dispatch span — tracks the causal link between mentioner and dispatched targets - let dispatchSpan: Span | undefined; - const pendingTail = worklist.slice(index + 1); - const pendingOriginalTargets = targetCats.slice(index + 1); - // F216 c1.3 + P1-2 (砚砚 review): route each mentioned cat through the pure - // resolveRoutingDecisions function (unifies the depth/pendingTail/streak/occupancy/fairness guards - // that used to be inline here + duplicated in the relay path). Resolve+apply ONE cat at a time - // so each target's decision observes the prior targets' mutations (a2aCount++ and streak - // update) — matching the original sequential semantics. A single batch resolve would freeze - // every target's streak peek against the pre-loop streakPair: e.g. "@gemini @codex" with a hot - // opus<->codex streak would wrongly block @codex even though processing @gemini first resets - // the pair. The decision layer PEEKS streak read-only; this execution layer does the real - // updateStreakOnPush mutation + worklist.push + span + yield (砚砚 OQ3: side effects stay here). - // callerActivity is loop-invariant (same for every target this turn) → hoist once. - const hadSubstantiveToolCall = collectedToolNames.some((n) => isSubstantiveTool(n)); - for (const nextCat of a2aMentions) { - const [decision] = resolveRoutingDecisions( - { type: 'inline_mention', cats: [nextCat], content: storedContent, callerCatId: catId }, - { - a2aCount: worklistEntry.a2aCount, - maxDepth, - aborted: Boolean(catSignal?.aborted), - queuedMessagesPending, - pendingTail, - pendingOriginalTargets, - hasActiveAgent: (c) => Boolean(hasQueuedOrActiveAgentForCat?.(threadId, c)), - peekStreak: (target) => - peekStreakOnPush(worklistEntry, catId, target, { - hadSubstantiveToolCall, - outputLength: storedContent.length, - }), - }, - ); - if (!decision) continue; // pending original target → replies to user, no decision emitted - if (decision.action === 'skip') { - if (decision.reason === 'dedup_active') { - log.info( - { threadId, catId: nextCat, fromCat: catId }, - 'A2A text-scan dedup: cat actively processing in InvocationQueue, skipping', - ); - } - continue; - } - if (decision.action === 'mark_replyto') { - // pendingTail hit (non-original target): bind reply metadata, don't push again. - worklistEntry.a2aFrom.set(nextCat, catId); - // F121: response-text path — set trigger message for auto-replyTo - if (storedMsgId) worklistEntry.a2aTriggerMessageId.set(nextCat, storedMsgId); - continue; - } - // enqueue_worklist | block_pingpong both reached the streak gate in the legacy code, so the - // real (mutating) updateStreakOnPush must run exactly once here for either — peek above was - // read-only prediction; this is the canonical mutation point (parity guaranteed by c1.1). - // F167 L1 + Phase D: callerActivity gates streak accumulation; streak>=4 inertia → block. - const streak = updateStreakOnPush(worklistEntry, catId, nextCat, { - hadSubstantiveToolCall, - outputLength: storedContent.length, - }); - if (decision.action === 'block_pingpong') { - log.info( - { threadId, catId: nextCat, fromCat: catId, count: streak.count }, - 'F167 L1: A2A ping-pong terminated (streak >= 4)', - ); - yield { - type: 'system_info' as AgentMessageType, - catId, - content: JSON.stringify({ - type: 'a2a_pingpong_terminated', - fromCatId: catId, - targetCatId: nextCat, - pairCount: streak.count, - }), - timestamp: Date.now(), - } as AgentMessage; - continue; - } - - // enqueue_worklist means the target looks free; defer_queue means Queue already has a - // responsibility for it. In both cases the tracker claim is the final admission fence: - // another live owner leaves this exact source in the durable queue, never in this worklist. - const claimed = await claimOrDeferA2ATarget( - nextCat, - catId, - storedContent, - storedMsgId, - noteAcceptedTurnCustodyHandoff, - ); - while (pendingRoutingPreflightNotices.length > 0) { - const notice = pendingRoutingPreflightNotices.shift(); - if (notice) yield notice; - } - if (!claimed) { - worklistEntry.a2aCount++; - continue; - } - // F153: lazily create mention_dispatch span on first actual push - if (!dispatchSpan) { - const mentionerSpan = catInvocationSpans.get(index); - if (mentionerSpan) { - const parentCtx = trace.setSpan(context.active(), mentionerSpan); - dispatchSpan = routeSerialTracer.startSpan( - 'cat_cafe.mention_dispatch', - { - attributes: { [AGENT_ID]: catId as string, 'dispatch.target_count': a2aMentions.length }, - }, - parentCtx, - ); - // F153 Phase I: counter for Step Summary aggregate; only AGENT_ID attribute (mentioner cat). - a2aDispatchCount.add(1, { [AGENT_ID]: catId as string }); - } - } - - worklist.push(nextCat); - worklistEntry.a2aCount++; - pendingTail.push(nextCat); // Keep dedup view in sync - worklistEntry.a2aFrom.set(nextCat, catId); - // F121: response-text path — set trigger message for auto-replyTo - if (storedMsgId) worklistEntry.a2aTriggerMessageId.set(nextCat, storedMsgId); - // F153: record mention parent span for dispatched target - if (dispatchSpan) mentionParentSpan.set(worklist.length - 1, dispatchSpan); - } - // F153: end or defer dispatch span based on child execution - if (dispatchSpan) { - let maxChildIdx = -1; - for (const [idx, s] of mentionParentSpan) { - if (s === dispatchSpan && idx > maxChildIdx) maxChildIdx = idx; - } - if (maxChildIdx > index) { - pendingDispatchSpans.push({ span: dispatchSpan, lastChildIndex: maxChildIdx }); - } else { - dispatchSpan.end(); - } - } - } else if (a2aMentions.length > 0 && catSignal?.aborted && deferA2AEnqueue) { - // #813 fix: When invocation is aborted (e.g., after context seal), defer @mentions - // to the queue instead of silently dropping them. This ensures handoff continuity - // even when the cat's invocation was interrupted after writing a line-start @mention. - // - // P2 gate: Do NOT recover for user-initiated cancellations (user_cancel / cancel_all). - // The user explicitly stopped the flow — enqueueing autoExecute A2A work afterward - // would contradict their intent and run work they tried to stop. - const abortReason = catSignal.reason; - const isUserInitiatedAbort = abortReason === 'user_cancel' || abortReason === 'cancel_all'; - if (isUserInitiatedAbort) { - log.info( - { threadId, catId, abortReason, mentionCount: a2aMentions.length }, - '#813: A2A abort-recovery suppressed — user-initiated cancellation', - ); - } else { - for (const nextCat of a2aMentions) { - if (worklistEntry.a2aCount >= maxDepth) { - log.info( - { threadId, catId: nextCat, fromCat: catId, a2aCount: worklistEntry.a2aCount, maxDepth }, - 'A2A abort-recovery blocked: depth limit reached', - ); - continue; - } - // P2: dedup — skip if target cat already has queued/active work - // (same guard the inline and fairness-gate paths apply via - // resolveRoutingDecisions → hasActiveAgent). Without this, a - // seal-recovery enqueue could duplicate an earlier same-turn handoff. - if (hasQueuedOrActiveAgentForCat?.(threadId, nextCat)) { - log.info( - { threadId, catId: nextCat, fromCat: catId }, - '#813: A2A abort-recovery skipped — target already queued/active', - ); - continue; - } - const routingDisposition = await preflightA2ATarget(nextCat); - while (pendingRoutingPreflightNotices.length > 0) { - const notice = pendingRoutingPreflightNotices.shift(); - if (notice) yield notice; - } - if (routingDisposition === 'rejected') continue; - deferA2AEnqueue({ - threadId, - userId, - ownerAuthProvenance, - content: storedContent, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [nextCat], - callerCatId: catId, - messageId: storedMsgId, - a2aTriggerMessageId: storedMsgId, - autoExecute: true, - priority: 'normal', - intent: 'execute', - }); - worklistEntry.a2aCount++; - log.info( - { threadId, catId: nextCat, fromCat: catId }, - '#813: A2A mention recovered after signal abort — deferred to queue', - ); - } - } - } else if (a2aMentions.length > 0 && queuedMessagesPending && deferA2AEnqueue && !catSignal?.aborted) { - // F216 c2: deferred enqueue via the unified resolveRoutingDecisions decision layer. - // Same guard chain as inline (depth/pendingTail/streak/occupancy) but ctx.queuedMessagesPending=true - // makes the LAST gate return defer_queue instead of enqueue_worklist. Resolve+apply ONE cat at a - // time (NOT batch) so each target's decision observes prior targets' a2aCount++ and streak - // mutations — same per-target ordering fix as the inline path (砚砚 P1-2: a batch resolve would - // freeze every peekStreak against the pre-loop streakPair and mis-block later targets). - // F185 Phase B: deferred enqueue preserves A2A handoff behind non-agent entries. - const pendingTailDeferred = worklist.slice(index + 1); - const pendingOriginalTargetsDeferred = targetCats.slice(index + 1); - const hadSubstantiveToolCallDeferred = collectedToolNames.some((n) => isSubstantiveTool(n)); - // F153 Phase I: lazy mention_dispatch span for deferred path. End span immediately because the - // child route runs through QueueProcessor in a separate loop; the captured trace context is - // propagated via entry.callerTraceContext so the dispatched route parents under this span. - let deferredDispatchCtx: CallerTraceContext | undefined; - for (const nextCat of a2aMentions) { - const [decision] = resolveRoutingDecisions( - { type: 'deferred', cats: [nextCat], content: storedContent, callerCatId: catId }, - { - a2aCount: worklistEntry.a2aCount, - maxDepth, - aborted: Boolean(catSignal?.aborted), - queuedMessagesPending: true, - pendingTail: pendingTailDeferred, - pendingOriginalTargets: pendingOriginalTargetsDeferred, - hasActiveAgent: (c) => Boolean(hasQueuedOrActiveAgentForCat?.(threadId, c)), - peekStreak: (target) => - peekStreakOnPush(worklistEntry, catId, target, { - hadSubstantiveToolCall: hadSubstantiveToolCallDeferred, - outputLength: storedContent.length, - }), - }, - ); - if (!decision) continue; // pending original target → replies to user, no decision - if (decision.action === 'skip') { - if (decision.reason === 'dedup_active') { - log.info( - { threadId, catId: nextCat, fromCat: catId }, - 'A2A text-scan dedup (deferred): cat actively processing, skipping', - ); - } - continue; - } - if (decision.action === 'mark_replyto') { - // pendingTail hit (non-original target): rebind reply metadata, don't enqueue again. - worklistEntry.a2aFrom.set(nextCat, catId); - if (storedMsgId) worklistEntry.a2aTriggerMessageId.set(nextCat, storedMsgId); - continue; - } - // defer_queue | block_pingpong both passed the peek gate, so the real (mutating) - // updateStreakOnPush runs exactly once here for either (parity with inline c1.3 + c1.1). - const streakDeferred = updateStreakOnPush(worklistEntry, catId, nextCat, { - hadSubstantiveToolCall: hadSubstantiveToolCallDeferred, - outputLength: storedContent.length, - }); - if (decision.action === 'block_pingpong') { - log.info( - { threadId, catId: nextCat, fromCat: catId, count: streakDeferred.count }, - 'F167 L1: A2A ping-pong terminated in deferred path (streak >= 4)', - ); - yield { - type: 'system_info' as AgentMessageType, - catId, - content: JSON.stringify({ - type: 'a2a_pingpong_terminated', - fromCatId: catId, - targetCatId: nextCat, - pairCount: streakDeferred.count, - }), - timestamp: Date.now(), - } as AgentMessage; - continue; - } - // decision.action === 'defer_queue' - const routingDisposition = await preflightA2ATarget(nextCat); - while (pendingRoutingPreflightNotices.length > 0) { - const notice = pendingRoutingPreflightNotices.shift(); - if (notice) yield notice; - } - if (routingDisposition === 'rejected') continue; - // F153 Phase I: create dispatch span on first real enqueue and capture its trace - // context for cross-route causality. - if (!deferredDispatchCtx) { - const mentionerSpan = catInvocationSpans.get(index); - if (mentionerSpan) { - const parentCtx = trace.setSpan(context.active(), mentionerSpan); - const dSpan = routeSerialTracer.startSpan( - 'cat_cafe.mention_dispatch', - { - attributes: { - [AGENT_ID]: catId as string, - 'dispatch.target_count': a2aMentions.length, - 'dispatch.source': 'text-scan-deferred', - }, - }, - parentCtx, - ); - a2aDispatchCount.add(1, { [AGENT_ID]: catId as string }); - const sc = dSpan.spanContext(); - dSpan.end(); - deferredDispatchCtx = { - traceId: sc.traceId, - spanId: sc.spanId, - traceFlags: sc.traceFlags, - }; - } - } - const enqueueResult = deferA2AEnqueue({ - threadId, - userId, - ownerAuthProvenance, - content: storedContent, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [nextCat], - callerCatId: catId, - messageId: storedMsgId, - a2aTriggerMessageId: storedMsgId, - autoExecute: true, - priority: 'normal', - intent: 'execute', - ...(deferredDispatchCtx ? { callerTraceContext: deferredDispatchCtx } : {}), - }); - if (enqueueResult?.outcome === 'enqueued' && storedMsgId) { - noteAcceptedTurnCustodyHandoff(nextCat, storedMsgId); - } - worklistEntry.a2aCount++; - log.info( - { threadId, catId: nextCat, fromCat: catId }, - 'A2A text-scan deferred: enqueued behind non-agent entries (F185-B)', - ); - } - } - - // F27: Emit a2a_handoff for ALL new A2A targets (both response-text and callback-pushed). - // We track which targets have already been announced to avoid duplicate handoff events. - const serialLegs: Array<{ catId: CatId; config?: CatConfig }> = []; - // ── INV-1 HOLDER: SEALED ADMITTED BATCH ──────────────────────────────────────────── - // Announce only from a frozen batch whose admission is already closed. - // - // Two defects live at this exact spot, and they pull in opposite directions: - // (a) deriving index/total while claims are still outstanding announces a group size that - // counts legs which may still be pruned → 砚砚 R5: "串行 1/2" with one real leg; - // (b) splitting claim and emit into two passes over the MUTABLE worklist opens a - // reentrancy window: `yield` suspends this generator, a callback `pushToWorklist` - // lands, and the emit pass announces AND starts a cat that never claimed a slot - // → 砚砚 R6, a custody violation I introduced while fixing (a). - // - // Sealing resolves both: claim/prune the pending tail, freeze a copy, close the batch - // BEFORE the first yield, then emit only from the frozen copy. A push arriving mid-emit - // cannot join this batch's size and cannot skip admission — it simply becomes the next - // batch, which the drain loop claims on its following pass. Late arrivals are therefore - // late, not unadmitted. - let sealGuard = maxDepth + targetCats.length + 2; - while (handoffEmitted < worklist.length && sealGuard-- > 0) { - for (let wi = handoffEmitted; wi < worklist.length; wi++) { - if (wi < targetCats.length) continue; - const claimed = await claimOrDeferA2ATarget( - worklist[wi]!, - catId, - storedContent, - storedMsgId, - noteAcceptedTurnCustodyHandoff, - ); - while (pendingRoutingPreflightNotices.length > 0) { - const notice = pendingRoutingPreflightNotices.shift(); - if (notice) yield notice; - } - if (!claimed) { - worklist.splice(wi, 1); - wi--; - } - } - if (handoffEmitted >= worklist.length) break; - const batchStart = handoffEmitted; - const sealedBatch: readonly CatId[] = Object.freeze(worklist.slice(batchStart)); - handoffEmitted = worklist.length; // close the batch BEFORE any yield can suspend us - for (const [legIndex, pendingCat] of sealedBatch.entries()) { - if (batchStart + legIndex < targetCats.length) continue; // originals are not A2A legs - - // === A2A_HANDOFF 审计 (fire-and-forget, 缅因猫 review P2-3) === - const auditLog = getEventAuditLog(); - auditLog - .append({ - type: AuditEventTypes.A2A_HANDOFF, - threadId, - data: { - fromCat: catId, - toCat: pendingCat, - userId, - a2aDepth: worklistEntry.a2aCount, - maxDepth, - }, - }) - .catch((err) => { - log.warn({ threadId, fromCat: catId, toCat: pendingCat, err }, 'A2A_HANDOFF audit write failed'); - }); - - // F233 P1 (云端 review): ball.handed 已移到 worklist 主循环接球时刻统一 emit(覆盖 original + - // A2A),此处不再 emit——这里只是 A2A handoff 发射点(球离开前手),A2A target 真正接球在主循环。 - const nextConfig: CatConfig | undefined = catRegistry.tryGet(pendingCat as string)?.config; - // F086/F216: the scheduling mode is DECLARED, never inferred from how many targets - // appeared or in what order. Inline line-start @mentions are one ordered worklist. - const projection: A2ARoutingProjection = { - mode: A2A_INLINE_MENTION_MODE, - index: legIndex + 1, - total: sealedBatch.length, - }; - serialLegs.push({ catId: pendingCat, ...(nextConfig ? { config: nextConfig } : {}) }); - yield { - type: 'a2a_handoff' as AgentMessageType, - catId, - content: formatA2AHandoffContent(catId, pendingCat, catConfig, nextConfig, projection), - invocationId: ownInvocationId, - targetCatId: pendingCat, - routing: projection, - timestamp: Date.now(), - } as AgentMessage; - } - } - // F086/F216 requirement 4: multi-target inline @ is normalized to serial (the semantics - // the runtime already executes) and SAID OUT LOUD. No NLP over the body, no silent - // downgrade, and the structured parallel escape hatch is named explicitly. - if (serialLegs.length > 1) { - const noticePayload = buildSerialMultiTargetNoticePayload(catId, serialLegs); - yield { - type: 'system_info' as AgentMessageType, - catId, - content: noticePayload, - invocationId: ownInvocationId, - timestamp: Date.now(), - } as AgentMessage; - // Persist directly rather than via userFacingSystemInfoContents: the tool-only emit site - // below runs AFTER that array is flushed, so routing the notice through it would silently - // drop half the cases. Same writer, ordering-independent. - await persistUserFacingSystemInfoNotices({ - messageStore: deps.messageStore, - threadId, - catId: catId as string, - contents: [noticePayload], - ...(options.persistenceContext ? { persistenceContext: options.persistenceContext } : {}), - }); - } } else if (!hadError) { // No text content and no error. // Persist only when we have non-text payload (tool/thinking/rich). @@ -4927,7 +3969,7 @@ export async function* routeSerial( catProducedOutput = true; } - if (shouldPersistNoTextMessage || callbackAlreadyStored) { + if (shouldPersistNoTextMessage || callbackAlreadyStored || lifecycleResponseMessageId) { try { const visibleTurnInvocationId = visibleContentInvocationIdOverride ?? ownInvocationId; let storedNoText = null; @@ -4964,11 +4006,12 @@ export async function* routeSerial( !hadError && !doneMsg?.errorCode && !catSignal?.aborted && + actionOutputCommitAllowed && visibleTurnInvocationId === ownInvocationId, }); const noTextMessageInput: AppendMessageInput = { + from: { kind: 'agent', catId }, userId, - catId, content: '', mentions: [], origin: 'stream', @@ -4998,6 +4041,34 @@ export async function* routeSerial( ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}), }, }; + const abortReason = catSignal?.reason; + const lifecycleTerminalStatus: 'completed' | 'failed' | 'canceled' | 'interrupted' = catSignal?.aborted + ? abortReason === 'user_cancel' || abortReason === 'cancel_all' + ? 'canceled' + : 'interrupted' + : hadProviderError + ? 'failed' + : 'completed'; + const lifecycleTerminalReason = + lifecycleTerminalStatus === 'completed' + ? undefined + : typeof doneMsg?.errorCode === 'string' && doneMsg.errorCode.length > 0 + ? doneMsg.errorCode + : typeof abortReason === 'string' && abortReason.length > 0 + ? abortReason + : lifecycleTerminalStatus === 'failed' + ? 'provider_error' + : lifecycleTerminalStatus; + const lifecycleResponse = + lifecycleResponseMessageId && lifecyclePriorFrontierMessageId !== undefined && ownInvocationId + ? { + messageId: lifecycleResponseMessageId, + priorFrontierMessageId: lifecyclePriorFrontierMessageId, + status: lifecycleTerminalStatus, + completedAt: Math.max(Date.now(), invocationStartedAt), + ...(lifecycleTerminalReason ? { reason: lifecycleTerminalReason } : {}), + } + : undefined; const answerBearingNoText = hasRichBlocks || Boolean(renderThinkingChunks(thinkingChunks).trim().length > 0); const replayUnsafeToolNames = findReplayUnsafeToolNames(collectedToolNames); @@ -5018,6 +4089,7 @@ export async function* routeSerial( freshnessClosureId: options.freshnessClosureId, freshnessSupplementId: options.freshnessSupplementId, message: noTextMessageInput, + ...(lifecycleResponse ? { lifecycleResponse } : {}), replayUnsafeToolNames, evaluateFreshness: evaluateCurrentStreamFreshness, }); @@ -5038,6 +4110,14 @@ export async function* routeSerial( await enqueueFreshnessSupplement(decision, catId as string); } } + } else if (lifecycleResponse && ownInvocationId) { + storedNoText = await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponse.messageId, + ownInvocationId, + lifecycleResponse, + noTextMessageInput, + ); } else { // Reviewed read-only tool-only records are audit output, not answer content. // Unknown or mutating tools still enter the freshness gate above so a stale @@ -5093,7 +4173,7 @@ export async function* routeSerial( } if (!shouldPersistNoTextMessage && !callbackAlreadyStored) { - if (!sawUserFacingSystemInfo && !isFreshnessClosureSuccessor) { + if (!catSignal?.aborted && !sawUserFacingSystemInfo && !isFreshnessClosureSuccessor) { yield { type: 'system_info' as AgentMessageType, catId, @@ -5114,7 +4194,7 @@ export async function* routeSerial( deps.draftStore.delete(userId, threadId, ownInvocationId)?.catch?.(noop); } } - } else if (collectedToolEvents.length > 0) { + } else if (collectedToolEvents.length > 0 || lifecycleResponseMessageId) { // hadError && textContent === '' but toolEvents exist — persist tool record so // refreshing the page still shows what the cat attempted before the error. try { @@ -5134,17 +4214,17 @@ export async function* routeSerial( ); } else { const executionProjections = await readTurnExecutionProjections(visibleTurnInvocationId); - const storedToolError = await deps.messageStore.append({ + const errorMessageInput: AppendMessageInput = { + from: { kind: 'agent', catId }, userId, - catId, - content: '', + content: terminalFailureContent ?? '', mentions: [], origin: 'stream', timestamp: invocationStartedAt, threadId, ...(streamReplyTo ? { replyTo: streamReplyTo } : {}), ...(persistedMetadata ? { metadata: persistedMetadata } : {}), - toolEvents: collectedToolEvents, + ...(collectedToolEvents.length > 0 ? { toolEvents: collectedToolEvents } : {}), ...((options.parentInvocationId ?? visibleTurnInvocationId) || doneMsg?.tracing ? { extra: { @@ -5166,9 +4246,52 @@ export async function* routeSerial( }, } : {}), - }); - turnStoredMessageId = storedToolError.id; - recordPersistedOutputMessageId(storedToolError.id); + }; + let storedErrorTools; + if (lifecycleResponseMessageId && ownInvocationId) { + const terminal = { + status: catSignal?.aborted ? ('interrupted' as const) : ('failed' as const), + completedAt: Math.max(Date.now(), invocationStartedAt), + reason: + typeof doneMsg?.errorCode === 'string' && doneMsg.errorCode.length > 0 + ? doneMsg.errorCode + : 'provider_error', + }; + if ( + terminal.status === 'failed' && + a2aTriggerMessageId && + options.a2aCallerCatId && + !options.a2aFailureReport + ) { + if (!commitFailedA2AReport) { + throw new Error('failed response A2A report admission unavailable'); + } + storedErrorTools = await commitFailedA2AReport({ + responseMessageId: lifecycleResponseMessageId, + invocationId: ownInvocationId, + terminal: { ...terminal, status: 'failed' }, + message: errorMessageInput, + userId, + ownerAuthProvenance, + threadId, + reporterCatId: catId, + predecessorCatId: options.a2aCallerCatId as CatId, + ...(options.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}), + }); + } else { + storedErrorTools = await commitLifecycleResponseFromAppendInput( + deps.messageStore, + lifecycleResponseMessageId, + ownInvocationId, + terminal, + errorMessageInput, + ); + } + } else { + storedErrorTools = await deps.messageStore.append(errorMessageInput); + } + turnStoredMessageId = storedErrorTools.id; + recordPersistedOutputMessageId(storedErrorTools.id); } // #80: Clean up draft only after successful append if (deps.draftStore && ownInvocationId) { @@ -5221,117 +4344,22 @@ export async function* routeSerial( contents: userFacingSystemInfoContents, ...(turnTriggerMessageId ? { expectedSourceMessageId: turnTriggerMessageId } : {}), ...(ownInvocationId ? { expectedDispatchInvocationId: ownInvocationId } : {}), + ...(lifecycleResponseMessageId && turnStoredMessageId === lifecycleResponseMessageId && terminalFailureContent + ? { terminalFailureText: terminalFailureContent } + : {}), ...(options.persistenceContext ? { persistenceContext: options.persistenceContext } : {}), }); - a2aMentions = getLocalRoutingLineStartMentions(a2aMentions); - - // F27: Emit a2a_handoff for ALL new A2A targets (both response-text and callback-pushed). - // Keep this outside the text branch: callback/tool-only turns can push worklist entries - // without producing text, but their child slots still must be tracked before parent done. - // We track which targets have already been announced to avoid duplicate handoff events. - const toolOnlySerialLegs: Array<{ catId: CatId; config?: CatConfig }> = []; - // INV-1 HOLDER (serial side, tool-only turn) — SAME sealed-batch drain as the text path. - // 砚砚 R6 required both loops to hold the line: a callback push can feed either one, so - // leaving one loop iterating the mutable worklist would just relocate the reentrancy window - // rather than close it. - let toolOnlySealGuard = maxDepth + targetCats.length + 2; - while (handoffEmitted < worklist.length && toolOnlySealGuard-- > 0) { - for (let wi = handoffEmitted; wi < worklist.length; wi++) { - if (wi < targetCats.length) continue; - const claimed = await claimOrDeferA2ATarget( - worklist[wi]!, - catId, - undefined, - undefined, - noteAcceptedTurnCustodyHandoff, - ); - while (pendingRoutingPreflightNotices.length > 0) { - const notice = pendingRoutingPreflightNotices.shift(); - if (notice) yield notice; - } - if (!claimed) { - worklist.splice(wi, 1); - wi--; - } - } - if (handoffEmitted >= worklist.length) break; - const batchStart = handoffEmitted; - const sealedBatch: readonly CatId[] = Object.freeze(worklist.slice(batchStart)); - handoffEmitted = worklist.length; // close the batch BEFORE any yield can suspend us - for (const [legIndex, pendingCat] of sealedBatch.entries()) { - if (batchStart + legIndex < targetCats.length) continue; // originals are not A2A legs - - // === A2A_HANDOFF 审计 (fire-and-forget, 缅因猫 review P2-3) === - const auditLog = getEventAuditLog(); - auditLog - .append({ - type: AuditEventTypes.A2A_HANDOFF, - threadId, - data: { - fromCat: catId, - toCat: pendingCat, - userId, - a2aDepth: worklistEntry.a2aCount, - maxDepth, - }, - }) - .catch((err) => { - log.warn({ threadId, fromCat: catId, toCat: pendingCat, err }, 'A2A_HANDOFF audit write failed'); - }); - - // F233 P1 (云端 review): ball.handed 已移到 worklist 主循环接球时刻统一 emit(覆盖 original + - // A2A),此处不再 emit——这里只是 A2A handoff 发射点(球离开前手),A2A target 真正接球在主循环。 - const nextConfig: CatConfig | undefined = catRegistry.tryGet(pendingCat as string)?.config; - // F086/F216: same declared-serial contract as the text path above. - const projection: A2ARoutingProjection = { - mode: A2A_INLINE_MENTION_MODE, - index: legIndex + 1, - total: sealedBatch.length, - }; - toolOnlySerialLegs.push({ catId: pendingCat, ...(nextConfig ? { config: nextConfig } : {}) }); - yield { - type: 'a2a_handoff' as AgentMessageType, - catId, - content: formatA2AHandoffContent(catId, pendingCat, catConfig, nextConfig, projection), - invocationId: ownInvocationId, - targetCatId: pendingCat, - routing: projection, - timestamp: Date.now(), - } as AgentMessage; - } - } - // FINAL ADMISSION BOUNDARY: no callback from this caller may extend the worklist after - // this point. Close before the notice yield and every later await, not merely before done; - // otherwise a callback can land after the last claim/prune pass and start unadmitted on - // the next loop iteration while executedIndex still names the old caller. - setWorklistCallerAdmissionOpen(worklistEntry, false); - if (toolOnlySerialLegs.length > 1) { - const noticePayload = buildSerialMultiTargetNoticePayload(catId, toolOnlySerialLegs); - yield { - type: 'system_info' as AgentMessageType, - catId, - content: noticePayload, - invocationId: ownInvocationId, - timestamp: Date.now(), - } as AgentMessage; - await persistUserFacingSystemInfoNotices({ - messageStore: deps.messageStore, - threadId, - catId: catId as string, - contents: [noticePayload], - ...(options.persistenceContext ? { persistenceContext: options.persistenceContext } : {}), - }); - } - // Persist error as system message so it survives F5 reload. // During streaming, errors render as red badges via ephemeral frontend state. // Without persistence, they vanish on page refresh. - if (collectedErrorText) { + const lifecycleErrorOwnedByResponse = + lifecycleResponseMessageId !== undefined && turnStoredMessageId === lifecycleResponseMessageId; + if (collectedErrorText && !lifecycleErrorOwnedByResponse) { try { await deps.messageStore.append({ + from: { kind: 'system', service: 'agent-error' }, userId: 'system', - catId: null, content: `Error: ${collectedErrorText}`, mentions: [], origin: 'stream', @@ -5494,15 +4522,14 @@ export async function* routeSerial( const reinvokeContent = reinvokeDecision.reinvokePrompt || buildFreshnessReinvokePrompt(threadId, reinvokeDecision.senders, reinvokeDecision.noticeIds.length); - freshnessReinvokeEnqueue({ + await freshnessReinvokeEnqueue({ threadId, userId, ownerAuthProvenance, content: reinvokeContent, - source: 'agent', + from: { kind: 'agent', catId: catId as string }, sourceCategory: 'freshness', targetCats: [catId as string], - callerCatId: catId as string, autoExecute: true, priority: 'normal', intent: 'execute', @@ -5544,13 +4571,12 @@ export async function* routeSerial( // Yield buffered done with correct isFinal (evaluated AFTER worklist may have grown) // MUST always reach here regardless of append success (缅因猫 review P1-2) // F194 Phase Z9 砚砚 R1 P1-1: stamp ownInvocationId on done if not already set. - emitSingleAcceptedTurnCustodyHandoff(); const isTerminalCoordinationWake = turnCustodyWake.kind === 'non_obligation' && turnCustodyWake.source === 'coordination_terminal'; if (index === worklist.length - 1 || isTerminalCoordinationWake) { await flushTurnCustodyShadowCloses(index === worklist.length - 1 ? 'route_settled' : 'next_turn_boundary'); } - await releaseTurnCustodyAdoption(); + if (doneMsg) { const isFinal = index === worklist.length - 1; const ownStampedDone = @@ -5562,10 +4588,8 @@ export async function* routeSerial( ...(mentionsUser ? { mentionsUser } : {}), ...(turnCustodyTerminalWitnesses[0] ? { turnCustodyTerminalWitness: turnCustodyTerminalWitnesses[0] } : {}), ...(turnCustodyTerminalWitnesses.length > 0 ? { turnCustodyTerminalWitnesses } : {}), - ...(structuredDispositionMissingCode ? { errorCode: structuredDispositionMissingCode } : {}), isFinal, }); - activeTrackedA2ASlots.delete(catId); if (isFinal) yieldedFinalDone = true; if (ownInvocationId) { completedCatInvocationIds.push([catId, ownInvocationId]); @@ -5573,14 +4597,11 @@ export async function* routeSerial( } } - // F27: Advance executedIndex so pushToWorklist knows which cats are done - worklistEntry.executedIndex = index + 1; index++; } } finally { // Provider/route failure after system_info must revoke callback ownership // before any adopted projections are closed or the route exits. - await releaseTurnCustodyAdoption(); if (keepaliveTimer) clearInterval(keepaliveTimer); // Phase T stop gate is a turn-settled verdict: current output persistence, @@ -5592,17 +4613,7 @@ export async function* routeSerial( if (options.routeSpan) { options.routeSpan.setAttribute(ROUTE_TOTAL_CATS_INVOKED, index); options.routeSpan.setAttribute(ROUTE_TOTAL_TOKENS, routeTotalTokens); - options.routeSpan.setAttribute(ROUTE_HAS_A2A_HANDOFF, worklist.length > targetCats.length); } - // F153: End all pending dispatch spans (unconditional — covers abort/throw) - for (const entry of pendingDispatchSpans) { - entry.span.end(); - } - - if (options.invocationController && options.completeA2ASlots && activeTrackedA2ASlots.size > 0) { - options.completeA2ASlots(threadId, [...activeTrackedA2ASlots], options.invocationController); - } - // F200 AC-A1: fire-and-forget recall correlation after all cats complete. // TD 2026-08-12: bounded tail read — full-thread reads of large keys // (observed 221MB) froze the API event loop every round-end. diff --git a/packages/api/src/domains/cats/services/agents/routing/routing-decision.ts b/packages/api/src/domains/cats/services/agents/routing/routing-decision.ts deleted file mode 100644 index 136c18a1a5..0000000000 --- a/packages/api/src/domains/cats/services/agents/routing/routing-decision.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * F216 c1.2: routeSerial 决策层 — 纯函数统一路由 guard。 - * - * routeSerial 历史上把"该不该派下一只猫"的 depth/dedup/streak/pendingTail 判断散在 inline-mention - * (route-serial.ts:1703-1797) 和 relay (route-serial.ts:1126-1150) 两段里各写一遍,加任何决策都要 - * 多处同步改 → 笛卡尔积式炸 edge case(F215 7 轮 review 的根因)。 - * - * 本模块把这套判断收口成一个**纯函数** resolveRoutingDecisions:输入一个 RoutingSignal + 一份只读 - * RoutingContext 快照,输出结构化 RoutingDecision[]。**不做任何副作用** —— worklist.push / - * updateStreakOnPush(mutate) / span 创建 / yield 都留在 routeSerial 执行层 apply decision 时做 - * (砚砚 GPT-5.5 OQ3:副作用不进决策函数,否则纯函数变上帝函数)。streak 用 ctx.peekStreak 只读预判 - * (配对 WorklistRegistry.peekStreakOnPush,c1.1)。 - */ - -import type { CatId } from '@cat-cafe/shared'; - -/** 路由信号来源 —— 三条历史路径各自的触发形态。 */ -export type RoutingSignal = - | { type: 'inline_mention'; cats: CatId[]; content: string; callerCatId: CatId } - | { type: 'relay_malformed'; cat: CatId; callerCatId: CatId } - | { type: 'deferred'; cats: CatId[]; content: string; callerCatId: CatId }; - -/** 结构化决策 —— 执行层据此 apply 副作用。 */ -export type RoutingDecision = - | { action: 'enqueue_worklist'; cat: CatId } // 执行层:worklist.push + updateStreakOnPush + span - | { action: 'defer_queue'; cat: CatId } // 执行层:deferA2AEnqueue(排到非-agent 之后) - | { action: 'mark_replyto'; cat: CatId } // pendingTail 命中且非原始 target:只设 a2aFrom/triggerMsg,不 push - | { action: 'skip'; cat: CatId; reason: 'depth' | 'dedup_active' | 'aborted' | 'queue_pending' } - | { action: 'block_pingpong'; cat: CatId; pairCount: number }; // 执行层:yield a2a_pingpong_terminated - -/** 决策所需的只读上下文快照(不 mutate)。 */ -export interface RoutingContext { - /** 当前 A2A 深度(已 push 的 A2A 目标数)。 */ - a2aCount: number; - /** A2A 链最大深度。 */ - maxDepth: number; - /** 本 cat 的执行 signal 是否已 abort。 */ - aborted: boolean; - /** 队列里是否有非-agent(user/connector)消息待处理 —— fairness gate。 */ - queuedMessagesPending: boolean; - /** worklist 中尚未执行的尾部(index+1..),用于 dedup。 */ - pendingTail: readonly CatId[]; - /** pendingTail 中属于用户原始选择的 target(这些应回复用户,不应被当成 A2A 重派)。 */ - pendingOriginalTargets: readonly CatId[]; - /** 该 cat 是否已有 InvocationQueue 责任;新意图必须 durable defer,不能被当成 dedup 丢弃。 */ - hasActiveAgent: (cat: CatId) => boolean; - /** 只读 streak 预判(不 mutate;配对 peekStreakOnPush)。 */ - peekStreak: (target: CatId) => { wouldBlock: boolean; count: number }; -} - -/** - * 纯函数:把一个路由信号解析成有序的决策列表。 - * - * inline_mention:逐个 cat 走完整 guard 链(abort→depth→pendingTail→streak→occupancy/fairness→route), - * depth 预算在多 cat 间累计消费(每次 enqueue 占一个 slot)。 - * relay_malformed:恢复路径,只受 depth + pending-only dedup 约束(F215 逐字语义),不碰 streak/fairness。 - * deferred:等同 inline_mention 在 queue-pending 时的形态(全部 defer_queue),c2 接线时复用。 - */ -export function resolveRoutingDecisions(signal: RoutingSignal, ctx: RoutingContext): RoutingDecision[] { - if (signal.type === 'relay_malformed') { - return [resolveRelay(signal.cat, ctx)]; - } - - const decisions: RoutingDecision[] = []; - // Local depth budget: each route slot consumes one, so later cats can hit the limit mid-list. - // Both enqueue_worklist (inline) AND defer_queue (queue-pending path, c2) are real A2A route slots — - // a defer_queue enqueues a handoff behind non-agent messages, it just runs later. Counting only - // enqueue_worklist would let a batch resolve emit unlimited defer_queue past maxDepth - // (砚砚 review PR#1991 P2). skip/mark_replyto/block_pingpong do NOT consume a slot (no new route). - let depth = ctx.a2aCount; - for (const cat of signal.cats) { - const decision = resolveInlineCat(cat, ctx, depth); - if (decision === null) continue; // pending original target → no decision (replies to user) - decisions.push(decision); - if (decision.action === 'enqueue_worklist' || decision.action === 'defer_queue') depth++; - } - return decisions; -} - -/** relay 恢复路径:depth + pending-only dedup(F215 :1131-1134 逐字语义),无 streak/fairness。 */ -function resolveRelay(cat: CatId, ctx: RoutingContext): RoutingDecision { - if (ctx.a2aCount >= ctx.maxDepth) return { action: 'skip', cat, reason: 'depth' }; - // F215 pending-only dedup: cat already queued ahead → skip duplicate relay push. - if (ctx.pendingTail.includes(cat)) return { action: 'skip', cat, reason: 'dedup_active' }; - return { action: 'enqueue_worklist', cat }; -} - -/** - * inline-mention 单 cat guard 链(顺序与 routeSerial:1703-1785 一致)。 - * 返回 null = pending 原始 target,不发决策(保持回复用户)。 - */ -function resolveInlineCat(cat: CatId, ctx: RoutingContext, depth: number): RoutingDecision | null { - // Outer gate (routeSerial :1703): aborted takes priority over everything. - if (ctx.aborted) return { action: 'skip', cat, reason: 'aborted' }; - // Depth (consumed cumulatively across the cat list). - if (depth >= ctx.maxDepth) return { action: 'skip', cat, reason: 'depth' }; - // Already pending in worklist tail. - if (ctx.pendingTail.includes(cat)) { - // Original user target → leave it replying to user (no decision). - if (ctx.pendingOriginalTargets.includes(cat)) return null; - // Non-original duplicate → just (re)bind reply metadata, don't push again. - return { action: 'mark_replyto', cat }; - } - // Ping-pong breaker (read-only预判; execution layer does the real updateStreakOnPush mutate). - const streak = ctx.peekStreak(cat); - if (streak.wouldBlock) return { action: 'block_pingpong', cat, pairCount: streak.count }; - // Queue occupancy is not proof that this source intent is a duplicate. The - // execution layer must give the new handoff durable Queue custody and leave - // the current owner running; silently skipping here caused clowder-ai#1335. - if (ctx.hasActiveAgent(cat)) return { action: 'defer_queue', cat }; - // F216 c2: queue fairness gate is the LAST check, AFTER depth/pendingTail/streak/occupancy. This way the - // deferred path (queuedMessagesPending=true) still runs the full guard chain before deferring — it - // gets skip:depth / mark_replyto / block_pingpong exactly like inline, then - // defers a clean enqueue. For the inline path queuedMessagesPending is always false (outer condition - // `!queuedMessagesPending`), so this check is a no-op there → inline behavior unchanged. - if (ctx.queuedMessagesPending) return { action: 'defer_queue', cat }; - return { action: 'enqueue_worklist', cat }; -} diff --git a/packages/api/src/domains/cats/services/agents/routing/routing-preflight-notice.ts b/packages/api/src/domains/cats/services/agents/routing/routing-preflight-notice.ts index a3f694bff7..7b4bb202d8 100644 --- a/packages/api/src/domains/cats/services/agents/routing/routing-preflight-notice.ts +++ b/packages/api/src/domains/cats/services/agents/routing/routing-preflight-notice.ts @@ -31,7 +31,7 @@ export async function routingPreflightNotice( try { const stored = await deps.messageStore.append({ userId: 'system', - catId: null, + from: { kind: 'system', service: 'routing-preflight' }, threadId, timestamp, content, diff --git a/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts b/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts index cfd142687d..b2a5c7d367 100644 --- a/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts +++ b/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts @@ -105,7 +105,7 @@ export interface VoidHoldInput { } export interface VoidHoldEvaluation { - /** True iff the void-hold-hint should fire (caller emits the connector + counter). */ + /** True iff the internal void-hold correction signal and counter should fire. */ readonly shouldEmit: boolean; /** * Matched HOLD_PATTERN id when text claims hold — preserved even when diff --git a/packages/api/src/domains/cats/services/cloud-bridge/cloud-assistant-return-ingest.ts b/packages/api/src/domains/cats/services/cloud-bridge/cloud-assistant-return-ingest.ts index f79c7e000f..86fbdcbbc1 100644 --- a/packages/api/src/domains/cats/services/cloud-bridge/cloud-assistant-return-ingest.ts +++ b/packages/api/src/domains/cats/services/cloud-bridge/cloud-assistant-return-ingest.ts @@ -116,10 +116,10 @@ export class CloudAssistantReturnIngestService { : { status: 'rejected', reason: 'grant_not_found' }; } try { - const append = await this.deps.messageStore.appendIdempotent({ + const message = await this.deps.messageStore.append({ + from: { kind: 'agent', catId: TARGET_CAT_ID }, threadId: source.threadId, userId: source.userId, - catId: TARGET_CAT_ID, content: input.content, mentions: [], origin: 'callback', @@ -128,9 +128,9 @@ export class CloudAssistantReturnIngestService { replyTo: source.id, idempotencyKey, }); - await this.commitAfterPersistence(claimed, append.message); - await this.broadcast(append.message); - return { status: append.idempotent ? 'duplicate' : 'persisted', messageId: append.message.id }; + await this.commitAfterPersistence(claimed, message); + await this.broadcast(message); + return { status: 'persisted', messageId: message.id }; } catch (error) { await this.deps.grantStore.release(claimed).catch(() => false); this.deps.logger.warn( diff --git a/packages/api/src/domains/cats/services/context/ContextAssembler.ts b/packages/api/src/domains/cats/services/context/ContextAssembler.ts index bffaee0223..478d011489 100644 --- a/packages/api/src/domains/cats/services/context/ContextAssembler.ts +++ b/packages/api/src/domains/cats/services/context/ContextAssembler.ts @@ -14,7 +14,9 @@ import { } from '@cat-cafe/shared'; import { estimateTokens } from '../../../../utils/token-counter.js'; import { formatPromptTime } from '../format-time.js'; +import { messageFrom } from '../stores/message-from.js'; import { isDelivered, type StoredMessage } from '../stores/ports/MessageStore.js'; +import { isAgentReadableManagedHoldMessage } from '../stores/visibility.js'; export interface ContextAssemblerOptions { executionGrant?: CollectiveExecutionGrant; @@ -50,8 +52,8 @@ export function buildMessageMap(messages: readonly StoredMessage[]): ReadonlyMap } /** - * Get display name for a message sender. - * catId === null → user ("co-creator"), otherwise look up catRegistry. + * Get the display name for an agent projection. Message identity itself comes + * from MessageFrom; this helper only resolves an already-selected cat id. * For variant cats (e.g. sonnet, opus-45), includes variantLabel to distinguish same-family members. */ export function getSenderName(catId: string | null): string { @@ -100,6 +102,23 @@ export function getSourceDisplayName(source: { label: string; sender?: { id: str return safeLabel; } +function getMessageSenderName(msg: StoredMessage): string { + if (msg.source) return getSourceDisplayName(msg.source); + const from = messageFrom(msg); + switch (from.kind) { + case 'user': + return 'co-creator'; + case 'agent': + return getSenderName(from.catId); + case 'external': + return sanitizeDisplaySegment(from.sender?.name ?? from.sender?.id ?? from.connectorId); + case 'plugin': + return sanitizeDisplaySegment(from.instanceId); + case 'system': + return sanitizeDisplaySegment(from.service); + } +} + /** * Truncate content preserving both head and tail. * Head gets 40% of budget, tail gets 60% (conclusions/requests live at the end). @@ -137,7 +156,7 @@ export function formatMessage( // export route) pass their own formatter to avoid leaking UTC into documents // whose header/footer use host-local time. const time = (options?.formatTime ?? formatPromptTime)(msg.timestamp); - const sender = msg.source ? getSourceDisplayName(msg.source) : getSenderName(msg.catId); + const sender = getMessageSenderName(msg); // F52: Annotate cross-thread messages with source thread const sourceThreadId = msg.extra?.crossPost?.sourceThreadId; const crossPostTag = isCrossThreadProvenance(sourceThreadId, msg.threadId) @@ -150,7 +169,7 @@ export function formatMessage( if (msg.replyTo && options?.messageMap) { const parent = options.messageMap.get(msg.replyTo); if (parent) { - const parentSender = parent.source ? getSourceDisplayName(parent.source) : getSenderName(parent.catId); + const parentSender = getMessageSenderName(parent); const sanitized = options?.sanitizeContent ? options.sanitizeContent(parent.content) : parent.content; const raw = sanitized.replaceAll('\n', ' '); const preview = raw.length > REPLY_PREVIEW_LENGTH ? `${raw.slice(0, REPLY_PREVIEW_LENGTH)}…` : raw; @@ -179,17 +198,19 @@ export function assembleContext(messages: StoredMessage[], options?: ContextAsse // isEligibleReplyParent and incremental context paths which already exclude them). // Defense: also exclude legacy error messages that were incorrectly persisted with // userId=user by route-parallel.ts (context poisoning bug, fixed in PR #992). - // Only filter cat messages (catId !== null) starting with [错误] — user messages are legit. + // Only filter agent messages starting with [错误] — user messages are legit. // All 6 known contaminated records start with [错误] (no partial-text-before-error exists // in practice, since stream_idle_stall means zero text was produced before the error). - const deliveredMessages = messages.filter( - (m) => + const deliveredMessages = messages.filter((m) => { + const from = messageFrom(m); + return ( (!options?.executionGrant || collectiveMessageInScope(m, options.executionGrant)) && isDelivered(m) && - m.userId !== 'system' && + (from.kind !== 'system' || isAgentReadableManagedHoldMessage(m)) && m.origin !== 'briefing' && - !(m.catId && m.content?.startsWith('[错误]')), - ); + !(from.kind === 'agent' && m.content?.startsWith('[错误]')) + ); + }); if (deliveredMessages.length === 0) { return { contextText: '', messageCount: 0, estimatedTokens: 0 }; diff --git a/packages/api/src/domains/cats/services/context/MessageBundlePromptResolver.ts b/packages/api/src/domains/cats/services/context/MessageBundlePromptResolver.ts index d7311d16cf..81db5130de 100644 --- a/packages/api/src/domains/cats/services/context/MessageBundlePromptResolver.ts +++ b/packages/api/src/domains/cats/services/context/MessageBundlePromptResolver.ts @@ -39,7 +39,18 @@ interface ResolveMessageBundlePromptInput { } function formatAuthor(item: ResolvedMessageSelectionItem): string { - return item.author.kind === 'cat' ? `cat:@${item.author.catId}` : `user:${item.author.userId}`; + switch (item.from.kind) { + case 'agent': + return `cat:@${item.from.catId}`; + case 'user': + return `user:${item.from.userId}`; + case 'external': + return `external:${item.from.connectorId}:${item.from.sender?.id ?? 'unknown'}`; + case 'plugin': + return `plugin:${item.from.instanceId}`; + case 'system': + return `system:${item.from.service}`; + } } function formatExactRef(item: MessageBundleCarrierV1['items'][number]): string { diff --git a/packages/api/src/domains/cats/services/context/MessageBundleSourceGroup.ts b/packages/api/src/domains/cats/services/context/MessageBundleSourceGroup.ts index 4339a4f916..5e866b0511 100644 --- a/packages/api/src/domains/cats/services/context/MessageBundleSourceGroup.ts +++ b/packages/api/src/domains/cats/services/context/MessageBundleSourceGroup.ts @@ -1,5 +1,5 @@ +import { messageFrom } from '../stores/message-from.js'; import type { IMessageStore, StoredMessage } from '../stores/ports/MessageStore.js'; -import { isSystemUserMessage } from '../stores/visibility.js'; import { isAccessibleSourceRecord, isSourceGroupTerminal, sortSourceRecords } from './MessageBundleSourceProjection.js'; import type { MessageSelectionAuth } from './message-selection-types.js'; @@ -29,7 +29,7 @@ function compareRecords(left: StoredMessage, right: StoredMessage): number { } function isBrowserUserRecord(message: StoredMessage): boolean { - return message.catId === null && message.source === undefined && !isSystemUserMessage(message); + return messageFrom(message).kind === 'user'; } function bubbleInvocationId(message: StoredMessage): string | null { @@ -38,9 +38,10 @@ function bubbleInvocationId(message: StoredMessage): string | null { } function assistantBaseKey(message: StoredMessage): string | null { - if (message.catId === null || isSystemUserMessage(message)) return null; + const from = messageFrom(message); + if (from.kind !== 'agent') return null; const invocationId = bubbleInvocationId(message); - return invocationId ? `${message.catId}::${invocationId}` : null; + return invocationId ? `${from.catId}::${invocationId}` : null; } function buildTurnSegments(records: readonly StoredMessage[]): Map { diff --git a/packages/api/src/domains/cats/services/context/MessageBundleSourceProjection.ts b/packages/api/src/domains/cats/services/context/MessageBundleSourceProjection.ts index c24e3b428d..692fd50416 100644 --- a/packages/api/src/domains/cats/services/context/MessageBundleSourceProjection.ts +++ b/packages/api/src/domains/cats/services/context/MessageBundleSourceProjection.ts @@ -5,6 +5,7 @@ import { projectMarkdownReadableText, type RichBlock, } from '@cat-cafe/shared'; +import { messageFrom } from '../stores/message-from.js'; import type { StoredMessage, StoredToolEvent } from '../stores/ports/MessageStore.js'; import type { Thread } from '../stores/ports/ThreadStore.js'; import { @@ -213,14 +214,18 @@ export function readRichBlockFallback(block: RichBlock): string { } } -export function projectMessageBundleReadableContent( +export function projectMessageBundleReadableContentWithCoverage( message: Pick, -): string { +): { content: string; fullyRepresented: boolean } { const parts: string[] = []; + let fullyRepresented = true; if (message.content.trim()) parts.push(message.content); for (const block of message.contentBlocks ?? []) { const fallback = readContentBlockFallback(block); - if (!fallback?.trim()) continue; + if (!fallback?.trim()) { + fullyRepresented = false; + continue; + } if (block.type === 'text' && message.content.trim()) continue; parts.push(fallback); } @@ -228,7 +233,13 @@ export function projectMessageBundleReadableContent( const fallback = readRichBlockFallback(block); if (fallback?.trim()) parts.push(fallback); } - return parts.join('\n'); + return { content: parts.join('\n'), fullyRepresented }; +} + +export function projectMessageBundleReadableContent( + message: Pick, +): string { + return projectMessageBundleReadableContentWithCoverage(message).content; } /** @@ -273,12 +284,11 @@ export function isAccessibleSourceRecord( sourceThreadId: string, auth: MessageSelectionAuth, ): message is StoredMessage { + const from = message ? messageFrom(message) : null; const ownerAuthored = message?.userId === auth.userId && - message.userId !== 'system' && - message.userId !== 'scheduler' && - message.catId !== 'system' && - message.source === undefined && + from?.kind !== 'system' && + from?.kind !== 'external' && message.origin !== 'briefing'; const managedHoldConnector = message ? isOwnerVisibleManagedHoldConnector(message, auth.userId) : false; return Boolean( diff --git a/packages/api/src/domains/cats/services/context/MessageSelectionResolver.ts b/packages/api/src/domains/cats/services/context/MessageSelectionResolver.ts index 1f7fc72e16..67a43e81e6 100644 --- a/packages/api/src/domains/cats/services/context/MessageSelectionResolver.ts +++ b/packages/api/src/domains/cats/services/context/MessageSelectionResolver.ts @@ -81,7 +81,6 @@ export { export type { MessageSelectionAdmissionResult, MessageSelectionAuth, - MessageSelectionAuthor, MessageSelectionInvalidReason, MessageSelectionProjectedItem, MessageSelectionReadResult, diff --git a/packages/api/src/domains/cats/services/context/message-selection-results.ts b/packages/api/src/domains/cats/services/context/message-selection-results.ts index c731810486..63faefedd2 100644 --- a/packages/api/src/domains/cats/services/context/message-selection-results.ts +++ b/packages/api/src/domains/cats/services/context/message-selection-results.ts @@ -1,14 +1,14 @@ import type { RichBlock } from '@cat-cafe/shared'; +import { messageFrom } from '../stores/message-from.js'; import type { StoredMessage } from '../stores/ports/MessageStore.js'; import type { - MessageSelectionAuthor, MessageSelectionTombstone, MessageSelectionTombstoneReason, ResolvedMessageSelectionItem, } from './message-selection-types.js'; -function authorFor(message: StoredMessage): MessageSelectionAuthor { - return message.catId === null ? { kind: 'user', userId: message.userId } : { kind: 'cat', catId: message.catId }; +function fromFor(message: StoredMessage): NonNullable { + return structuredClone(messageFrom(message)); } export function tombstone(messageId: string, reason: MessageSelectionTombstoneReason): MessageSelectionTombstone { @@ -26,7 +26,7 @@ export function projectedItem( kind: item.kind, messageId: message.id, sourceThreadId: message.threadId, - author: authorFor(message), + from: fromFor(message), timestamp: message.timestamp, readableContent, ...(item.comment ? { comment: item.comment } : {}), diff --git a/packages/api/src/domains/cats/services/context/message-selection-types.ts b/packages/api/src/domains/cats/services/context/message-selection-types.ts index f1e5817891..3990200c97 100644 --- a/packages/api/src/domains/cats/services/context/message-selection-types.ts +++ b/packages/api/src/domains/cats/services/context/message-selection-types.ts @@ -1,4 +1,4 @@ -import type { MessageBundleCarrierV1, MessageBundleItemV1, RichBlock } from '@cat-cafe/shared'; +import type { MessageBundleCarrierV1, MessageBundleItemV1, MessageFrom, RichBlock } from '@cat-cafe/shared'; import type { StoredMessage } from '../stores/ports/MessageStore.js'; import type { Thread } from '../stores/ports/ThreadStore.js'; @@ -16,14 +16,12 @@ export type MessageSelectionInvalidReason = export type MessageSelectionTombstoneReason = 'source_unavailable' | 'source_changed'; -export type MessageSelectionAuthor = { kind: 'user'; userId: string } | { kind: 'cat'; catId: string }; - export interface ResolvedMessageSelectionItem { status: 'available'; kind: 'message' | 'quote' | 'cli_quote' | 'rich_block'; messageId: string; sourceThreadId: string; - author: MessageSelectionAuthor; + from: MessageFrom; timestamp: number; readableContent: string; comment?: string; diff --git a/packages/api/src/domains/cats/services/context/prompt-template-loader.ts b/packages/api/src/domains/cats/services/context/prompt-template-loader.ts index 7693e9b668..01b63ed509 100644 --- a/packages/api/src/domains/cats/services/context/prompt-template-loader.ts +++ b/packages/api/src/domains/cats/services/context/prompt-template-loader.ts @@ -214,7 +214,7 @@ const TEMPLATE_FILES: Record = { D7_serial: { base: 'd7-mode-serial.md', local: '' }, D7_parallel: { base: 'd7-mode-parallel.md', local: '' }, D7_solo: { base: 'd7-mode-solo.md', local: '' }, - D12: { base: 'd12-active-participant.md', local: '' }, + D12: { base: 'd12-recent-speaker.md', local: '' }, D13: { base: 'd13-routing-policy.md', local: '' }, D15: { base: 'd15-voice-off.md', local: '' }, D15_on: { base: 'd15-voice-on.md', local: '' }, diff --git a/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts b/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts index ddb2ef230e..ce3e309e71 100644 --- a/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts +++ b/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts @@ -25,9 +25,9 @@ export async function deliverBriefingCard( ): Promise { const rich: RichMessageExtra = { v: 1, blocks: [card] }; const msg = await messageStore.append({ + from: { kind: 'system', service: 'duty-briefing' }, threadId, userId: BRIEFING_USER_ID, - catId: null, content: card.title, mentions: [], timestamp: now, diff --git a/packages/api/src/domains/cats/services/freshness/FreshnessNoticeBroker.ts b/packages/api/src/domains/cats/services/freshness/FreshnessNoticeBroker.ts index d46ee20ef1..e4db98026f 100644 --- a/packages/api/src/domains/cats/services/freshness/FreshnessNoticeBroker.ts +++ b/packages/api/src/domains/cats/services/freshness/FreshnessNoticeBroker.ts @@ -62,7 +62,7 @@ export function createContentFreeFreshnessNotice(input: { threadId: string; unse return ( `📬 freshness notice:当前 thread 有 ${input.unseenCount} 条新消息。` + `请在自然工具断点调用 cat_cafe_get_thread_context({ threadId: "${input.threadId}", responseMode: "full" }) ` + - '无过滤精确读取;本提醒不含消息正文。' + '无过滤精确读取未读增量;返回的 contextScope=unread_delta,hasMore=false 只表示这批未读已读完,不表示完整历史已经读完;本提醒不含消息正文。' ); } diff --git a/packages/api/src/domains/cats/services/freshness/FreshnessNoticeService.ts b/packages/api/src/domains/cats/services/freshness/FreshnessNoticeService.ts index 18ebff5cc8..e2f3e4518d 100644 --- a/packages/api/src/domains/cats/services/freshness/FreshnessNoticeService.ts +++ b/packages/api/src/domains/cats/services/freshness/FreshnessNoticeService.ts @@ -173,7 +173,8 @@ export class FreshnessNoticeService { `📬 提醒:你有 ${unseen.count} 条未读消息(当前 thread)\n` + `来自:${unseen.senders.join(', ')}\n` + `位置:threadId=${threadId}; messageId=${unseen.maxMessageId}\n` + - `调 cat_cafe_get_thread_context({ threadId: "${threadId}", responseMode: "full" }) 无过滤查看完整内容`; + `调 cat_cafe_get_thread_context({ threadId: "${threadId}", responseMode: "full" }) 无过滤读取未读增量;` + + `contextScope=unread_delta,hasMore=false 只表示这批未读已读完,不表示完整历史已经读完`; return { text, noticeId }; } @@ -265,7 +266,8 @@ export class FreshnessNoticeService { `⚠️ 你这轮有 ${unresolved.length} 条未读消息未查看\n` + `来自:${senders.join(', ')}\n` + `位置:threadId=${threadId}; messageId=${latestMessageId}\n` + - `建议调 cat_cafe_get_thread_context({ threadId: "${threadId}", responseMode: "full" }) 无过滤读取后再退出`; + `建议调 cat_cafe_get_thread_context({ threadId: "${threadId}", responseMode: "full" }) 无过滤读取未读增量后再退出;` + + `contextScope=unread_delta,hasMore=false 只表示这批未读已读完,不表示完整历史已经读完`; return { text }; } diff --git a/packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts b/packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts index 4dee569d99..93ed9f01dc 100644 --- a/packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts +++ b/packages/api/src/domains/cats/services/freshness/FreshnessRelevancePolicy.ts @@ -1,3 +1,4 @@ +import { messageFrom } from '../stores/message-from.js'; import type { FreshnessReadableMessage } from './checkFreshnessForPostMessage.js'; export type FreshnessRelevanceReason = @@ -54,7 +55,7 @@ export function decideFreshnessRelevance( return { relevant: false, reason: 'same_user_wave_sibling_reply' }; } - if (message.catId === null) { + if (messageFrom(message as unknown as Parameters[0]).kind !== 'agent') { if (explicitTargets.size > 0 && !explicitTargets.has(context.catId)) { return { relevant: false, reason: 'directed_to_other_cat' }; } diff --git a/packages/api/src/domains/cats/services/freshness/FreshnessSourcePolicy.ts b/packages/api/src/domains/cats/services/freshness/FreshnessSourcePolicy.ts index eb3cdde903..c1e02d65c0 100644 --- a/packages/api/src/domains/cats/services/freshness/FreshnessSourcePolicy.ts +++ b/packages/api/src/domains/cats/services/freshness/FreshnessSourcePolicy.ts @@ -1,17 +1,17 @@ -import { isCrossThreadProvenance } from '@cat-cafe/shared'; +import { isCrossThreadProvenance, type MessageFrom } from '@cat-cafe/shared'; +import { messageFrom } from '../stores/message-from.js'; export interface FreshnessSourceMessage { threadId?: string; + from?: MessageFrom; catId: string | null; extra?: { crossPost?: { sourceThreadId: string } }; } export interface FreshnessQueueSourceEntry { - source: string; - callerCatId?: string; + from: MessageFrom; sourceCategory?: string; messageId?: string | null; - mergedMessageIds?: string[]; } export interface FreshnessSourceMessageReader { @@ -33,7 +33,9 @@ export function isFreshnessSelfSourceMessage( catId: string, targetThreadId: string, ): boolean { - return message.catId === catId && !hasCrossThreadFreshnessProvenance(message, targetThreadId); + const from = messageFrom(message as unknown as Parameters[0]); + const authorCatId = from.kind === 'agent' ? from.catId : null; + return authorCatId === catId && !hasCrossThreadFreshnessProvenance(message, targetThreadId); } /** @@ -48,15 +50,15 @@ export async function isFreshnessSelfSourceQueueEntry( targetThreadId: string, messageStore: FreshnessSourceMessageReader, ): Promise { - if (!(entry.source === 'agent' && entry.callerCatId === catId)) return false; + if (!(entry.from.kind === 'agent' && entry.from.catId === catId)) return false; if (entry.sourceCategory !== 'a2a' || !messageStore.getById) return true; - const messageIds = [entry.messageId ?? '', ...(entry.mergedMessageIds ?? [])].filter( - (messageId, index, all) => messageId.length > 0 && all.indexOf(messageId) === index, - ); + const messageIds = entry.messageId ? [entry.messageId] : []; for (const messageId of messageIds) { const message = await messageStore.getById(messageId); - if (message?.catId === catId && hasCrossThreadFreshnessProvenance(message, targetThreadId)) { + const from = message ? messageFrom(message as unknown as Parameters[0]) : null; + const authorCatId = from?.kind === 'agent' ? from.catId : null; + if (message && authorCatId === catId && hasCrossThreadFreshnessProvenance(message, targetThreadId)) { return false; } } diff --git a/packages/api/src/domains/cats/services/freshness/ThreadUnseenChecker.ts b/packages/api/src/domains/cats/services/freshness/ThreadUnseenChecker.ts index 7b434c5817..55c3f353cf 100644 --- a/packages/api/src/domains/cats/services/freshness/ThreadUnseenChecker.ts +++ b/packages/api/src/domains/cats/services/freshness/ThreadUnseenChecker.ts @@ -133,9 +133,7 @@ export class ThreadUnseenChecker implements UnseenChecker { const senderSet = new Set(nonSelf.map((e) => getQueuedFreshnessSenderLabel(e))); const senders = [...senderSet]; const frontierEntry = nonSelf.at(-1); - const correlationMessageIds = [frontierEntry?.messageId ?? '', ...(frontierEntry?.mergedMessageIds ?? [])].filter( - (messageId, index, all) => messageId.length > 0 && all.indexOf(messageId) === index, - ); + const correlationMessageIds = frontierEntry?.messageId ? [frontierEntry.messageId] : []; const noticeDedupKey = JSON.stringify({ queueEntryId: frontierEntry?.entryId ?? null, messageIds: [...correlationMessageIds].sort(), @@ -157,9 +155,9 @@ export class ThreadUnseenChecker implements UnseenChecker { // syntheticSeq = max(seenSeq+1, Date.now()) ensures the cursor exceeds // the current seen cursor (codex R13 HWM fix). maxMessageId: cursorFor({ id: '0', visibilitySeq: syntheticSeq }), - // Re-checking the same queued entry generates a fresh synthetic cursor. - // Coalesce by durable, content-free Queue identity instead; a newly - // merged message ID changes this key and permits exactly one new notice. + // Re-checking the same queued row generates a fresh synthetic cursor. + // Deduplicate by its durable, content-free scalar identity; a new source + // arrives as a new row and therefore permits exactly one new notice. noticeDedupKey, // Receipt truth must use the exact Queue identity, never the synthetic // cursor frontier. If the frontier entry lacks identity, keep [] so diff --git a/packages/api/src/domains/cats/services/freshness/checkFreshnessForPostMessage.ts b/packages/api/src/domains/cats/services/freshness/checkFreshnessForPostMessage.ts index 6442e49973..8d5339cc6a 100644 --- a/packages/api/src/domains/cats/services/freshness/checkFreshnessForPostMessage.ts +++ b/packages/api/src/domains/cats/services/freshness/checkFreshnessForPostMessage.ts @@ -12,10 +12,11 @@ * all decision logic to FreshnessGateService. */ -import type { CatId, PublishedFreshnessAnnotation } from '@cat-cafe/shared'; +import type { CatId, MessageFrom, PublishedFreshnessAnnotation } from '@cat-cafe/shared'; import { freshnessGateForward, freshnessGateHeld } from '../../../../infrastructure/telemetry/instruments.js'; import { getSourceDisplayName } from '../context/ContextAssembler.js'; import { cursorFor } from '../stores/cursor.js'; +import { messageFrom } from '../stores/message-from.js'; import type { DeliveryCursorStore } from '../stores/ports/DeliveryCursorStore.js'; import type { ThreadMessageReadOptions } from '../stores/ports/MessageStore.js'; import type { FreshnessAttentionEventLog } from './FreshnessAttentionEventLog.js'; @@ -29,6 +30,7 @@ export interface FreshnessReadableMessage { id: string; threadId?: string; catId: string | null; + from?: MessageFrom; content: string; /** #1200: visibility-domain sequence number (injected by store). */ visibilitySeq?: number; @@ -88,11 +90,9 @@ export interface QueuedMessageChecker { catId: string, ): Array<{ entryId?: string; - source: string; + from: MessageFrom; content: string; - callerCatId?: string; messageId?: string | null; - mergedMessageIds?: string[]; sourceCategory?: string; }>; } @@ -117,11 +117,9 @@ export function createQueueChecker( opts?: { parentInvocationId?: string }, ): Array<{ entryId?: string; - source: string; + from: MessageFrom; content: string; - callerCatId?: string; messageId?: string | null; - mergedMessageIds?: string[]; sourceCategory?: string; }>; }, @@ -138,11 +136,9 @@ export function createQueueChecker( }) .map((e) => ({ ...(e.entryId ? { entryId: e.entryId } : {}), - source: e.source, + from: structuredClone(e.from), content: e.content, - callerCatId: e.callerCatId, ...(e.messageId !== undefined ? { messageId: e.messageId } : {}), - ...(e.mergedMessageIds ? { mergedMessageIds: e.mergedMessageIds } : {}), sourceCategory: e.sourceCategory, })); }, @@ -217,23 +213,26 @@ const PREVIEW_CONTENT_LIMIT = 200; const UNSEEN_FETCH_LIMIT = 20; // Per-batch fetch limit const MAX_PAGINATION_ROUNDS = 5; // 5 × 20 = 100 max raw messages before fail-open -export function getFreshnessSenderLabel(msg: Pick): string { - return msg.source ? getSourceDisplayName(msg.source) : (msg.catId ?? 'user'); +export function getFreshnessSenderLabel(msg: Pick): string { + if (msg.source) return getSourceDisplayName(msg.source); + const from = messageFrom(msg as unknown as Parameters[0]); + if (from.kind === 'agent') return from.catId; + if (from.kind === 'external') return from.sender?.name ?? from.sender?.id ?? from.connectorId; + if (from.kind === 'plugin') return from.instanceId; + if (from.kind === 'system') return from.service; + return 'user'; } -export function getQueuedFreshnessSenderLabel(entry: { - source: string; - callerCatId?: string; - sourceCategory?: string; -}): string { - if (entry.source === 'user') return 'user'; - if (entry.source === 'connector') { +export function getQueuedFreshnessSenderLabel(entry: { from: MessageFrom; sourceCategory?: string }): string { + if (entry.from.kind === 'user') return 'user'; + if (entry.from.kind === 'external' || entry.from.kind === 'plugin') { if (entry.sourceCategory === 'review') return 'Review'; if (entry.sourceCategory === 'issue') return 'Issue'; if (entry.sourceCategory === 'ci') return 'CI'; return 'Connector'; } - return entry.callerCatId ?? 'unknown'; + if (entry.from.kind === 'agent') return entry.from.catId; + return entry.from.service; } const INTERNAL_FRESHNESS_USER_IDS = new Set(['system']); @@ -270,17 +269,21 @@ export function isFreshnessRoutableMessage(msg: FreshnessReadableMessage): boole * actually mentioned the replying cat. */ export async function isExpectedA2AReplyForCat( - msg: Pick, + msg: Pick, catId: string, messageStore: Pick, ): Promise { - if (!msg.replyTo || !msg.catId || msg.catId === catId) return false; + const from = messageFrom(msg as unknown as Parameters[0]); + const replyCatId = from.kind === 'agent' ? from.catId : null; + if (!msg.replyTo || !replyCatId || replyCatId === catId) return false; if (typeof messageStore.getById !== 'function') return false; const parent = await messageStore.getById(msg.replyTo); if (!parent) return false; - if (parent.catId !== catId) return false; - return Array.isArray(parent.mentions) && parent.mentions.includes(msg.catId); + const parentFrom = messageFrom(parent as unknown as Parameters[0]); + const parentCatId = parentFrom.kind === 'agent' ? parentFrom.catId : null; + if (parentCatId !== catId) return false; + return Array.isArray(parent.mentions) && parent.mentions.includes(replyCatId); } /** diff --git a/packages/api/src/domains/cats/services/freshness/checkStreamOutputFreshness.ts b/packages/api/src/domains/cats/services/freshness/checkStreamOutputFreshness.ts index 34eacc6195..0542579b8d 100644 --- a/packages/api/src/domains/cats/services/freshness/checkStreamOutputFreshness.ts +++ b/packages/api/src/domains/cats/services/freshness/checkStreamOutputFreshness.ts @@ -56,16 +56,8 @@ function boundQueuedFreshnessEntry( throughMessageId: string | null | undefined, ): QueuedFreshnessEntry | null { if (throughMessageId === undefined) return entry; - const boundedIds = [...(entry.messageId ? [entry.messageId] : []), ...(entry.mergedMessageIds ?? [])].filter( - (id) => throughMessageId !== null && id <= throughMessageId, - ); - if (!entry.messageId && (entry.mergedMessageIds?.length ?? 0) === 0) return entry; - if (boundedIds.length === 0) return null; - return { - ...entry, - messageId: boundedIds[0], - mergedMessageIds: boundedIds.slice(1), - }; + if (!entry.messageId) return entry; + return throughMessageId !== null && entry.messageId <= throughMessageId ? entry : null; } /** @@ -290,16 +282,9 @@ export async function checkStreamOutputFreshness(input: CheckStreamFreshnessInpu if (nonSelfQueued.length > 0) { const queueSenders = [...new Set(nonSelfQueued.map((q) => getQueuedFreshnessSenderLabel(q)))]; const queuedMessageIds = [ - ...new Set( - nonSelfQueued.flatMap((queued) => [ - ...(queued.messageId ? [queued.messageId] : []), - ...(queued.mergedMessageIds ?? []), - ]), - ), + ...new Set(nonSelfQueued.flatMap((queued) => (queued.messageId ? [queued.messageId] : []))), ].sort(); - const identityMissing = nonSelfQueued.some( - (queued) => !queued.messageId && (queued.mergedMessageIds?.length ?? 0) === 0, - ); + const identityMissing = nonSelfQueued.some((queued) => !queued.messageId); return emit({ stale: true, unseenCount: nonSelfQueued.length, diff --git a/packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStartupReconciler.ts b/packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStartupReconciler.ts index fdc9a6d37b..e5f8a05d3f 100644 --- a/packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStartupReconciler.ts +++ b/packages/api/src/domains/cats/services/freshness/closure/FreshnessClosureStartupReconciler.ts @@ -14,7 +14,7 @@ interface StartupRecoveryLogger { export interface FreshnessClosureStartupReconcilerDeps { closureStore: Pick; - enqueue(closure: FreshnessClosureAggregate): EnqueueResult; + enqueue(closure: FreshnessClosureAggregate): EnqueueResult | Promise; executeThread(threadId: string): void | Promise; onProjection?(projection: FreshnessClosureProjection): void | Promise; log: StartupRecoveryLogger; @@ -88,7 +88,7 @@ async function reconcileStartupCandidate( evidenceRef: 'startup:recent_running_recovery', now, }); - const enqueueResult = deps.enqueue(closure); + const enqueueResult = await deps.enqueue(closure); if (enqueueResult.outcome === 'full') { await blockStartupRecovery(deps, closure, ['startup:queue_full'], now); return { recovered: 0, blocked: 1, enqueued: 0 }; diff --git a/packages/api/src/domains/cats/services/freshness/createFreshnessReinvokeCheck.ts b/packages/api/src/domains/cats/services/freshness/createFreshnessReinvokeCheck.ts index 62bdc42433..daeba42144 100644 --- a/packages/api/src/domains/cats/services/freshness/createFreshnessReinvokeCheck.ts +++ b/packages/api/src/domains/cats/services/freshness/createFreshnessReinvokeCheck.ts @@ -47,7 +47,8 @@ function quotaKey(catId: string, threadId: string): string { export function buildFreshnessReinvokePrompt(threadId: string, senders: string[], noticeCount: number): string { return ( `你上一轮 turn 中有来自 ${senders.join(', ')} 的 ${noticeCount} 条未读消息,` + - `请调用 cat_cafe_get_thread_context({ threadId: "${threadId}", responseMode: "full" }) 无过滤读取并回应。` + `请调用 cat_cafe_get_thread_context({ threadId: "${threadId}", responseMode: "full" }) 无过滤读取未读增量并回应;` + + `返回的 contextScope=unread_delta,hasMore=false 只表示这批未读已读完,不表示完整历史已经读完。` ); } diff --git a/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessOutputCommitCoordinator.ts b/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessOutputCommitCoordinator.ts index b5b1cec574..e17438e9db 100644 --- a/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessOutputCommitCoordinator.ts +++ b/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessOutputCommitCoordinator.ts @@ -6,7 +6,8 @@ import type { FreshnessSupplementProjection, OutputCommitDecision, } from '@cat-cafe/shared'; -import type { IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; +import type { AppendMessageInput, IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; +import { commitLifecycleResponseFromAppendInput } from '../../stores/ports/MessageStore.js'; import type { FreshnessClosureStore } from '../closure/FreshnessClosureStore.js'; import { FreshnessOutputRefiner } from './FreshnessOutputRefiner.js'; import { recordFreshnessGlassBoxTransition } from './freshness-glass-box-telemetry.js'; @@ -106,10 +107,15 @@ export class FreshnessOutputCommitCoordinator { } private async commitOriginal(input: FreshnessOutputCommitInput): Promise { - const observed = await this.deps.messageStore.appendAndObservePriorFrontier({ - ...input.message, - idempotencyKey: input.message.idempotencyKey ?? publishedOriginalIdempotencyKey(input), - }); + const observed = input.lifecycleResponse + ? { + message: await this.terminalizeLifecycleResponse(input, input.message), + priorFrontierMessageId: input.lifecycleResponse.priorFrontierMessageId, + } + : await this.deps.messageStore.appendAndObservePriorFrontier({ + ...input.message, + idempotencyKey: input.message.idempotencyKey ?? publishedOriginalIdempotencyKey(input), + }); try { await this.finalizeLegacyClosure(input, observed.message, observed.priorFrontierMessageId); } catch { @@ -157,6 +163,15 @@ export class FreshnessOutputCommitCoordinator { supplement: FreshnessSupplementAggregate, ): Promise { if (supplement.status === 'declined') return supplementDeclinedDecision(input, supplement); + if (input.lifecycleResponse) { + const { contentBlocks: _contentBlocks, toolEvents: _toolEvents, ...declineMessage } = input.message; + void _contentBlocks; + void _toolEvents; + await this.terminalizeLifecycleResponse(input, { + ...declineMessage, + content: '', + }); + } const declined = await this.deps.closureStore.declineSupplement(supplement.id, { invocationId: input.invocationId, now: Date.now(), @@ -182,7 +197,7 @@ export class FreshnessOutputCommitCoordinator { if (supplement.status !== 'running' || supplement.runningInvocationId !== input.invocationId) { throw new Error('only the claimed invocation may publish a freshness supplement'); } - const observed = await this.deps.messageStore.appendAndObservePriorFrontier({ + const supplementMessage: AppendMessageInput = { ...input.message, timestamp: Date.now(), replyTo: supplement.originalMessageId, @@ -196,7 +211,13 @@ export class FreshnessOutputCommitCoordinator { originalMessageId: supplement.originalMessageId, }, }, - }); + }; + const observed = input.lifecycleResponse + ? { + message: await this.terminalizeLifecycleResponse(input, supplementMessage), + priorFrontierMessageId: input.lifecycleResponse.priorFrontierMessageId, + } + : await this.deps.messageStore.appendAndObservePriorFrontier(supplementMessage); const committed = await this.commitSupplementState(supplement, input.invocationId, observed.message.id); if (!committed) { return { @@ -228,6 +249,22 @@ export class FreshnessOutputCommitCoordinator { return null; } + private async terminalizeLifecycleResponse( + input: FreshnessOutputCommitInput, + message: AppendMessageInput, + ): Promise { + const lifecycle = input.lifecycleResponse; + if (!lifecycle) throw new Error('lifecycle response identity is required'); + if (input.commitLifecycleResponse) return input.commitLifecycleResponse(message); + return commitLifecycleResponseFromAppendInput( + this.deps.messageStore, + lifecycle.messageId, + input.turnInvocationId ?? input.invocationId, + lifecycle, + message, + ); + } + private async finalizeLegacyClosure( input: FreshnessOutputCommitInput, message: StoredMessage, diff --git a/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts b/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts index aa49b46461..f1b647a802 100644 --- a/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts +++ b/packages/api/src/domains/cats/services/freshness/glass-box/FreshnessSupplementStartupReconciler.ts @@ -1,4 +1,4 @@ -import type { FreshnessSupplementAggregate, FreshnessSupplementProjection } from '@cat-cafe/shared'; +import type { FreshnessSupplementAggregate, FreshnessSupplementProjection, MessageFrom } from '@cat-cafe/shared'; import type { EnqueueResult } from '../../agents/invocation/InvocationQueue.js'; import type { IMessageStore } from '../../stores/ports/MessageStore.js'; import type { FreshnessClosureStore } from '../closure/FreshnessClosureStore.js'; @@ -13,11 +13,11 @@ interface StartupRecoveryLogger { interface SupplementQueueCarrier { threadId: string; userId: string; + kind: 'private_input'; content: string; - source: 'agent'; + from: MessageFrom; sourceCategory: 'freshness'; targetCats: string[]; - callerCatId: string; autoExecute: true; priority: 'normal'; intent: 'execute'; @@ -34,7 +34,7 @@ interface SupplementQueueCarrier { export interface FreshnessSupplementStartupReconcilerDeps { closureStore: Pick; messageStore: Pick; - enqueue(entry: SupplementQueueCarrier): EnqueueResult; + enqueue(entry: SupplementQueueCarrier): EnqueueResult | Promise; executeThread(threadId: string): void | Promise; onProjection?(projection: FreshnessSupplementProjection): void | Promise; log: StartupRecoveryLogger; @@ -53,11 +53,11 @@ function carrierFor(supplement: FreshnessSupplementAggregate): SupplementQueueCa return { threadId: supplement.threadId, userId: supplement.userId, + kind: 'private_input', content: `[Freshness Supplement ${supplement.id}] startup recovery`, - source: 'agent', + from: { kind: 'agent', catId: supplement.catId }, sourceCategory: 'freshness', targetCats: [supplement.catId], - callerCatId: supplement.catId, autoExecute: true, priority: 'normal', intent: 'execute', @@ -131,7 +131,7 @@ async function reconcileCandidate( let enqueueResult: EnqueueResult; try { - enqueueResult = deps.enqueue(carrierFor(candidate)); + enqueueResult = await deps.enqueue(carrierFor(candidate)); } catch { await failCandidate(deps, candidate, 'scheduler_unavailable', now); return { recoveredCommitted: 0, failed: 1, enqueued: 0 }; diff --git a/packages/api/src/domains/cats/services/freshness/glass-box/freshness-output-contract.ts b/packages/api/src/domains/cats/services/freshness/glass-box/freshness-output-contract.ts index 9e388f6309..bd8ed1284c 100644 --- a/packages/api/src/domains/cats/services/freshness/glass-box/freshness-output-contract.ts +++ b/packages/api/src/domains/cats/services/freshness/glass-box/freshness-output-contract.ts @@ -6,7 +6,7 @@ import type { OutputCommitDecision, PublishedFreshnessAnnotation, } from '@cat-cafe/shared'; -import type { AppendMessageInput } from '../../stores/ports/MessageStore.js'; +import type { AppendMessageInput, StoredMessage } from '../../stores/ports/MessageStore.js'; import type { StreamFreshnessResult } from '../checkStreamOutputFreshness.js'; import type { FreshnessClosureScope } from '../closure/FreshnessClosureStore.js'; @@ -33,6 +33,16 @@ export interface FreshnessOutputCommitInput extends FreshnessClosureScope { /** Exact ADR-042 supplement carrier. Never aliases a legacy closure identity. */ freshnessSupplementId?: string; message: AppendMessageInput; + /** Existing processing response that must be terminalized instead of appending a second bubble. */ + lifecycleResponse?: { + messageId: string; + priorFrontierMessageId: string | null; + status: 'completed' | 'failed' | 'canceled' | 'interrupted'; + completedAt: number; + reason?: string; + }; + /** Storage-linearized terminal override for completed-final message wake admission. */ + commitLifecycleResponse?: (message: AppendMessageInput) => Promise; evaluateFreshness: (priorFrontierMessageId: string | null) => Promise; replayUnsafeToolNames?: string[]; /** @deprecated ADR-042 does not recheck an append frontier. */ diff --git a/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts b/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts index 4d709734b2..67bfe3d834 100644 --- a/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts +++ b/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts @@ -12,6 +12,7 @@ import type { CliDiagnostics, FrustrationSignalType, RichBlock } from '@cat-cafe/shared'; import type { SocketManager } from '../../../../infrastructure/websocket/index.js'; +import { messageFrom } from '../stores/message-from.js'; import type { IFrustrationIssueStore } from '../stores/ports/FrustrationIssueStore.js'; import type { IMessageStore } from '../stores/ports/MessageStore.js'; @@ -269,13 +270,14 @@ export async function evaluate( let recentMessages: Array<{ role: 'user' | 'cat' | 'system'; content: string; timestamp: number }> = []; try { const messages = await deps.messageStore.getByThread(threadId, CONTEXT_MESSAGE_COUNT); - recentMessages = (messages as Array<{ catId?: string; userId?: string; content?: string; timestamp: number }>).map( - (m) => ({ - role: (m.catId ? 'cat' : m.userId === 'system' ? 'system' : 'user') as 'user' | 'cat' | 'system', - content: typeof m.content === 'string' ? m.content.slice(0, 500) : '', + recentMessages = messages.map((m) => { + const from = messageFrom(m); + return { + role: (from.kind === 'agent' ? 'cat' : from.kind === 'system' ? 'system' : 'user') as 'user' | 'cat' | 'system', + content: m.content.slice(0, 500), timestamp: m.timestamp, - }), - ); + }; + }); } catch { // Non-blocking: proceed without context if messageStore fails } @@ -304,8 +306,8 @@ export async function evaluate( // 7. Post as system message with rich blocks try { const stored = await deps.messageStore.append({ + from: { kind: 'system', service: 'frustration-detector' }, userId: 'system', - catId: null, threadId, content: `[${signalType === 'user_report' ? '用户反馈' : '自动检测'}] ${signalType === 'user_report' ? '你发起了问题反馈' : `检测到可能的问题(${signalType === 'cli_error' ? 'CLI 错误' : '操作中断'})`},已自动整理上下文。`, mentions: [], diff --git a/packages/api/src/domains/cats/services/game/GameOrchestrator.ts b/packages/api/src/domains/cats/services/game/GameOrchestrator.ts index db40ca7f61..ca5a841e15 100644 --- a/packages/api/src/domains/cats/services/game/GameOrchestrator.ts +++ b/packages/api/src/domains/cats/services/game/GameOrchestrator.ts @@ -335,8 +335,8 @@ export class GameOrchestrator { const userId = runtime.config.observerUserId ?? 'system'; Promise.resolve( this.messageStore.append({ + from: { kind: 'agent', catId: catId as import('@cat-cafe/shared').CatId }, userId, - catId: catId as import('@cat-cafe/shared').CatId, content, mentions: [], timestamp: Date.now(), diff --git a/packages/api/src/domains/cats/services/game/gameSystemMessage.ts b/packages/api/src/domains/cats/services/game/gameSystemMessage.ts index 5b2dd11572..8ea45e9515 100644 --- a/packages/api/src/domains/cats/services/game/gameSystemMessage.ts +++ b/packages/api/src/domains/cats/services/game/gameSystemMessage.ts @@ -1,4 +1,3 @@ -import type { CatId } from '@cat-cafe/shared'; import type { IMessageStore, StoredMessage } from '../stores/ports/MessageStore.js'; interface NarrativeSocketLike { @@ -16,8 +15,8 @@ export async function appendGameSystemMessage(params: { const stored = params.messageStore ? await Promise.resolve( params.messageStore.append({ + from: { kind: 'system', service: 'game-orchestrator' }, userId: 'system', - catId: 'system' as CatId, content: params.content, mentions: [], timestamp, diff --git a/packages/api/src/domains/cats/services/game/wakeCatImpl.ts b/packages/api/src/domains/cats/services/game/wakeCatImpl.ts index 1a72125aca..aeafad6a62 100644 --- a/packages/api/src/domains/cats/services/game/wakeCatImpl.ts +++ b/packages/api/src/domains/cats/services/game/wakeCatImpl.ts @@ -6,6 +6,7 @@ * leaking game secrets (e.g. "你是狼人") into the thread chat flow. */ +import { createHash } from 'node:crypto'; import type { CatId } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; import type { InvocationQueue } from '../agents/invocation/InvocationQueue.js'; @@ -13,7 +14,7 @@ import type { IThreadStore } from '../stores/ports/ThreadStore.js'; import type { WakeCatFn } from './GameNarratorDriver.js'; export interface QueueProcessorLike { - tryAutoExecute(threadId: string): Promise; + requestDrain(threadId: string): Promise; } export interface WakeCatDeps { @@ -32,15 +33,17 @@ export function createWakeCatFn(deps: WakeCatDeps): WakeCatFn { const thread = await threadStore.get(threadId); const userId = thread?.createdBy ?? 'default-user'; - const result = invocationQueue.enqueue({ + const result = await invocationQueue.enqueueDurable({ + from: { kind: 'system', service: 'game-orchestrator' }, threadId, userId, + kind: 'private_input', ownerAuthProvenance: 'unknown', content: briefing, - source: 'agent', targetCats: [catId], intent: 'execute', autoExecute: true, + sourceId: `game:${threadId}:${catId}:${createHash('sha256').update(briefing).digest('hex')}`, }); if (result.outcome === 'full') { @@ -48,7 +51,7 @@ export function createWakeCatFn(deps: WakeCatDeps): WakeCatFn { return; } - await queueProcessor.tryAutoExecute(threadId); + await queueProcessor.requestDrain(threadId); log.info( { diff --git a/packages/api/src/domains/cats/services/index.ts b/packages/api/src/domains/cats/services/index.ts index a14793daf5..073440a25d 100644 --- a/packages/api/src/domains/cats/services/index.ts +++ b/packages/api/src/domains/cats/services/index.ts @@ -9,6 +9,7 @@ export type { InvocationDeps, InvocationParams } from './agents/invocation/invok export { invokeSingleCat } from './agents/invocation/invoke-single-cat.js'; export { buildMcpCallbackInstructions, needsMcpInjection } from './agents/invocation/McpPromptInjector.js'; export { ClaudeAgentService } from './agents/providers/ClaudeAgentService.js'; +export { ClaudeSdkAgentService } from './agents/providers/ClaudeSdkAgentService.js'; export { CodexAgentService } from './agents/providers/CodexAgentService.js'; export { GeminiAgentService } from './agents/providers/GeminiAgentService.js'; export { KimiAgentService } from './agents/providers/KimiAgentService.js'; diff --git a/packages/api/src/domains/cats/services/push/decision-notification-policy.ts b/packages/api/src/domains/cats/services/push/decision-notification-policy.ts new file mode 100644 index 0000000000..916b493f7a --- /dev/null +++ b/packages/api/src/domains/cats/services/push/decision-notification-policy.ts @@ -0,0 +1,15 @@ +const DECISION_NOTIFICATION_RE = /\b(review|lgtm|merge|pr)\b/i; + +export function shouldMarkDecisionNotification(content: string): boolean { + const lower = content.toLowerCase(); + return ( + DECISION_NOTIFICATION_RE.test(content) || + content.includes('合入') || + content.includes('审批') || + content.includes('批准') || + content.includes('决策') || + content.includes('请确认') || + content.includes('是否允许') || + lower.includes('can merge') + ); +} diff --git a/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts b/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts index e62601e987..a9a1f0bc40 100644 --- a/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts +++ b/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts @@ -94,9 +94,9 @@ function mapTranscriptEventToMessage( if (!content) return null; const base: AppendMessageInput = { + from: evtType === 'user' ? { kind: 'user', userId } : { kind: 'agent', catId: session.catId }, userId, threadId: session.threadId, - catId: evtType === 'user' ? null : session.catId, content, mentions: [], timestamp: evt.t, diff --git a/packages/api/src/domains/cats/services/session/InvocationPromptInputProjector.ts b/packages/api/src/domains/cats/services/session/InvocationPromptInputProjector.ts index 46bc6f7a2f..1ff4bf6b99 100644 --- a/packages/api/src/domains/cats/services/session/InvocationPromptInputProjector.ts +++ b/packages/api/src/domains/cats/services/session/InvocationPromptInputProjector.ts @@ -13,6 +13,11 @@ export interface InvocationPromptInputProjectorDeps { } function messageAuthor(message: StoredMessage): 'user' | 'assistant' | 'system' { + if (message.from) { + if (message.from.kind === 'agent') return 'assistant'; + if (message.from.kind === 'system') return 'system'; + return 'user'; + } if (message.catId === null) return 'user'; return message.catId === 'system' ? 'system' : 'assistant'; } diff --git a/packages/api/src/domains/cats/services/stores/message-from.ts b/packages/api/src/domains/cats/services/stores/message-from.ts new file mode 100644 index 0000000000..d194a915a6 --- /dev/null +++ b/packages/api/src/domains/cats/services/stores/message-from.ts @@ -0,0 +1,55 @@ +import type { CatId, ConnectorSource, MessageFrom } from '@cat-cafe/shared'; + +/** + * Synthetic connector identity used only when a legacy Redis row proves that + * a connector source existed but its payload cannot be parsed. Treating that + * row as a user message would grant it operator authority, so the fallback + * deliberately remains in the non-user namespace. + */ +export const UNPARSEABLE_LEGACY_CONNECTOR_ID = 'legacy-unparseable-source'; + +export interface MessageFromSource { + readonly from?: MessageFrom; + readonly userId: string; + readonly catId: CatId | null; + readonly source?: ConnectorSource; + readonly sourceParseFailure?: true; + readonly origin?: 'stream' | 'callback' | 'briefing'; +} + +/** + * Return the canonical sender identity for both current and legacy messages. + * + * New writes always persist `from`. The remaining branches are the single + * compatibility boundary for pre-MessageFrom rows; callers must not repeat + * legacy userId/catId/source inference. + */ +export function messageFrom(message: MessageFromSource): MessageFrom { + if (message.from) return message.from; + + if (message.source) { + return { + kind: 'external', + connectorId: message.source.connector, + ...(message.source.sender ? { sender: message.source.sender } : {}), + }; + } + + if (message.sourceParseFailure) { + return { kind: 'external', connectorId: UNPARSEABLE_LEGACY_CONNECTOR_ID }; + } + + if ( + message.origin === 'briefing' || + ((message.userId === 'system' || message.userId === 'scheduler') && + (message.catId === 'system' || message.catId === null)) + ) { + return { + kind: 'system', + service: message.origin === 'briefing' ? 'legacy-briefing' : message.userId, + }; + } + + if (message.catId !== null) return { kind: 'agent', catId: message.catId }; + return { kind: 'user', userId: message.userId }; +} diff --git a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts index 04241c2557..c10ad1be86 100644 --- a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts @@ -1,7 +1,3 @@ -import { - assertMemoryTypedWaitCustody, - assertTypedWaitCustodyBindings, -} from '../../../../ball-custody/TypedWaitCustodyGuard.js'; /** * Message Store * 内存消息存储,供 MCP 回传工具 get_thread_context / get_pending_mentions 使用 @@ -10,33 +6,46 @@ import { */ import { createHash, randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; import type { CatId, + CatRoutingError, ConnectorSource, CrossThreadCoordination, CustodyOfferV1, EvolutionPreparationSubmissionV1, + LifecycleDeliveryFailureReason, + LifecycleDispatchRef, + LifecycleStoredMessageMetadata, MessageBundleCarrierV1, MessageContent, + MessageFrom, ProviderSemanticEvent, PublishedFreshnessAnnotation, - QueueMessageReceipt, ReplyPreview, RichMessageExtra, SchedulerMessageExtra, } from '@cat-cafe/shared'; import { collectiveOwnerAdmissionV1Schema, - collectiveSourceIdentitySchema, collectiveWorkInvocationV1Schema, custodyOfferV1Schema, evolutionPreparationSubmissionV1Schema, isCrossThreadProvenance, + isLifecycleStoredMessageMetadata, + isMessageFrom, } from '@cat-cafe/shared'; import { normalizeJsonUnicode } from '../../../../../utils/json-unicode.js'; +import { InMemoryQueueLedgerStore } from '../../agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js'; +import { + type QueueLedgerEntry, + type QueueLedgerStore, + queueLedgerAdmissionsMatch, +} from '../../agents/invocation/queue-ledger/QueueLedger.js'; import type { MessageMetadata } from '../../types.js'; import { cursorFor, parseCursor } from '../cursor.js'; import type { MessageDeliveryBoundary } from '../message-delivery-boundary.js'; +import { messageFrom } from '../message-from.js'; import { getTimelineOrderTime, isDurableOwnerReadEvidence, @@ -46,26 +55,10 @@ import { resolveDeliveryTimelineScore, resolveThreadMessageVisibility, } from '../visibility.js'; -import { - assertQueueCustodyMessageBinding, - assertQueueCustodyTransition, - cloneQueueCustodyAdmissionIntent, - cloneQueuedMessageCustody, - type QueueCustodyAdmissionIntent, - type QueueCustodyTransitionInput, - type QueuedMessageCustody, - queueCustodyAdmissionIntentsMatch, - terminalizeRecalledQueueCustody, -} from './queued-message-custody.js'; // Single source of truth: ThreadStore.ts owns DEFAULT_THREAD_ID import { DEFAULT_THREAD_ID } from './ThreadStore.js'; import type { TurnExecutionMessageProjection } from './TurnExecutionStore.js'; export { DEFAULT_THREAD_ID }; -export type { - QueueCustodyAdmissionIntent, - QueueCustodyTransitionInput, - QueuedMessageCustody, -} from './queued-message-custody.js'; /** * F117: Check if a message should be visible in timeline/history/context. @@ -102,8 +95,6 @@ export interface ThreadMessageReadOptions { includeQueuedCatMessages?: boolean; /** Include durable queued owner work (user or Host connector ingress) in the browser timeline only. */ includeQueuedUserMessages?: boolean; - /** Include queued user work whose body was durably exposed to this exact cat. */ - includeExposedQueuedUserMessagesForCatId?: CatId; /** Include only owner-visible, content-free tombstones whose body had a proven exposure before recall. */ includeRecalledUserMessages?: boolean; /** @@ -122,7 +113,14 @@ export interface MessageRecallMarker { exposure: 'none' | 'seen'; recalledAt: number; /** Exact body-exposure witnesses only. Legacy seen flags never manufacture a timestamp. */ - exposures?: readonly import('./queued-message-custody.js').QueueBodyExposure[]; + exposures?: readonly LegacyMessageBodyExposure[]; +} + +/** Read compatibility for recalls created before History became delivery truth. */ +export interface LegacyMessageBodyExposure { + targetCatId: string; + invocationId: string; + seenAt: number; } /** TTL=0 owner-authored composer state. The message body has no second durable copy after recall. */ @@ -164,7 +162,7 @@ export interface RecallMessageToComposerDraftInput { export type RecallMessageToComposerDraftResult = | { kind: 'recalled'; - verdict: 'zero_exposure' | 'exposed'; + verdict: 'zero_exposure'; message: StoredMessage; draft: OwnerComposerDraft; insertedRange: { start: number; end: number }; @@ -244,9 +242,13 @@ export interface StoredMessage { /** Thread this message belongs to (always set after append) */ threadId: string; userId: string; - /** null = user message, CatId = cat message */ + /** RFC #1356 canonical sender identity. Missing only on legacy hydrated rows. */ + from?: MessageFrom; + /** Compatibility projection for existing UI/index consumers; derived from from.kind. */ catId: CatId | null; content: string; + /** #1354: canonical Queue → History → Active Run lifecycle projection. */ + lifecycle?: LifecycleStoredMessageMetadata; /** Rich content blocks (text, images, code). When absent, use content string. */ contentBlocks?: readonly MessageContent[]; /** Tool events recorded during agent invocation (for history replay). */ @@ -267,6 +269,10 @@ export interface StoredMessage { invocationId: string; }; rich?: RichMessageExtra; + /** #1354 structured routing feedback retained from Queue payload into History. */ + routingWarnings?: readonly CatRoutingError[]; + /** Fresh source created by an owner-authorized cloud delivery retry. */ + cloudBridgeRetry?: import('@cat-cafe/shared').CloudBridgeRetryV1; /** #814/F224: explicit post_message callback bubble; history hydration must not merge it into stream output. */ isExplicitPost?: boolean; /** F081 + F194 Phase Z3 dual id: @@ -395,8 +401,6 @@ export interface StoredMessage { /** Durable protocol receipt retained for the same live/history presentation. */ systemInfo?: { v: 1; payload: Record; fallbackCatId?: string }; a2aRouting?: { fromCatId?: string; targetCatId?: string; invocationId?: string }; - /** F264: derived browser projection; canonical truth remains queueCustody. */ - queueReceipt?: QueueMessageReceipt; /** F288 (K-1 plugin messaging): canonical plugin payload — the envelope is a pure * projection of this (single truth source). Strict shape owned by * domains/messaging/envelope.ts (PluginMessageExtra); kept structural here so the @@ -447,10 +451,6 @@ export interface StoredMessage { timelineOrderAt?: number; /** F117: Delivery lifecycle status. undefined = legacy (treated as delivered) */ deliveryStatus?: 'queued' | 'delivered' | 'canceled'; - /** F254 ADR-042: TTL-0 execution custody for this exact ordinary queued user message. */ - queueCustody?: QueuedMessageCustody; - /** Crash-recoverable A2A fan-out intent before the first complete custody CAS. */ - queueCustodyAdmission?: QueueCustodyAdmissionIntent; /** F264 Gap F: content-free terminal recall truth; body custody lives only in owner composer draft. */ recall?: MessageRecallMarker; /** F121: ID of the message this is replying to (same thread only) */ @@ -470,13 +470,35 @@ export interface StoredMessage { visibilitySeq?: number; } +export function assertMessageFromConsistent( + msg: Pick, +): asserts msg is typeof msg & { from: MessageFrom } { + if (!isMessageFrom(msg.from)) throw new Error('append requires one valid MessageFrom sender identity'); + const from = msg.from; + if (from.kind === 'user') { + if (from.userId !== msg.userId) throw new Error('MessageFrom userId must match the message owner userId'); + if (msg.source !== undefined) throw new Error('MessageFrom user must not carry connector presentation source'); + } else if (from.kind === 'agent') { + if (msg.catId !== from.catId) throw new Error('MessageFrom agent catId projection mismatch'); + if (msg.source !== undefined) throw new Error('MessageFrom agent must not carry connector presentation source'); + } else if (from.kind === 'external') { + if (msg.source && msg.source.connector !== from.connectorId) { + throw new Error('MessageFrom external connectorId must match connector presentation source'); + } + } else if (from.kind === 'plugin') { + if (msg.extra?.pluginMessage?.instanceId !== from.instanceId) { + throw new Error('MessageFrom plugin instanceId must match the plugin message payload'); + } + if (msg.source !== undefined) throw new Error('MessageFrom plugin must not carry connector presentation source'); + } + if (from.kind !== 'agent' && msg.catId !== null) { + throw new Error('only MessageFrom agent may project a catId'); + } +} + export type MessageAppendListener = (message: StoredMessage) => void; -/** - * The F278 inbox needs only the canonical source identity, marker body, ordering, - * and stream/cross-post provenance. Keep this separate from a full history message - * so read-side callers do not decode tool events or rich blocks they never inspect. - */ +/** Narrow source projection used by the F278 inbox verifier. */ export type PawFeelSourceMessageProjection = Pick< StoredMessage, | 'id' @@ -489,42 +511,26 @@ export type PawFeelSourceMessageProjection = Pick< | 'timelineOrderAt' | 'deliveryStatus' | 'origin' + | 'sourceParseFailure' | 'extra' > & { - /** - * Presence-only sentinels used to preserve legacy timeline fallback order. - * The source reader must never decode or expose the full connector/custody - * carrier merely to answer whether it exists. - */ source?: unknown; - queueCustody?: unknown; }; type PawFeelSourceExtra = Pick, 'stream' | 'crossPost'>; -function projectPawFeelSourceStream( - stream: NonNullable['stream'], -): PawFeelSourceExtra['stream'] | undefined { - if (!stream?.invocationId && !stream?.turnInvocationId) return undefined; - return { - ...(stream.invocationId ? { invocationId: stream.invocationId } : {}), - ...(stream.turnInvocationId ? { turnInvocationId: stream.turnInvocationId } : {}), - }; -} - -function projectPawFeelSourceCrossPost( - crossPost: NonNullable['crossPost'], -): PawFeelSourceExtra['crossPost'] | undefined { - return crossPost?.sourceThreadId ? { sourceThreadId: crossPost.sourceThreadId } : undefined; -} - function projectPawFeelSourceExtra(extra: StoredMessage['extra']): PawFeelSourceExtra | undefined { - const stream = projectPawFeelSourceStream(extra?.stream); - const crossPost = projectPawFeelSourceCrossPost(extra?.crossPost); + const stream = + extra?.stream?.invocationId || extra?.stream?.turnInvocationId + ? { + ...(extra.stream.invocationId ? { invocationId: extra.stream.invocationId } : {}), + ...(extra.stream.turnInvocationId ? { turnInvocationId: extra.stream.turnInvocationId } : {}), + } + : undefined; + const crossPost = extra?.crossPost?.sourceThreadId ? { sourceThreadId: extra.crossPost.sourceThreadId } : undefined; return stream || crossPost ? { ...(stream ? { stream } : {}), ...(crossPost ? { crossPost } : {}) } : undefined; } -/** Project a full in-memory message to exactly what the F278 source verifier consumes. */ export function projectPawFeelSourceMessage(message: StoredMessage): PawFeelSourceMessageProjection { const extra = projectPawFeelSourceExtra(message.extra); return { @@ -539,24 +545,15 @@ export function projectPawFeelSourceMessage(message: StoredMessage): PawFeelSour ...(message.deliveryStatus ? { deliveryStatus: message.deliveryStatus } : {}), ...(message.origin ? { origin: message.origin } : {}), ...(message.source !== undefined ? { source: true } : {}), - ...(message.queueCustody !== undefined ? { queueCustody: true } : {}), + ...(message.sourceParseFailure === true ? { sourceParseFailure: true } : {}), ...(extra ? { extra } : {}), }; } -/** Per-source outcome keeps an unavailable source row visible instead of turning a batch error into an empty success. */ export type PawFeelSourceProjectionRead = | { kind: 'available'; message: PawFeelSourceMessageProjection } | { kind: 'unavailable'; reason: 'not_found' | 'read_failed' }; -/** Content-free replay projection of the TTL=0 Queue custody carried by one message. */ -export interface QueueCustodyLifecycleRecord { - messageId: string; - threadId: string; - userId: string; - custody: QueuedMessageCustody; -} - /** * Result of markDelivered(). * @@ -569,35 +566,6 @@ export type MarkDeliveredResult = StoredMessage & { deliveryTransitioned: boolea /** Result of the atomic queued → canceled delivery transition. */ export type MarkCanceledResult = StoredMessage & { deliveryTransitioned: boolean }; -export interface CustodyOfferTransitionInput { - expectedSourceMessageRevision: string; - expectedOffer: CustodyOfferV1 | null; - nextOffer: CustodyOfferV1; -} - -export type CustodyOfferTransitionResult = - | { kind: 'updated'; message: StoredMessage } - | { kind: 'state_conflict'; currentOffer: CustodyOfferV1 | null } - | { kind: 'source_revision_mismatch' } - | { kind: 'invalid_state' } - | { kind: 'invalid_transition' } - | { kind: 'not_found' }; - -export type QueueCustodyTransitionResult = - | { kind: 'updated'; message: StoredMessage; deliveryTransitioned: boolean } - | { kind: 'revision_mismatch'; actualRevision: number } - | { kind: 'not_found' }; - -export type QueueCustodyInitializeResult = - | { kind: 'initialized'; message: StoredMessage } - | { kind: 'existing'; message: StoredMessage } - | { kind: 'not_found' } - | { kind: 'not_queued' }; - -export type QueueCustodyAdmissionInitializeResult = - | { kind: 'initialized' | 'existing'; message: StoredMessage } - | { kind: 'not_found' | 'not_queued' | 'conflict' }; - /** * Narrow recovery transition for a legacy timeline-visible carrier that was * persisted before Queue admission. Only an unclassified message with no @@ -627,7 +595,13 @@ export type StoredPluginMessage = NonNullable, - 'pluginMessage' | 'custodyOfferV1' | 'evolutionPreparationSubmissionV1' + | 'pluginMessage' + | 'custodyOfferV1' + | 'evolutionPreparationSubmissionV1' + | 'deliveryBoundary' + | 'collectiveOwnerAdmissionV1' + | 'collectiveWorkInvocationV1' + | 'collectiveAuthorizationInvalid' >; /** @@ -635,8 +609,17 @@ export type HostMessageExtra = Omit< */ export type AppendMessageInput = Omit< StoredMessage, - 'id' | 'threadId' | 'deliveredAt' | 'timelineOrderAt' | 'deliveryStatus' | 'recall' | 'sourceParseFailure' + | 'id' + | 'threadId' + | 'from' + | 'catId' + | 'deliveredAt' + | 'timelineOrderAt' + | 'deliveryStatus' + | 'recall' + | 'sourceParseFailure' > & { + from: MessageFrom; threadId?: string; /** Append may initialize only queued state; terminal delivery metadata belongs to transition methods. */ deliveryStatus?: 'queued'; @@ -647,68 +630,774 @@ export type AppendMessageInput = Omit< idempotencyKey?: string; }; +type CanonicalAppendMessageInput = AppendMessageInput & { + catId: CatId | null; +}; + +export function canonicalizeAppendMessageInput(input: AppendMessageInput): CanonicalAppendMessageInput { + const normalized = normalizeJsonUnicode(input); + assertValidAppendMessageInput(normalized); + if (!isMessageFrom(normalized.from)) { + throw new Error('append requires one valid MessageFrom sender identity'); + } + if ('catId' in normalized) { + throw new Error('append sender identity must use MessageFrom, not a catId projection'); + } + const canonical: CanonicalAppendMessageInput = { + ...normalized, + catId: normalized.from.kind === 'agent' ? (normalized.from.catId as CatId) : null, + }; + delete (canonical as unknown as { provenance?: unknown }).provenance; + assertMessageFromConsistent(canonical); + return canonical; +} + +export type QueueLedgerAdmissionFactory = (messageId: string) => readonly QueueLedgerEntry[]; + +export type QueueLedgerMessageAdmissionResult = + | { + outcome: 'enqueued'; + message: StoredMessage; + entries: QueueLedgerEntry[]; + deduped: boolean; + } + | { outcome: 'full' }; + +export type QueueLedgerLifecycleAdmissionResult = + | { + kind: 'applied' | 'replayed'; + message: StoredMessage; + entries: QueueLedgerEntry[]; + /** True only when the ledger rows already existed before this transaction. */ + ledgerReplayed: boolean; + } + | { kind: 'full' } + | Exclude; + +export function prepareLifecycleResponseTerminalWithLedgerTargets( + current: StoredMessage, + patch: LifecycleResponseTerminalPatch, + entries: readonly QueueLedgerEntry[], +): + | { + kind: 'prepared'; + message: StoredMessage; + entries: QueueLedgerEntry[]; + lifecycleReplayed: boolean; + } + | Exclude { + if (current.lifecycle?.kind !== 'response') { + return { kind: 'conflict', reason: 'not_response', message: structuredClone(current) }; + } + if (current.lifecycle.invocationId !== patch.invocationId) { + return { kind: 'conflict', reason: 'invocation_mismatch', message: structuredClone(current) }; + } + if ( + !Number.isFinite(patch.completedAt) || + patch.completedAt < current.lifecycle.startedAt || + (patch.reason !== undefined && patch.reason.length === 0) + ) { + return { kind: 'conflict', reason: 'invalid_terminal', message: structuredClone(current) }; + } + const dispatchedTargets = new Set(current.lifecycle.dispatchRefs?.map((ref) => ref.targetId) ?? []); + const pendingEntries = entries.flatMap((entry) => { + const targets = entry.targets.filter((targetId) => !dispatchedTargets.has(targetId)); + return targets.length > 0 ? [{ ...entry, targets }] : []; + }); + const targetIds = pendingEntries.flatMap((entry) => entry.targets); + const next = prepareLifecycleResponseTerminalMessage(current, patch); + const assigned = assignLifecycleDispatchTargetsMetadata( + next.lifecycle, + lifecycleInputIdentityForStoredMessage(next), + targetIds, + ); + if (assigned.kind === 'conflict') { + return { kind: 'conflict', reason: 'different_terminal', message: structuredClone(current) }; + } + next.lifecycle = assigned.lifecycle; + if (current.lifecycle.status === 'processing') { + return { kind: 'prepared', message: next, entries: pendingEntries, lifecycleReplayed: false }; + } + return isDeepStrictEqual(current, next) + ? { kind: 'prepared', message: next, entries: pendingEntries, lifecycleReplayed: true } + : { kind: 'conflict', reason: 'different_terminal', message: structuredClone(current) }; +} + +export function prepareQueueLedgerMessageAdmission( + message: AppendMessageInput, + messageId: string, + entries: readonly QueueLedgerEntry[], +): AppendMessageInput { + const threadId = message.threadId ?? DEFAULT_THREAD_ID; + const canonical = canonicalizeAppendMessageInput(message); + const storedIdentity: StoredMessage = { ...canonical, id: messageId, threadId }; + if ( + storedIdentity.deliveryStatus !== 'queued' && + !(storedIdentity.from?.kind === 'agent' && !storedIdentity.deliveryStatus) + ) { + throw new Error('atomic Message/Queue admission requires queued work or published Agent speech'); + } + return { ...message, lifecycle: prepareQueueLedgerSourceLifecycle(storedIdentity, entries) }; +} + +/** Prepare the History half of existing-source Queue admission without changing its visibility. */ +export function prepareQueueLedgerSourceLifecycle( + message: StoredMessage, + entries: readonly QueueLedgerEntry[], +): LifecycleStoredMessageMetadata { + const targetIds = entries.flatMap((entry) => entry.targets); + const dispatchedTargets = new Set(message.lifecycle?.dispatchRefs?.map((ref) => ref.targetId) ?? []); + if (targetIds.some((targetId) => dispatchedTargets.has(targetId))) { + throw new Error('atomic Message/Queue admission cannot replay an already dispatched source target'); + } + const assigned = assignLifecycleDispatchTargetsMetadata( + message.lifecycle, + lifecycleInputIdentityForStoredMessage(message), + targetIds, + ); + if (assigned.kind === 'conflict') { + throw new Error('atomic Message/Queue admission has conflicting lifecycle identity'); + } + return assigned.lifecycle; +} + +export interface LifecycleResponseTerminalPatch { + invocationId: string; + status: 'completed' | 'failed' | 'canceled' | 'interrupted'; + completedAt: number; + reason?: string; + content: string; + contentBlocks?: readonly MessageContent[]; + toolEvents?: readonly StoredToolEvent[]; + metadata?: MessageMetadata; + extra?: StoredMessage['extra']; + thinking?: string; + origin?: StoredMessage['origin']; + mentions: readonly CatId[]; + mentionsUser?: boolean; + replyTo?: string; +} + +export type CommitLifecycleResponseTerminalResult = + | { kind: 'applied' | 'replayed'; message: StoredMessage } + | { + kind: 'conflict'; + reason: 'not_response' | 'invocation_mismatch' | 'invalid_terminal' | 'different_terminal'; + message: StoredMessage; + } + | { kind: 'not_found' }; + +export type LifecycleInputDispatchPatch = { + orderKey: string; + producerInvocationId?: string; + targetId: string; + statusMessageId: string; +} & ({ phase: 'dispatched'; dispatchedAt: number } | { phase: 'settled' }); + +export type AdvanceLifecycleInputDispatchResult = + | { kind: 'applied' | 'replayed'; message: StoredMessage } + | { + kind: 'conflict'; + reason: 'not_input' | 'identity_mismatch' | 'invalid_transition' | 'status_message_mismatch' | 'duplicate_target'; + message: StoredMessage; + } + | { kind: 'not_found' }; + +export interface LifecycleAppendAdmissionInput { + threadId: string; + entryId: string; + inputMessageIds: readonly string[]; + runs: readonly { + targetId: string; + invocationId: string; + responseMessageId: string; + /** Exact time this source was admitted to the target response. */ + dispatchedAt: number; + }[]; +} + +export type CommitLifecycleAppendAdmissionResult = + | { kind: 'applied' | 'replayed'; messages: StoredMessage[] } + | { + kind: 'conflict'; + reason: 'invalid_input' | 'scope_mismatch' | 'input_lifecycle_conflict' | 'response_lifecycle_conflict'; + } + | { kind: 'not_found' }; + +export interface LifecycleAppendRejectionInput { + threadId: string; + entryId: string; + inputMessageIds: readonly string[]; + failureMessageIds: readonly string[]; + run: { + targetId: string; + invocationId: string; + responseMessageId: string; + }; +} + +export type CommitLifecycleAppendRejectionResult = + | { kind: 'applied' | 'replayed'; messages: StoredMessage[] } + | { kind: 'conflict'; reason: 'invalid_input' | 'scope_mismatch' | 'lifecycle_conflict' } + | { kind: 'not_found' }; + +type AssignLifecycleDispatchTargetsResult = + | { kind: 'applied'; lifecycle: LifecycleStoredMessageMetadata } + | { kind: 'replayed'; lifecycle: LifecycleStoredMessageMetadata } + | { kind: 'conflict' }; + +/** + * Ensure one source message has lifecycle identity without mirroring Queue + * routing intent into History. dispatchRefs are created only by actual delivery. + */ +export function assignLifecycleDispatchTargetsMetadata( + current: LifecycleStoredMessageMetadata | undefined, + identity: Pick, + targetIds: readonly string[], +): AssignLifecycleDispatchTargetsResult { + if (targetIds.some((targetId) => !targetId) || new Set(targetIds).size !== targetIds.length) { + return { kind: 'conflict' }; + } + if (!current) { + return { + kind: 'applied', + lifecycle: { + kind: 'input', + ...identity, + dispatchRefs: [], + }, + }; + } + // A response's terminal status describes its own execution. Once published, + // the same History message remains eligible to wake another member regardless + // of whether that execution completed, failed, was canceled, or was interrupted. + if (current.kind === 'delivery_failure') { + return { kind: 'conflict' }; + } + if (current.orderKey !== identity.orderKey || current.producerInvocationId !== identity.producerInvocationId) { + return { kind: 'conflict' }; + } + const refs = current.dispatchRefs ?? []; + return new Set(refs.map((ref) => ref.targetId)).size === refs.length + ? { kind: 'replayed', lifecycle: current } + : { kind: 'conflict' }; +} + +export interface LifecyclePreAdmissionFailureInput { + sourceMessageId: string; + expectedEntryId: string; + /** Scalar ledger target; empty only for a targetless conversation row. */ + requestedTargets: readonly string[]; + reason: LifecycleDeliveryFailureReason; + content: string; + contentBlocks?: readonly MessageContent[]; + failedAt: number; +} + +export type CommitLifecyclePreAdmissionFailureResult = + | { kind: 'applied' | 'replayed'; inputMessage: StoredMessage; failureMessage: StoredMessage } + | { + kind: 'conflict'; + reason: 'not_queued' | 'different_failure' | 'invalid_failure'; + inputMessage: StoredMessage; + failureMessage?: StoredMessage; + } + | { kind: 'not_found' }; + +export function preAdmissionFailureIdempotencyKey(entryId: string): string { + return `message-lifecycle:pre-admission-failure:${entryId}`; +} + +export function matchesLifecyclePreAdmissionFailure( + failureMessage: StoredMessage, + sourceMessage: StoredMessage, + input: LifecyclePreAdmissionFailureInput, +): boolean { + const lifecycle = failureMessage.lifecycle; + return ( + failureMessage.threadId === sourceMessage.threadId && + failureMessage.userId === 'system' && + failureMessage.from?.kind === 'system' && + failureMessage.from.service === 'message_delivery' && + failureMessage.content === input.content && + isDeepStrictEqual(failureMessage.contentBlocks, input.contentBlocks) && + lifecycle?.kind === 'delivery_failure' && + lifecycle.status === 'failed' && + lifecycle.sourceEntryId === input.expectedEntryId && + lifecycle.inputMessageId === sourceMessage.id && + isDeepStrictEqual(lifecycle.requestedTargets, input.requestedTargets) && + lifecycle.reason === input.reason && + lifecycle.createdAt === input.failedAt + ); +} + +/** + * A public agent wake may fail before a response bubble exists. In that case + * the delivery failure is the target's terminal status message, so the + * recipient ref is recorded directly as settled while preserving the source + * message's input/response identity. + */ +export function settleAssignedLifecycleDispatchFailureMetadata( + current: LifecycleStoredMessageMetadata | undefined, + targetIds: readonly string[], + failureMessageId: string, + dispatchedAt: number, +): LifecycleStoredMessageMetadata | null { + if ( + !isLifecycleDispatchableSource(current) || + !failureMessageId || + targetIds.length === 0 || + targetIds.some((targetId) => !targetId) || + new Set(targetIds).size !== targetIds.length + ) { + return null; + } + const requested = new Set(targetIds); + const refs = current.dispatchRefs ?? []; + if (new Set(refs.map((ref) => ref.targetId)).size !== refs.length) return null; + if (targetIds.some((targetId) => refs.some((ref) => ref.targetId === targetId))) return null; + return { + ...current, + dispatchRefs: [ + ...refs, + ...[...requested].map((targetId) => ({ + targetId, + phase: 'settled' as const, + statusMessageId: failureMessageId, + dispatchedAt, + })), + ], + }; +} + +type LifecycleInputDispatchMetadataResult = + | { kind: 'applied'; lifecycle: LifecycleStoredMessageMetadata } + | { kind: 'replayed' } + | { + kind: 'conflict'; + reason: Exclude['reason']; + }; + +function isLifecycleDispatchableSource( + lifecycle: LifecycleStoredMessageMetadata | undefined, +): lifecycle is Exclude { + return lifecycle?.kind === 'input' || lifecycle?.kind === 'response'; +} + +export function advanceLifecycleInputDispatchMetadata( + current: LifecycleStoredMessageMetadata | undefined, + patch: LifecycleInputDispatchPatch, +): LifecycleInputDispatchMetadataResult { + const identity = { + kind: 'input' as const, + orderKey: patch.orderKey, + ...(patch.producerInvocationId ? { producerInvocationId: patch.producerInvocationId } : {}), + }; + if (!current) { + if (patch.phase !== 'dispatched') return { kind: 'conflict', reason: 'invalid_transition' }; + return { + kind: 'applied', + lifecycle: { + ...identity, + dispatchRefs: [ + { + targetId: patch.targetId, + phase: 'dispatched', + statusMessageId: patch.statusMessageId, + dispatchedAt: patch.dispatchedAt, + }, + ], + }, + }; + } + if (!isLifecycleDispatchableSource(current)) { + return { kind: 'conflict', reason: 'not_input' }; + } + if (current.orderKey !== patch.orderKey || current.producerInvocationId !== patch.producerInvocationId) { + return { kind: 'conflict', reason: 'identity_mismatch' }; + } + const refs = current.dispatchRefs ?? []; + const matching = refs.filter((ref) => ref.targetId === patch.targetId); + if (matching.length > 1) return { kind: 'conflict', reason: 'duplicate_target' }; + const existing = matching[0]; + if (!existing) { + if (patch.phase !== 'dispatched') return { kind: 'conflict', reason: 'invalid_transition' }; + return { + kind: 'applied', + lifecycle: { + ...current, + dispatchRefs: [ + ...refs, + { + targetId: patch.targetId, + phase: 'dispatched', + statusMessageId: patch.statusMessageId, + dispatchedAt: patch.dispatchedAt, + }, + ], + }, + }; + } + if (existing.statusMessageId !== patch.statusMessageId) { + return { kind: 'conflict', reason: 'status_message_mismatch' }; + } else if (existing.phase === patch.phase) { + if ( + existing.phase === 'dispatched' && + patch.phase === 'dispatched' && + existing.dispatchedAt === undefined && + patch.dispatchedAt !== undefined + ) { + const nextRef: LifecycleDispatchRef = { ...existing, dispatchedAt: patch.dispatchedAt }; + return { + kind: 'applied', + lifecycle: { + ...current, + dispatchRefs: refs.map((ref) => (ref.targetId === patch.targetId ? nextRef : ref)), + }, + }; + } + return { kind: 'replayed' }; + } else if (existing.phase === 'settled' || patch.phase !== 'settled') { + return { kind: 'conflict', reason: 'invalid_transition' }; + } + const nextRef: LifecycleDispatchRef = { + targetId: patch.targetId, + phase: patch.phase, + statusMessageId: patch.statusMessageId, + ...(existing.dispatchedAt !== undefined ? { dispatchedAt: existing.dispatchedAt } : {}), + }; + return { + kind: 'applied', + lifecycle: { + ...current, + dispatchRefs: refs.map((ref) => (ref.targetId === patch.targetId ? nextRef : ref)), + }, + }; +} + +function appendLifecycleResponseInputsMetadata( + current: LifecycleStoredMessageMetadata | undefined, + input: Pick & { + targetId: string; + invocationId: string; + }, +): { kind: 'applied'; lifecycle: LifecycleStoredMessageMetadata } | { kind: 'replayed' } | { kind: 'conflict' } { + if ( + current?.kind !== 'response' || + current.status !== 'processing' || + current.targetId !== input.targetId || + current.invocationId !== input.invocationId + ) { + return { kind: 'conflict' }; + } + const hasEntry = current.inputEntryIds.includes(input.entryId); + const presentMessageIds = input.inputMessageIds.filter((messageId) => current.inputMessageIds.includes(messageId)); + if ( + (hasEntry && presentMessageIds.length !== input.inputMessageIds.length) || + (!hasEntry && presentMessageIds.length > 0) + ) { + return { kind: 'conflict' }; + } + if (hasEntry) return { kind: 'replayed' }; + return { + kind: 'applied', + lifecycle: { + ...current, + inputEntryIds: [...current.inputEntryIds, input.entryId], + inputMessageIds: [...current.inputMessageIds, ...input.inputMessageIds], + }, + }; +} + +export function prepareLifecycleAppendAdmission( + messages: readonly StoredMessage[], + input: LifecycleAppendAdmissionInput, +): + | { kind: 'prepared'; lifecycles: LifecycleStoredMessageMetadata[]; replayed: boolean } + | CommitLifecycleAppendAdmissionResult { + if ( + !input.threadId || + !input.entryId || + input.inputMessageIds.length === 0 || + new Set(input.inputMessageIds).size !== input.inputMessageIds.length || + input.runs.length === 0 || + input.runs.some( + (run) => !run.targetId || !run.invocationId || !run.responseMessageId || !Number.isFinite(run.dispatchedAt), + ) || + new Set(input.runs.map((run) => run.targetId)).size !== input.runs.length || + new Set(input.runs.map((run) => run.responseMessageId)).size !== input.runs.length || + new Set([...input.inputMessageIds, ...input.runs.map((run) => run.responseMessageId)]).size !== + input.inputMessageIds.length + input.runs.length + ) { + return { kind: 'conflict', reason: 'invalid_input' }; + } + if (messages.length !== input.inputMessageIds.length + input.runs.length) return { kind: 'not_found' }; + if (messages.some((message) => message.threadId !== input.threadId)) { + return { kind: 'conflict', reason: 'scope_mismatch' }; + } + + const lifecycles: LifecycleStoredMessageMetadata[] = []; + let replayed = true; + for (let index = 0; index < input.inputMessageIds.length; index += 1) { + const message = messages[index]!; + let lifecycle = message.lifecycle; + for (const [runIndex, run] of input.runs.entries()) { + const transition = advanceLifecycleInputDispatchMetadata(lifecycle, { + ...lifecycleInputIdentityForStoredMessage(message), + targetId: run.targetId, + phase: 'dispatched', + statusMessageId: run.responseMessageId, + dispatchedAt: run.dispatchedAt, + }); + if (transition.kind === 'conflict') return { kind: 'conflict', reason: 'input_lifecycle_conflict' }; + if (transition.kind === 'applied') { + lifecycle = transition.lifecycle; + replayed = false; + } + } + if (!lifecycle) return { kind: 'conflict', reason: 'input_lifecycle_conflict' }; + lifecycles.push(lifecycle); + } + for (let index = 0; index < input.runs.length; index += 1) { + const run = input.runs[index]!; + const message = messages[input.inputMessageIds.length + index]!; + const transition = appendLifecycleResponseInputsMetadata(message.lifecycle, { + entryId: input.entryId, + inputMessageIds: input.inputMessageIds, + targetId: run.targetId, + invocationId: run.invocationId, + }); + if (transition.kind === 'conflict') return { kind: 'conflict', reason: 'response_lifecycle_conflict' }; + if (transition.kind === 'applied') replayed = false; + lifecycles.push(transition.kind === 'applied' ? transition.lifecycle : message.lifecycle!); + } + return { kind: 'prepared', lifecycles, replayed }; +} + +export function prepareLifecycleAppendRejection( + messages: readonly StoredMessage[], + input: LifecycleAppendRejectionInput, +): + | { kind: 'prepared'; lifecycles: LifecycleStoredMessageMetadata[]; replayed: boolean } + | CommitLifecycleAppendRejectionResult { + if ( + !input.threadId || + !input.entryId || + !input.run.targetId || + !input.run.invocationId || + !input.run.responseMessageId || + input.inputMessageIds.length === 0 || + input.failureMessageIds.length !== input.inputMessageIds.length || + new Set(input.inputMessageIds).size !== input.inputMessageIds.length || + new Set(input.failureMessageIds).size !== input.failureMessageIds.length + ) { + return { kind: 'conflict', reason: 'invalid_input' }; + } + if (messages.length !== input.inputMessageIds.length + 1) return { kind: 'not_found' }; + if (messages.some((message) => message.threadId !== input.threadId)) { + return { kind: 'conflict', reason: 'scope_mismatch' }; + } + + const lifecycles: LifecycleStoredMessageMetadata[] = []; + let replayed = true; + for (let index = 0; index < input.inputMessageIds.length; index += 1) { + const lifecycle = messages[index]!.lifecycle; + if (!lifecycle || lifecycle.kind === 'delivery_failure') { + return { kind: 'conflict', reason: 'lifecycle_conflict' }; + } + const refs = lifecycle.dispatchRefs ?? []; + const matching = refs.filter((ref) => ref.targetId === input.run.targetId); + if (matching.length !== 1) return { kind: 'conflict', reason: 'lifecycle_conflict' }; + const current = matching[0]!; + const failureMessageId = input.failureMessageIds[index]!; + if (current.phase === 'settled' && current.statusMessageId === failureMessageId) { + lifecycles.push(lifecycle); + continue; + } + if (current.phase !== 'dispatched' || current.statusMessageId !== input.run.responseMessageId) { + return { kind: 'conflict', reason: 'lifecycle_conflict' }; + } + replayed = false; + lifecycles.push({ + ...lifecycle, + dispatchRefs: refs.map((ref) => + ref.targetId === input.run.targetId + ? { + targetId: input.run.targetId, + phase: 'settled' as const, + statusMessageId: failureMessageId, + dispatchedAt: ref.dispatchedAt, + } + : ref, + ), + }); + } + + const response = messages[input.inputMessageIds.length]!; + const responseLifecycle = response.lifecycle; + if ( + responseLifecycle?.kind !== 'response' || + responseLifecycle.targetId !== input.run.targetId || + responseLifecycle.invocationId !== input.run.invocationId + ) { + return { kind: 'conflict', reason: 'lifecycle_conflict' }; + } + const hasEntry = responseLifecycle.inputEntryIds.includes(input.entryId); + const presentMessageIds = input.inputMessageIds.filter((messageId) => + responseLifecycle.inputMessageIds.includes(messageId), + ); + if (!hasEntry && presentMessageIds.length === 0) { + lifecycles.push(responseLifecycle); + } else if (!hasEntry || presentMessageIds.length !== input.inputMessageIds.length) { + return { kind: 'conflict', reason: 'lifecycle_conflict' }; + } else { + replayed = false; + const rejectedIds = new Set(input.inputMessageIds); + lifecycles.push({ + ...responseLifecycle, + inputEntryIds: responseLifecycle.inputEntryIds.filter((entryId) => entryId !== input.entryId), + inputMessageIds: responseLifecycle.inputMessageIds.filter((messageId) => !rejectedIds.has(messageId)), + }); + } + return { kind: 'prepared', lifecycles, replayed }; +} + +export function lifecycleInputIdentityForStoredMessage( + message: StoredMessage, +): Pick { + messageFrom(message); + if (message.lifecycle) { + return { + orderKey: message.lifecycle.orderKey, + ...(message.lifecycle.producerInvocationId + ? { producerInvocationId: message.lifecycle.producerInvocationId } + : {}), + }; + } + const orderTime = message.timelineOrderAt ?? message.deliveredAt ?? message.timestamp; + const producerInvocationId = message.extra?.stream?.turnInvocationId ?? message.extra?.stream?.invocationId; + return { + orderKey: `${orderTime}:${message.id}`, + ...(producerInvocationId ? { producerInvocationId } : {}), + }; +} + +export async function commitLifecycleResponseFromAppendInput( + store: IMessageStore, + responseMessageId: string, + invocationId: string, + terminal: { + status: LifecycleResponseTerminalPatch['status']; + completedAt: number; + reason?: string; + }, + message: AppendMessageInput, +): Promise { + const current = await store.getById(responseMessageId); + if (!current) throw new Error(`lifecycle response not found: ${responseMessageId}`); + const terminalPatch = lifecycleResponseTerminalPatchFromAppendInput(current, invocationId, terminal, message); + const result = await store.commitLifecycleResponseTerminal(responseMessageId, terminalPatch); + if (result.kind !== 'applied' && result.kind !== 'replayed') { + throw new Error( + `lifecycle response terminal conflict: ${result.kind}:${'reason' in result ? result.reason : 'missing'}`, + ); + } + await settleLifecycleResponseInputs(store, result.message, responseMessageId); + return result.message; +} + +export async function settleLifecycleResponseInputs( + store: IMessageStore, + response: StoredMessage, + responseMessageId: string, +): Promise { + const lifecycle = response.lifecycle; + if (lifecycle?.kind === 'response') { + for (const inputMessageId of lifecycle.inputMessageIds) { + const inputMessage = await store.getById(inputMessageId); + if (!inputMessage || !isLifecycleDispatchableSource(inputMessage.lifecycle)) continue; + const targetRef = inputMessage.lifecycle.dispatchRefs?.find((ref) => ref.targetId === lifecycle.targetId); + if (!targetRef) continue; + const settled = await store.advanceLifecycleInputDispatch(inputMessageId, { + orderKey: inputMessage.lifecycle.orderKey, + ...(inputMessage.lifecycle.producerInvocationId + ? { producerInvocationId: inputMessage.lifecycle.producerInvocationId } + : {}), + targetId: lifecycle.targetId, + phase: 'settled', + statusMessageId: responseMessageId, + }); + if (settled.kind !== 'applied' && settled.kind !== 'replayed') { + throw new Error( + `lifecycle input settlement conflict: ${inputMessageId}:${settled.kind}:${'reason' in settled ? settled.reason : 'missing'}`, + ); + } + } + } +} + +export function lifecycleResponseTerminalPatchFromAppendInput( + current: StoredMessage, + invocationId: string, + terminal: { + status: LifecycleResponseTerminalPatch['status']; + completedAt: number; + reason?: string; + }, + message: AppendMessageInput, +): LifecycleResponseTerminalPatch { + const responseReplyTo = message.replyTo ?? current.replyTo; + return { + invocationId, + status: terminal.status, + completedAt: terminal.completedAt, + ...(terminal.reason ? { reason: terminal.reason } : {}), + content: message.content, + ...(message.contentBlocks ? { contentBlocks: message.contentBlocks } : {}), + ...(message.toolEvents ? { toolEvents: message.toolEvents } : {}), + ...(message.metadata ? { metadata: message.metadata } : {}), + extra: { ...current.extra, ...message.extra }, + ...(message.thinking ? { thinking: message.thinking } : {}), + ...(message.origin ? { origin: message.origin } : {}), + mentions: message.mentions, + ...(message.mentionsUser ? { mentionsUser: true } : {}), + ...(responseReplyTo ? { replyTo: responseReplyTo } : {}), + }; +} + /** * Enforce delivery lifecycle ownership for JavaScript callers that can bypass * the structural AppendMessageInput boundary. */ export function assertValidAppendDeliveryMetadata(msg: AppendMessageInput): void { const runtimeInput = msg as AppendMessageInput & - Partial>; + Partial> & + Record; if ( 'deliveredAt' in runtimeInput || 'timelineOrderAt' in runtimeInput || + // Reject the retired field as well: JavaScript callers must not resurrect + // append-time publication for an undelivered owner source. + 'timelinePublishedAtAppend' in runtimeInput || (runtimeInput.deliveryStatus !== undefined && runtimeInput.deliveryStatus !== 'queued') ) { throw new TypeError('append() delivery metadata is transition-owned; only queued status may be initialized'); } } -function assertValidPreparationSubmission(msg: AppendMessageInput): void { - const preparation = msg.extra?.evolutionPreparationSubmissionV1; - if (!preparation) return; - const parsed = evolutionPreparationSubmissionV1Schema.safeParse(preparation); - if (!parsed.success || msg.catId !== parsed.data.authorCatId) { - throw new TypeError('append() preparation submission requires its exact authenticated cat author'); - } - const { revision, ...draft } = parsed.data; - if (deriveEvolutionPreparationSubmissionRevision(draft) !== revision) { - throw new TypeError('append() preparation submission revision must match its canonical typed payload'); - } +export interface CustodyOfferTransitionInput { + expectedSourceMessageRevision: string; + expectedOffer: CustodyOfferV1 | null; + nextOffer: CustodyOfferV1; } -/** Validate every caller-controlled field that affects persistent message order. */ -export function assertValidAppendMessageInput(msg: AppendMessageInput): void { - assertValidAppendDeliveryMetadata(msg); - assertValidStoredMessageTimestamp(msg.timestamp); - const ownerAdmission = msg.extra?.collectiveOwnerAdmissionV1; - const workInvocation = msg.extra?.collectiveWorkInvocationV1; - if (ownerAdmission !== undefined || workInvocation !== undefined) { - if (msg.catId !== null || msg.source || msg.extra?.collectiveAuthorizationInvalid) - throw new TypeError('Collective owner receipts require a Host-owned user Message'); - if (ownerAdmission !== undefined) collectiveOwnerAdmissionV1Schema.parse(ownerAdmission); - if (workInvocation !== undefined) collectiveWorkInvocationV1Schema.parse(workInvocation); - } - if (msg.queueCustody?.executionScope === 'collective-participation') { - const source = collectiveSourceIdentitySchema.safeParse(msg.source?.meta?.participation); - if ( - msg.source?.connector !== 'collective' || - msg.catId !== null || - !source.success || - msg.queueCustody.allTargetCats[0] !== source.data.catId - ) - throw new TypeError('Public Queue scope requires an exact durable Collective source'); - } - if (msg.queueCustody?.executionScope === 'collective-work' && !workInvocation) - throw new TypeError('Private Queue scope requires an exact Task invocation'); - const custody = msg.extra?.custodyOfferV1; - if (custody) { - const parsed = custodyOfferV1Schema.safeParse(custody); - if (!parsed.success || parsed.data.sourceMessageRevision !== deriveGrowingSourceMessageRevision(msg)) { - throw new TypeError('append() custodyOfferV1 must match the canonical persisted source revision'); - } - } - assertValidPreparationSubmission(msg); -} +export type CustodyOfferTransitionResult = + | { kind: 'updated'; message: StoredMessage } + | { kind: 'state_conflict'; currentOffer: CustodyOfferV1 | null } + | { kind: 'source_revision_mismatch' } + | { kind: 'invalid_state' } + | { kind: 'invalid_transition' } + | { kind: 'not_found' }; export function canonicalGrowingSourceJson(value: unknown): string { if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value); @@ -716,7 +1405,7 @@ export function canonicalGrowingSourceJson(value: unknown): string { if (!Number.isFinite(value)) throw new TypeError('source revision rejects non-finite numbers'); return JSON.stringify(value); } - if (Array.isArray(value)) return `[${value.map(canonicalGrowingSourceJson).join(',')}]`; + if (Array.isArray(value)) return '[' + value.map(canonicalGrowingSourceJson).join(',') + ']'; if (typeof value === 'object') { const record = value as Record; return ( @@ -724,7 +1413,7 @@ export function canonicalGrowingSourceJson(value: unknown): string { Object.keys(record) .filter((key) => record[key] !== undefined) .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalGrowingSourceJson(record[key])}`) + .map((key) => JSON.stringify(key) + ':' + canonicalGrowingSourceJson(record[key])) .join(',') + '}' ); @@ -738,13 +1427,26 @@ export function deriveGrowingSourceMessageRevision(message: Pick, ): string { - return `sha256:${createHash('sha256').update(canonicalGrowingSourceJson(submission), 'utf8').digest('hex')}`; + return 'sha256:' + createHash('sha256').update(canonicalGrowingSourceJson(submission), 'utf8').digest('hex'); +} + +function assertValidPreparationSubmission(msg: AppendMessageInput): void { + const preparation = msg.extra?.evolutionPreparationSubmissionV1; + if (!preparation) return; + const parsed = evolutionPreparationSubmissionV1Schema.safeParse(preparation); + if (!parsed.success || msg.from.kind !== 'agent' || msg.from.catId !== parsed.data.authorCatId) { + throw new TypeError('append() preparation submission requires its exact authenticated cat author'); + } + const { revision, ...draft } = parsed.data; + if (deriveEvolutionPreparationSubmissionRevision(draft) !== revision) { + throw new TypeError('append() preparation submission revision must match its canonical typed payload'); + } } function custodyOffersEqual(left: CustodyOfferV1 | null, right: CustodyOfferV1 | null): boolean { @@ -814,6 +1516,42 @@ export function isValidCustodyOfferTransition( return false; } +/** Validate every caller-controlled field that affects persistent message order. */ +export function assertValidAppendMessageInput(msg: AppendMessageInput): void { + assertValidAppendDeliveryMetadata(msg); + assertValidStoredMessageTimestamp(msg.timestamp); + if (msg.lifecycle !== undefined && !isLifecycleStoredMessageMetadata(msg.lifecycle)) { + throw new TypeError('append() lifecycle metadata is invalid'); + } + const ownerAdmission = msg.extra?.collectiveOwnerAdmissionV1; + const workInvocation = msg.extra?.collectiveWorkInvocationV1; + if (ownerAdmission !== undefined) { + if (msg.from.kind !== 'user' || msg.source || msg.extra?.collectiveAuthorizationInvalid) { + throw new TypeError('Collective owner receipts require a Host-owned user Message'); + } + collectiveOwnerAdmissionV1Schema.parse(ownerAdmission); + } + if (workInvocation !== undefined) { + if ( + msg.from.kind !== 'system' || + msg.from.service !== 'collective-work' || + msg.source || + msg.extra?.collectiveAuthorizationInvalid + ) { + throw new TypeError('Collective Work invocations require the canonical system producer'); + } + collectiveWorkInvocationV1Schema.parse(workInvocation); + } + const custody = msg.extra?.custodyOfferV1; + if (custody) { + const parsed = custodyOfferV1Schema.safeParse(custody); + if (!parsed.success || parsed.data.sourceMessageRevision !== deriveGrowingSourceMessageRevision(msg)) { + throw new TypeError('append() custodyOfferV1 must match the canonical persisted source revision'); + } + } + assertValidPreparationSubmission(msg); +} + export type ThreadFrontierAppendResult = | { kind: 'committed'; message: StoredMessage } | { kind: 'frontier_advanced'; actualLatestMessageId: string | null }; @@ -883,7 +1621,7 @@ export function mergeMessageExtra( evolutionPreparationSubmissionV1: _stripPreparation, deliveryBoundary: _stripBoundary, collectiveOwnerAdmissionV1: _stripCollectiveAdmission, - collectiveWorkInvocationV1: _stripCollectiveInvocation, + collectiveWorkInvocationV1: _stripCollectiveWork, collectiveAuthorizationInvalid: _stripCollectiveInvalid, ...incomingHost } = incoming ?? {}; @@ -940,8 +1678,30 @@ export interface IMessageStore { /** F102 KD-34: Listener called after every successful append (fire-and-forget) */ onAppend?: MessageAppendListener; append(msg: AppendMessageInput): StoredMessage | Promise; - /** Atomically append or return the durable idempotency winner with an explicit outcome. */ + /** Atomically append by durable idempotency key and report whether an earlier writer won. */ appendIdempotent(msg: AppendMessageInput): IdempotentAppendResult | Promise; + /** Atomically persist one message and its complete ADR-043 Queue fan-out. */ + appendWithQueueLedgerAdmission( + msg: AppendMessageInput, + buildAdmission: QueueLedgerAdmissionFactory, + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): QueueLedgerMessageAdmissionResult | Promise; + /** Atomically bind an already-persisted connector message to its Queue fan-out. */ + enqueueExistingMessageWithQueueLedgerAdmission( + messageId: string, + entries: readonly QueueLedgerEntry[], + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): QueueLedgerMessageAdmissionResult | Promise; + /** Atomically terminalize one response bubble and admit every outbound Queue row it created. */ + commitLifecycleResponseTerminalWithQueueLedgerAdmission( + messageId: string, + patch: LifecycleResponseTerminalPatch, + entries: readonly QueueLedgerEntry[], + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): QueueLedgerLifecycleAdmissionResult | Promise; /** Raw latest message identity, including queued/canceled entries, for output-commit linearization. */ getLatestThreadMessageIdIncludingQueued(threadId: string): string | null | Promise; /** Resolve an already-committed idempotent append without creating a claim. */ @@ -961,10 +1721,7 @@ export interface IMessageStore { ): ThreadObservedAppendResult | Promise; /** Get a single message by its ID. Returns null if not found. */ getById(id: string): StoredMessage | null | Promise; - /** - * F278 source-only read path. Redis implementations may batch a narrow field - * projection; all requested ids must receive an available/unavailable outcome. - */ + /** F278 source-only read path. */ getPawFeelSourceProjections?( ids: readonly string[], ): Map | Promise>; @@ -1001,20 +1758,13 @@ export interface IMessageStore { sinceInclusive: number, untilInclusive: number, ): StoredMessage[] | Promise; - /** Bounded evidence read. hasMore is conservative over source index entries, never silently truncated. */ + /** Bounded evidence read. hasMore is conservative over source index entries. */ listOwnerMessageWindowSlice?( ownerUserId: string, sinceInclusive: number, untilInclusive: number, limit: number, ): { messages: StoredMessage[]; hasMore: boolean } | Promise<{ messages: StoredMessage[]; hasMore: boolean }>; - /** - * Enumerate content-free durable Queue custody for one owner. Replay filters - * append-only exposure/outcome timestamps into its selected window. - */ - listOwnerQueueCustodyLifecycles?( - ownerUserId: string, - ): QueueCustodyLifecycleRecord[] | Promise; getMentionsFor( catId: CatId, limit?: number, @@ -1048,12 +1798,6 @@ export interface IMessageStore { userId?: string, options?: ThreadMessageReadOptions, ): StoredMessage[] | Promise; - /** Content-free durable reverse index used to settle an exact exposed child after recall hides its source. */ - getByQueueExposure( - threadId: string, - targetCatId: string, - invocationId: string, - ): StoredMessage[] | Promise; /** * Optional storage-native batch projection for Sidebar unread badges. * Implementations that provide it must preserve getByThreadAfter visibility @@ -1108,29 +1852,36 @@ export interface IMessageStore { id: string, patch: StreamMetadataAugmentInput, ): StoredMessage | null | Promise; + /** Replace the processing response body and terminalize that exact invocation once. */ + commitLifecycleResponseTerminal( + id: string, + patch: LifecycleResponseTerminalPatch, + ): CommitLifecycleResponseTerminalResult | Promise; + /** Atomically close one unadmitted public input and append its adjacent failure result. */ + commitLifecyclePreAdmissionFailure( + input: LifecyclePreAdmissionFailureInput, + ): CommitLifecyclePreAdmissionFailureResult | Promise; + /** Monotonically bind one public input target to the exact response result. */ + advanceLifecycleInputDispatch( + id: string, + patch: LifecycleInputDispatchPatch, + ): AdvanceLifecycleInputDispatchResult | Promise; + /** Atomically attach one Queue input to every exact processing response bubble. */ + commitLifecycleAppendAdmission( + input: LifecycleAppendAdmissionInput, + ): CommitLifecycleAppendAdmissionResult | Promise; + /** Compensate a provider rejection without leaving the input attached to a response that never read it. */ + commitLifecycleAppendRejection( + input: LifecycleAppendRejectionInput, + ): CommitLifecycleAppendRejectionResult | Promise; /** * F098-D: CAS transition queued → delivered at an admitted timestamp. - * `deliveryTransitioned` is true only when this call won; false on a state/custody no-op. + * `deliveryTransitioned` is true only when this call won; false on a state no-op. * Returns null only when the message is not found. */ markDelivered(id: string, deliveredAt: number): MarkDeliveredResult | null | Promise; - /** Recover one exact legacy-visible carrier into queued state before custody initialization. */ + /** Recover one exact legacy-visible carrier into queued state before Queue admission. */ prepareQueueAdmission(id: string): QueueAdmissionPrepareResult | Promise; - /** Persist the complete fan-out recovery intent before staging any process-local Queue carrier. */ - initializeQueueCustodyAdmission( - id: string, - admission: QueueCustodyAdmissionIntent, - ): QueueCustodyAdmissionInitializeResult | Promise; - /** F254: atomically backfill custody on an existing legacy queued message. */ - initializeQueueCustody( - id: string, - custody: QueuedMessageCustody, - ): QueueCustodyInitializeResult | Promise; - /** F254: revision-fenced custody transition; terminal delivery is committed in the same operation. */ - transitionQueueCustody( - id: string, - input: QueueCustodyTransitionInput, - ): QueueCustodyTransitionResult | Promise; /** * F117: CAS transition queued → canceled (withdraw/clear). * `deliveryTransitioned` is true only when this call won and false on a state no-op. @@ -1264,10 +2015,59 @@ function redactRecalledMessage(message: StoredMessage, recall: MessageRecallMark } function isRecallableOwnerMessage(message: StoredMessage): boolean { - if (message.catId !== null || message._tombstone) return false; - return ( - Boolean(message.queueCustody) && (message.deliveryStatus === 'queued' || message.deliveryStatus === 'delivered') - ); + if (messageFrom(message).kind !== 'user' || message._tombstone) return false; + return message.deliveryStatus === 'queued'; +} + +function replaceOptionalField( + message: StoredMessage, + key: K, + value: StoredMessage[K] | undefined, +): void { + if (value === undefined) delete message[key]; + else message[key] = structuredClone(value); +} + +function applyLifecycleResponseTerminalPatch( + current: StoredMessage, + patch: LifecycleResponseTerminalPatch, + lifecycle: LifecycleStoredMessageMetadata, +): StoredMessage { + const next = structuredClone(current); + next.content = patch.content; + next.mentions = [...patch.mentions]; + next.lifecycle = structuredClone(lifecycle); + replaceOptionalField(next, 'contentBlocks', patch.contentBlocks); + replaceOptionalField(next, 'toolEvents', patch.toolEvents); + replaceOptionalField(next, 'metadata', patch.metadata); + replaceOptionalField(next, 'extra', patch.extra); + replaceOptionalField(next, 'thinking', patch.thinking); + replaceOptionalField(next, 'origin', patch.origin); + replaceOptionalField(next, 'mentionsUser', patch.mentionsUser); + replaceOptionalField(next, 'replyTo', patch.replyTo); + return next; +} + +export function prepareLifecycleResponseTerminalMessage( + current: StoredMessage, + patch: LifecycleResponseTerminalPatch, +): StoredMessage { + const { + status: _currentStatus, + completedAt: _currentCompletedAt, + reason: _currentReason, + ...lifecycleIdentity + } = current.lifecycle as Extract; + void _currentStatus; + void _currentCompletedAt; + void _currentReason; + const lifecycle: LifecycleStoredMessageMetadata = { + ...lifecycleIdentity, + status: patch.status, + completedAt: patch.completedAt, + ...(patch.reason === undefined ? {} : { reason: patch.reason }), + } satisfies LifecycleStoredMessageMetadata; + return applyLifecycleResponseTerminalPatch(current, patch, lifecycle); } export class MessageStore { @@ -1302,11 +2102,7 @@ export class MessageStore { private readonly visibilitySeq = new Map(); /** F264 Gap F: persistent-by-contract in-memory mirror (no TTL or eviction). */ private readonly ownerComposerDrafts = new Map(); - - constructor(options?: { - maxMessages?: number; - onAppend?: MessageAppendListener; - }) { + constructor(options?: { maxMessages?: number; onAppend?: MessageAppendListener }) { this.maxMessages = options?.maxMessages ?? MAX_MESSAGES; this.onAppend = options?.onAppend; } @@ -1326,13 +2122,160 @@ export class MessageStore { } } + /** Publish a bodyless processing placeholder only when its final body is durable. */ + private publishLifecycleResponse(message: StoredMessage): void { + if (this.visibilitySeq.has(message.id)) return; + this.visibilitySeqCounter = Math.max(this.visibilitySeqCounter + 1, Date.now()); + this.visibilitySeq.set(message.id, this.visibilitySeqCounter); + message.visibilitySeq = this.visibilitySeqCounter; + } + /** * Append a message to the store. Returns the stored message with generated id. */ append(msg: AppendMessageInput): StoredMessage { - const normalizedMessage = normalizeJsonUnicode(msg); - assertValidAppendMessageInput(normalizedMessage); - assertQueueCustodyMessageBinding(normalizedMessage); + return this.appendWithReservedId(msg); + } + + appendWithQueueLedgerAdmission( + msg: AppendMessageInput, + buildAdmission: QueueLedgerAdmissionFactory, + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): QueueLedgerMessageAdmissionResult { + if (!(ledgerStore instanceof InMemoryQueueLedgerStore)) { + throw new Error('memory message admission requires the matching in-memory Queue ledger'); + } + const threadId = msg.threadId ?? DEFAULT_THREAD_ID; + const existing = msg.idempotencyKey ? this.getByIdempotencyKey(msg.userId, threadId, msg.idempotencyKey) : null; + if (existing) { + const expected = [...buildAdmission(existing.id)]; + const persisted = expected.map((entry) => ledgerStore.getNow(entry.threadId, entry.id)); + if ( + persisted.some((entry) => entry === null) || + !persisted.every((entry, index) => queueLedgerAdmissionsMatch(entry!, expected[index]!)) + ) { + throw new Error(`Queue admission identity conflict for replayed message ${existing.id}`); + } + return { + outcome: 'enqueued', + message: existing, + entries: persisted.filter((entry): entry is QueueLedgerEntry => entry !== null), + deduped: true, + }; + } + + const messageId = generateSortableId(msg.timestamp); + const entries = [...buildAdmission(messageId)]; + const admitted = ledgerStore.enqueueNow(entries, maxQueuedUserEntries); + if (admitted.outcome === 'full') return { outcome: 'full' }; + if (admitted.outcome === 'conflict') { + throw new Error(`Queue admission identity conflict for message ${messageId}`); + } + try { + const prepared = prepareQueueLedgerMessageAdmission(msg, messageId, entries); + const message = this.appendWithReservedId(prepared, messageId); + return { + outcome: 'enqueued', + message, + entries: admitted.entries, + deduped: admitted.outcome === 'replayed', + }; + } catch (error) { + if (admitted.outcome === 'enqueued') ledgerStore.removeEnqueuedNow(admitted.entries); + throw error; + } + } + + enqueueExistingMessageWithQueueLedgerAdmission( + messageId: string, + entries: readonly QueueLedgerEntry[], + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): QueueLedgerMessageAdmissionResult { + if (!(ledgerStore instanceof InMemoryQueueLedgerStore)) { + throw new Error('memory message admission requires the matching in-memory Queue ledger'); + } + const message = this.messages.find((candidate) => candidate.id === messageId); + if (!message) throw new Error(`Queue source message does not exist: ${messageId}`); + const lifecycle = prepareQueueLedgerSourceLifecycle(message, entries); + const admitted = ledgerStore.enqueueNow(entries, maxQueuedUserEntries); + if (admitted.outcome === 'full') return { outcome: 'full' }; + if (admitted.outcome === 'conflict') { + throw new Error(`Queue admission identity conflict for existing message ${messageId}`); + } + if (admitted.outcome === 'replayed') { + message.lifecycle = structuredClone(lifecycle); + return { outcome: 'enqueued', message: { ...message }, entries: admitted.entries, deduped: true }; + } + const preservesPublishedAgent = message.deliveryStatus === undefined && messageFrom(message).kind === 'agent'; + if (message.deliveryStatus === 'delivered' || message.deliveryStatus === 'canceled') { + ledgerStore.removeEnqueuedNow(admitted.entries); + throw new Error(`Queue source message already has delivery ownership: ${messageId}`); + } + message.lifecycle = structuredClone(lifecycle); + if (!preservesPublishedAgent) message.deliveryStatus = 'queued'; + return { outcome: 'enqueued', message: { ...message }, entries: admitted.entries, deduped: false }; + } + + commitLifecycleResponseTerminalWithQueueLedgerAdmission( + messageId: string, + patch: LifecycleResponseTerminalPatch, + entries: readonly QueueLedgerEntry[], + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): QueueLedgerLifecycleAdmissionResult { + if (!(ledgerStore instanceof InMemoryQueueLedgerStore)) { + throw new Error('memory lifecycle admission requires the matching in-memory Queue ledger'); + } + if (entries.length === 0) throw new Error('lifecycle Queue admission requires at least one row'); + const index = this.messages.findIndex((message) => message.id === messageId); + if (index < 0) return { kind: 'not_found' }; + const current = this.messages[index]!; + if ( + entries.some( + (entry) => + entry.threadId !== current.threadId || + entry.payload.sourceRecordId !== messageId || + entry.payload.messageId !== messageId, + ) + ) { + throw new Error('lifecycle Queue admission row must bind its exact response message'); + } + const prepared = prepareLifecycleResponseTerminalWithLedgerTargets(current, patch, entries); + if (prepared.kind !== 'prepared') return prepared; + if (prepared.entries.length === 0) { + if (entries.some((entry) => ledgerStore.getNow(entry.threadId, entry.id) !== null)) { + return { kind: 'conflict', reason: 'different_terminal', message: structuredClone(current) }; + } + return { + kind: prepared.lifecycleReplayed ? 'replayed' : 'applied', + message: structuredClone(prepared.message), + entries: [], + ledgerReplayed: true, + }; + } + const admitted = ledgerStore.enqueueNow(prepared.entries, maxQueuedUserEntries); + if (admitted.outcome === 'full') return { kind: 'full' }; + if (admitted.outcome === 'conflict') { + return { kind: 'conflict', reason: 'different_terminal', message: structuredClone(current) }; + } + this.messages[index] = prepared.message; + this.publishLifecycleResponse(prepared.message); + return { + kind: prepared.lifecycleReplayed ? 'replayed' : 'applied', + message: structuredClone(prepared.message), + entries: admitted.entries, + ledgerReplayed: admitted.outcome === 'replayed', + }; + } + + private appendWithReservedId( + msg: AppendMessageInput, + reservedId?: string, + options?: { deferVisibility?: boolean }, + ): StoredMessage { + const normalizedMessage = canonicalizeAppendMessageInput(msg); const threadId = normalizedMessage.threadId ?? DEFAULT_THREAD_ID; const idempotencyIndexKey = this.buildIdempotencyIndexKey( normalizedMessage.userId, @@ -1354,11 +2297,7 @@ export class MessageStore { void idempotencyKey; const stored: StoredMessage = { ...payload, - ...(payload.queueCustody ? { queueCustody: cloneQueuedMessageCustody(payload.queueCustody) } : {}), - ...(payload.queueCustodyAdmission - ? { queueCustodyAdmission: cloneQueueCustodyAdmissionIntent(payload.queueCustodyAdmission) } - : {}), - id: generateSortableId(normalizedMessage.timestamp), + id: reservedId ?? generateSortableId(normalizedMessage.timestamp), threadId, }; this.messages.push(stored); @@ -1372,7 +2311,7 @@ export class MessageStore { // seq = max(counter+1, Date.now()) mirrors the Redis Lua allocator: max(hwm+1, serverTimeMs). // Uses server wall-clock (Date.now()), NOT the message payload timestamp — a far-future // payload timestamp must NEVER enter the allocator. (#1200 P1-A fix) - if (isTimelinePublishedFn(stored)) { + if (!options?.deferVisibility && isTimelinePublishedFn(stored)) { this.visibilitySeqCounter = Math.max(this.visibilitySeqCounter + 1, Date.now()); this.visibilitySeq.set(stored.id, this.visibilitySeqCounter); // #1200 P2-6: Inject visibilitySeq into returned message so callers @@ -1418,7 +2357,8 @@ export class MessageStore { getLatestThreadMessageIdIncludingQueued(threadId: string): string | null { for (let index = this.messages.length - 1; index >= 0; index -= 1) { - const message = this.messages[index]!; + const message = this.messages[index]; + if (!message) continue; if (message.threadId === threadId) return message.id; } return null; @@ -1464,13 +2404,16 @@ export class MessageStore { } } const priorFrontierMessageId = this.getLatestThreadMessageIdIncludingQueued(threadId); - const message = this.append({ + const observedMessage: AppendMessageInput = { ...normalizedMessage, extra: { ...normalizedMessage.extra, freshness: { kind: 'scan_pending', priorFrontierMessageId }, }, - }); + }; + const deferVisibility = + observedMessage.lifecycle?.kind === 'response' && observedMessage.lifecycle.status === 'processing'; + const message = this.appendWithReservedId(observedMessage, undefined, { deferVisibility }); return { kind: 'committed', message, priorFrontierMessageId, idempotent: false }; } @@ -1572,24 +2515,17 @@ export class MessageStore { const projection = buildRecallDraft(msg, input, existingDraft, actualDraftRevision); - const custody = msg.queueCustody; - const exactExposures = custody?.bodyExposures ? structuredClone(custody.bodyExposures) : []; - const wasExposed = exactExposures.length > 0 || (custody?.seenByCatIds.length ?? 0) > 0; - if (custody) { - msg.queueCustody = terminalizeRecalledQueueCustody(custody, input.recalledAt); - } redactRecalledMessage(msg, { version: 1, - exposure: wasExposed ? 'seen' : 'none', + exposure: 'none', recalledAt: input.recalledAt, - ...(exactExposures.length > 0 ? { exposures: exactExposures } : {}), }); - if (!wasExposed) this.visibilitySeq.delete(id); + this.visibilitySeq.delete(id); this.ownerComposerDrafts.set(draftKey, projection.draft); return { kind: 'recalled', - verdict: wasExposed ? 'exposed' : 'zero_exposure', + verdict: 'zero_exposure', message: structuredClone(msg), draft: structuredClone(projection.draft), insertedRange: projection.insertedRange, @@ -1635,28 +2571,6 @@ export class MessageStore { return { messages: messages.slice(0, limit), hasMore: messages.length > limit }; } - listOwnerQueueCustodyLifecycles(ownerUserId: string): QueueCustodyLifecycleRecord[] { - return this.messages.flatMap((message) => { - const custody = message.queueCustody; - if ( - message.userId !== ownerUserId || - message.deletedAt || - !custody || - (custody.ownerUserId !== undefined && custody.ownerUserId !== ownerUserId) - ) { - return []; - } - return [ - { - messageId: message.id, - threadId: message.threadId, - userId: message.userId, - custody: structuredClone(custody), - }, - ]; - }); - } - /** * Get mentions for a specific cat, ascending (oldest first after cursor). * #1200 §8.7 migration: scans visibility ordering, match-counted (collects @@ -1782,10 +2696,11 @@ export class MessageStore { const matches: StoredMessage[] = []; for (let i = this.messages.length - 1; i >= 0 && matches.length < n; i--) { - const msg = this.messages[i]!; + const msg = this.messages[i]; + if (!msg) continue; if (msg.threadId !== threadId) continue; if (msg.deletedAt) continue; - if (msg.deliveryStatus === 'canceled') continue; + if (msg.deliveryStatus === 'canceled' && msg.lifecycle?.kind !== 'response') continue; if (!passesManagedHoldViewerBoundary(msg, userId)) { continue; } @@ -1819,12 +2734,9 @@ export class MessageStore { // The canonical visibility index intentionally excludes queued user work. // A caller that explicitly requests those rows therefore needs the raw - // thread timeline domain. Mixing visibilitySeq with queued authoring time - // would skip exposed queued rows and break Memory/Redis parity. - if ( - options?.includeQueuedUserMessages === true || - options?.includeExposedQueuedUserMessagesForCatId !== undefined - ) { + // thread timeline domain. Mixing visibilitySeq with hidden queued authoring + // time would skip pending sources and break Memory/Redis parity. + if (options?.includeQueuedUserMessages === true) { const ordered = this.messages .filter((msg) => msg.threadId === threadId) .filter((msg) => !userId || msg.userId === userId || isSystemUserMessage(msg)) @@ -1862,12 +2774,9 @@ export class MessageStore { if (userId && msg.userId !== userId && !isSystemUserMessage(msg)) continue; if (!isVisible(msg)) continue; const seq = this.visibilitySeq.get(msg.id); - if (seq === undefined) { - // Published but not yet in visibility index (queued cat speech, pre-visibility era). - // Use timeline position as fallback seq for inclusion without breaking ordering. - visible.push({ msg, seq: getTimelineOrderTime(msg) }); - continue; - } + // The visibility index is the cursor-read truth. A lifecycle processing + // placeholder deliberately has no position until its terminal body commits. + if (seq === undefined) continue; visible.push({ msg: { ...msg, visibilitySeq: seq }, seq }); } @@ -1913,18 +2822,6 @@ export class MessageStore { return visible.slice(0, max).map((v) => v.msg); } - getByQueueExposure(threadId: string, targetCatId: string, invocationId: string): StoredMessage[] { - return this.messages - .filter( - (message) => - message.threadId === threadId && - message.queueCustody?.bodyExposures?.some( - (exposure) => exposure.targetCatId === targetCatId && exposure.invocationId === invocationId, - ), - ) - .map((message) => structuredClone(message)); - } - /** * #1200 §8.7: Get the latest visible cursor for a thread. * @@ -2100,7 +2997,9 @@ export class MessageStore { delete msg.toolEvents; delete msg.metadata; delete msg.extra; + delete msg.lifecycle; delete msg.thinking; + delete msg.from; msg.deletedAt = Date.now(); msg.deletedBy = deletedBy; msg._tombstone = true; @@ -2145,15 +3044,13 @@ export class MessageStore { updateExtra(id: string, extra: HostMessageExtra): StoredMessage | null { const msg = this.messages.find((m) => m.id === id); if (!msg || msg.recall || msg._tombstone) return null; - // Strip pluginMessage to prevent bypassing updatePluginMessage()'s - // revision check — matches Redis store behaviour (codex P2 fix). const { pluginMessage: _stripPlugin, custodyOfferV1: _stripCustody, evolutionPreparationSubmissionV1: _stripPreparation, deliveryBoundary: _stripBoundary, collectiveOwnerAdmissionV1: _stripCollectiveAdmission, - collectiveWorkInvocationV1: _stripCollectiveInvocation, + collectiveWorkInvocationV1: _stripCollectiveWork, collectiveAuthorizationInvalid: _stripCollectiveInvalid, ...hostOnly } = extra as Record; @@ -2200,6 +3097,229 @@ export class MessageStore { return applyStreamMetadataAugment(msg, patch); } + commitLifecycleResponseTerminal( + id: string, + patch: LifecycleResponseTerminalPatch, + ): CommitLifecycleResponseTerminalResult { + const index = this.messages.findIndex((message) => message.id === id); + if (index === -1) return { kind: 'not_found' }; + const msg = this.messages[index]!; + if (msg.lifecycle?.kind !== 'response') { + return { kind: 'conflict', reason: 'not_response', message: structuredClone(msg) }; + } + if (msg.lifecycle.invocationId !== patch.invocationId) { + return { kind: 'conflict', reason: 'invocation_mismatch', message: structuredClone(msg) }; + } + if ( + !Number.isFinite(patch.completedAt) || + patch.completedAt < msg.lifecycle.startedAt || + (patch.reason !== undefined && patch.reason.length === 0) + ) { + return { kind: 'conflict', reason: 'invalid_terminal', message: structuredClone(msg) }; + } + const expectedMessage = prepareLifecycleResponseTerminalMessage(msg, patch); + if (msg.lifecycle.status !== 'processing') { + if (!isDeepStrictEqual(msg, expectedMessage)) { + return { kind: 'conflict', reason: 'different_terminal', message: structuredClone(msg) }; + } + this.publishLifecycleResponse(msg); + return { kind: 'replayed', message: structuredClone(msg) }; + } + this.messages[index] = expectedMessage; + this.publishLifecycleResponse(expectedMessage); + return { kind: 'applied', message: structuredClone(expectedMessage) }; + } + + commitLifecyclePreAdmissionFailure( + input: LifecyclePreAdmissionFailureInput, + ): CommitLifecyclePreAdmissionFailureResult { + const index = this.messages.findIndex((message) => message.id === input.sourceMessageId); + if (index === -1) return { kind: 'not_found' }; + const source = this.messages[index]!; + const idempotencyKey = preAdmissionFailureIdempotencyKey(input.expectedEntryId); + const existingFailure = this.getByIdempotencyKey('system', source.threadId, idempotencyKey); + if (existingFailure) { + if (!matchesLifecyclePreAdmissionFailure(existingFailure, source, input)) { + return { + kind: 'conflict', + reason: 'different_failure', + inputMessage: structuredClone(source), + failureMessage: existingFailure, + }; + } + const targetProjectionReplayed = input.requestedTargets.every((targetId) => + source.lifecycle?.dispatchRefs?.some( + (ref) => ref.targetId === targetId && ref.phase === 'settled' && ref.statusMessageId === existingFailure.id, + ), + ); + const queuedInputReplayed = + source.deliveryStatus === 'delivered' && + source.lifecycle?.kind === 'input' && + (input.requestedTargets.length === 0 || targetProjectionReplayed); + const publicWakeReplayed = + source.deliveryStatus === undefined && + messageFrom(source).kind === 'agent' && + source.lifecycle?.kind !== 'delivery_failure' && + source.lifecycle?.dispatchRefs !== undefined && + targetProjectionReplayed; + if (!queuedInputReplayed && !publicWakeReplayed) { + return { + kind: 'conflict', + reason: 'invalid_failure', + inputMessage: structuredClone(source), + failureMessage: existingFailure, + }; + } + return { + kind: 'replayed', + inputMessage: structuredClone(source), + failureMessage: existingFailure, + }; + } + + const isQueuedInput = source.deliveryStatus === 'queued'; + const isPublicAgentWake = + source.deliveryStatus === undefined && + messageFrom(source).kind === 'agent' && + source.visibility !== 'whisper' && + source.lifecycle?.kind !== 'delivery_failure'; + const uniqueTargets = new Set(input.requestedTargets); + if (!isQueuedInput && !isPublicAgentWake) { + return { kind: 'conflict', reason: 'not_queued', inputMessage: structuredClone(source) }; + } + const inputIdentity = lifecycleInputIdentityForStoredMessage(source); + const assigned = assignLifecycleDispatchTargetsMetadata(source.lifecycle, inputIdentity, input.requestedTargets); + const failureLifecycle: LifecycleStoredMessageMetadata = { + kind: 'delivery_failure', + orderKey: `${input.failedAt}:${source.id}:failure`, + status: 'failed', + sourceEntryId: input.expectedEntryId, + inputMessageId: source.id, + requestedTargets: [...input.requestedTargets], + reason: input.reason, + createdAt: input.failedAt, + }; + if ( + !Number.isInteger(input.failedAt) || + input.failedAt < source.timestamp || + input.requestedTargets.some((target) => typeof target !== 'string' || target.length === 0) || + uniqueTargets.size !== input.requestedTargets.length || + input.requestedTargets.length > 1 || + (isPublicAgentWake && input.requestedTargets.length === 0) || + assigned.kind === 'conflict' || + !isLifecycleStoredMessageMetadata(failureLifecycle) + ) { + return { kind: 'conflict', reason: 'invalid_failure', inputMessage: structuredClone(source) }; + } + + const failureInput: AppendMessageInput = { + from: { kind: 'system', service: 'message_delivery' }, + userId: 'system', + threadId: source.threadId, + content: input.content, + ...(input.contentBlocks ? { contentBlocks: input.contentBlocks } : {}), + mentions: [], + timestamp: input.failedAt, + lifecycle: failureLifecycle, + idempotencyKey, + }; + try { + assertValidAppendMessageInput(failureInput); + } catch { + return { kind: 'conflict', reason: 'invalid_failure', inputMessage: structuredClone(source) }; + } + + const failureMessage = this.append(failureInput); + const settledLifecycle = + input.requestedTargets.length === 0 + ? assigned.lifecycle + : settleAssignedLifecycleDispatchFailureMetadata( + assigned.lifecycle, + input.requestedTargets, + failureMessage.id, + input.failedAt, + ); + if (!settledLifecycle) { + const failureIndex = this.messages.findIndex((message) => message.id === failureMessage.id); + if (failureIndex !== -1) this.messages.splice(failureIndex, 1); + this.pruneIdempotencyIndexForMessageIds([failureMessage.id]); + this.visibilitySeq.delete(failureMessage.id); + return { kind: 'conflict', reason: 'invalid_failure', inputMessage: structuredClone(source) }; + } + source.lifecycle = structuredClone(settledLifecycle); + if (isPublicAgentWake) { + return { + kind: 'applied', + inputMessage: structuredClone(source), + failureMessage: structuredClone(failureMessage), + }; + } + + source.timelineOrderAt = input.failedAt; + source.deliveredAt = input.failedAt; + source.deliveryStatus = 'delivered'; + if (!this.visibilitySeq.has(source.id)) { + this.visibilitySeqCounter = Math.max(this.visibilitySeqCounter + 1, Date.now()); + this.visibilitySeq.set(source.id, this.visibilitySeqCounter); + source.visibilitySeq = this.visibilitySeqCounter; + } + + if (failureMessage.lifecycle?.kind !== 'delivery_failure') { + throw new Error(`pre-admission failure append lost lifecycle identity: ${failureMessage.id}`); + } + return { + kind: 'applied', + inputMessage: structuredClone(source), + failureMessage: structuredClone(failureMessage), + }; + } + + advanceLifecycleInputDispatch(id: string, patch: LifecycleInputDispatchPatch): AdvanceLifecycleInputDispatchResult { + const index = this.messages.findIndex((message) => message.id === id); + if (index === -1) return { kind: 'not_found' }; + const message = this.messages[index]!; + if (message.recall || message._tombstone) { + return { kind: 'conflict', reason: 'not_input', message: structuredClone(message) }; + } + const transition = advanceLifecycleInputDispatchMetadata(message.lifecycle, patch); + if (transition.kind === 'conflict') { + return { ...transition, message: structuredClone(message) }; + } + if (transition.kind === 'replayed') return { kind: 'replayed', message: structuredClone(message) }; + message.lifecycle = structuredClone(transition.lifecycle); + return { kind: 'applied', message: structuredClone(message) }; + } + + commitLifecycleAppendAdmission(input: LifecycleAppendAdmissionInput): CommitLifecycleAppendAdmissionResult { + const ids = [...input.inputMessageIds, ...input.runs.map((run) => run.responseMessageId)]; + const messages = ids.map((id) => this.messages.find((message) => message.id === id)); + if (messages.some((message) => !message)) return { kind: 'not_found' }; + const prepared = prepareLifecycleAppendAdmission(messages as StoredMessage[], input); + if (prepared.kind !== 'prepared') return prepared; + if (prepared.replayed) { + return { kind: 'replayed', messages: (messages as StoredMessage[]).map((message) => structuredClone(message)) }; + } + for (let index = 0; index < messages.length; index += 1) { + messages[index]!.lifecycle = structuredClone(prepared.lifecycles[index]!); + } + return { kind: 'applied', messages: (messages as StoredMessage[]).map((message) => structuredClone(message)) }; + } + + commitLifecycleAppendRejection(input: LifecycleAppendRejectionInput): CommitLifecycleAppendRejectionResult { + const ids = [...input.inputMessageIds, input.run.responseMessageId]; + const messages = ids.map((id) => this.messages.find((message) => message.id === id)); + if (messages.some((message) => !message)) return { kind: 'not_found' }; + const prepared = prepareLifecycleAppendRejection(messages as StoredMessage[], input); + if (prepared.kind !== 'prepared') return prepared; + if (prepared.replayed) { + return { kind: 'replayed', messages: (messages as StoredMessage[]).map((message) => structuredClone(message)) }; + } + for (let index = 0; index < messages.length; index += 1) { + messages[index]!.lifecycle = structuredClone(prepared.lifecycles[index]!); + } + return { kind: 'applied', messages: (messages as StoredMessage[]).map((message) => structuredClone(message)) }; + } + /** * F098-D: Mark a queued message as delivered (set deliveredAt timestamp). */ @@ -2208,10 +3328,6 @@ export class MessageStore { const msg = this.messages.find((m) => m.id === id); if (!msg) return null; if (msg.deliveryStatus !== 'queued') return { ...msg, deliveryTransitioned: false }; // only transition queued → delivered - if (msg.queueCustodyAdmission) return { ...msg, deliveryTransitioned: false }; - if (msg.queueCustody && msg.queueCustody.status !== 'terminal') { - return { ...msg, deliveryTransitioned: false }; - } msg.timelineOrderAt = resolveDeliveryTimelineScore(msg, deliveredAt); msg.deliveredAt = deliveredAt; msg.deliveryStatus = 'delivered'; @@ -2231,87 +3347,21 @@ export class MessageStore { const msg = this.messages.find((message) => message.id === id); if (!msg) return { kind: 'not_found' }; if (msg.deliveryStatus === 'queued') return { kind: 'existing', message: { ...msg } }; - if (msg.deliveryStatus !== undefined || msg.queueCustody) return { kind: 'conflict' }; + if (msg.deliveryStatus !== undefined) return { kind: 'conflict' }; msg.deliveryStatus = 'queued'; return { kind: 'prepared', message: { ...msg } }; } - initializeQueueCustodyAdmission( - id: string, - admission: QueueCustodyAdmissionIntent, - ): QueueCustodyAdmissionInitializeResult { - const msg = this.messages.find((message) => message.id === id); - if (!msg) return { kind: 'not_found' }; - if (msg.deliveryStatus !== 'queued') return { kind: 'not_queued' }; - if (msg.queueCustody) return { kind: 'conflict' }; - if (msg.queueCustodyAdmission) { - return queueCustodyAdmissionIntentsMatch(msg.queueCustodyAdmission, admission) - ? { kind: 'existing', message: { ...msg } } - : { kind: 'conflict' }; - } - assertQueueCustodyMessageBinding({ deliveryStatus: msg.deliveryStatus, queueCustodyAdmission: admission }); - msg.queueCustodyAdmission = cloneQueueCustodyAdmissionIntent(admission); - return { kind: 'initialized', message: { ...msg } }; - } - - initializeQueueCustody(id: string, custody: QueuedMessageCustody): QueueCustodyInitializeResult { - const msg = this.messages.find((message) => message.id === id); - if (!msg) return { kind: 'not_found' }; - if (msg.queueCustody) return { kind: 'existing', message: { ...msg } }; - if (msg.deliveryStatus !== 'queued') return { kind: 'not_queued' }; - assertQueueCustodyMessageBinding({ deliveryStatus: msg.deliveryStatus, queueCustody: custody }); - msg.queueCustody = cloneQueuedMessageCustody(custody); - delete msg.queueCustodyAdmission; - return { kind: 'initialized', message: { ...msg } }; - } - - transitionQueueCustody(id: string, input: QueueCustodyTransitionInput): QueueCustodyTransitionResult { - if (input.deliveredAt !== undefined) assertValidStoredMessageTimestamp(input.deliveredAt); - const msg = this.messages.find((message) => message.id === id); - if (!msg?.queueCustody) return { kind: 'not_found' }; - if (msg.queueCustody.revision !== input.expectedRevision) { - return { kind: 'revision_mismatch', actualRevision: msg.queueCustody.revision }; - } - const isExposedRecallSettlement = - msg.deliveryStatus === 'canceled' && - msg.recall?.exposure === 'seen' && - msg.queueCustody.status === 'terminal' && - input.next.status === 'terminal' && - input.deliveredAt === undefined; - if (msg.deliveryStatus !== 'queued' && !isExposedRecallSettlement) { - throw new Error('queue custody transition requires a queued message or exposed recall tombstone'); - } - if (input.replacement && input.replacement.sourceMessageId !== id) { - throw new Error('queue custody replacement proof source message mismatch'); - } - assertQueueCustodyTransition(msg.queueCustody, input); - assertTypedWaitCustodyBindings(msg, input.next, input.waitContinuationGuards); - assertMemoryTypedWaitCustody(input.waitContinuationGuards); - msg.queueCustody = cloneQueuedMessageCustody(input.next); - if (input.deliveredAt !== undefined) { - msg.timelineOrderAt = resolveDeliveryTimelineScore(msg, input.deliveredAt); - msg.deliveryStatus = 'delivered'; - msg.deliveredAt = input.deliveredAt; - // #1269 P1-2: allocate visibilitySeq when hidden queued work becomes visible. - // Same guard as markDelivered: preserve existing position, allocate only when missing. - if (!this.visibilitySeq.has(id)) { - this.visibilitySeqCounter = Math.max(this.visibilitySeqCounter + 1, Date.now()); - this.visibilitySeq.set(id, this.visibilitySeqCounter); - } - } - return { kind: 'updated', message: { ...msg }, deliveryTransitioned: input.deliveredAt !== undefined }; - } - /** F117: CAS transition queued → canceled; non-queued messages return an applied=false receipt. */ markCanceled(id: string): MarkCanceledResult | null { const msg = this.messages.find((m) => m.id === id); if (!msg) return null; if (msg.deliveryStatus !== 'queued') return { ...msg, deliveryTransitioned: false }; msg.deliveryStatus = 'canceled'; - // #1200: Remove from visibility index if present (backfill parity with Redis CANCEL_WITH_VISIBILITY_LUA) - this.visibilitySeq.delete(id); - delete msg.queueCustody; - delete msg.queueCustodyAdmission; + // A canceled response is the member-owned terminal surface. Queued source + // work remains hidden, but removing a response from the visibility index + // would make it disappear on history reload for every other participant. + if (msg.lifecycle?.kind !== 'response') this.visibilitySeq.delete(id); return { ...msg, deliveryTransitioned: true }; } @@ -2425,10 +3475,12 @@ export async function hydrateCrossThreadReplyHint( ?.coordination; const coordination = trigger.extra?.coordination ?? legacyCoordination; if (!hasCrossThreadProvenance && !coordination) return null; - if (!trigger.catId) return null; // user-authored messages have no catId — not a cross-thread relay + const triggerFrom = messageFrom(trigger); + const senderCatId = triggerFrom.kind === 'agent' ? triggerFrom.catId : null; + if (!senderCatId) return null; return { - sourceThreadId: hasCrossThreadProvenance ? crossPost!.sourceThreadId : trigger.threadId, - senderCatId: trigger.catId, + sourceThreadId: hasCrossThreadProvenance && crossPost?.sourceThreadId ? crossPost.sourceThreadId : trigger.threadId, + senderCatId: senderCatId as CatId, ...(hasCrossThreadProvenance && crossPost?.effectClass ? { effectClass: crossPost.effectClass } : {}), ...(coordination ? { coordination } : {}), }; diff --git a/packages/api/src/domains/cats/services/stores/ports/queued-message-custody.ts b/packages/api/src/domains/cats/services/stores/ports/queued-message-custody.ts deleted file mode 100644 index 6c105e77b0..0000000000 --- a/packages/api/src/domains/cats/services/stores/ports/queued-message-custody.ts +++ /dev/null @@ -1,1369 +0,0 @@ -import type { - CatId, - QueueAuthorIntent, - QueueReminderAttempt, - QueueTargetAttempt, - QueueTargetOutcome, -} from '@cat-cafe/shared'; -import { - type ActionSuccessorFence, - actionSuccessorFencesMatch, -} from '../../../../ball-custody/ActionSuccessorAdmissionContract.js'; -import type { TypedWaitCustodyGuard } from '../../../../ball-custody/TypedWaitCustodyGuard.js'; -import { normalizeOwnerAuthProvenance, type OwnerAuthProvenance } from '../../owner-auth-provenance.js'; - -export type QueuedMessageCustodyStatus = 'queued' | 'processing' | 'terminal'; - -export interface QueueBodyExposure { - targetCatId: string; - invocationId: string; - seenAt: number; -} - -export interface QueueTargetCarrierBinding { - entryId: string; - /** Immutable Queue admission identity, including action-successor generation. */ - idempotencyKey?: string; - /** Durable action authority; passive restart must not downgrade this carrier. */ - actionSuccessorFence?: ActionSuccessorFence; - source: 'agent'; - sourceCategory: 'a2a'; - callerCatId?: string; - a2aParentInvocationId?: string; - a2aTriggerMessageId: string; - autoExecute: true; - createdAt: number; -} - -export interface QueueTargetCarrierState { - status: 'queued' | 'processing'; - processingStartedAt?: number; -} - -/** - * Durable pre-CAS intent for one persisted A2A fan-out. - * - * Queue carriers are process-local until full custody is initialized. This - * record is written after target policy is decided but before any carrier is - * staged. `targetCats` is therefore the accepted subset startup may rebuild; - * `requestedTargetCats` also preserves rejected targets so full custody can - * terminalize them without making them selectable. Once full custody exists it - * atomically replaces this intent as canonical truth. - */ -export interface QueueCustodyAdmissionIntent { - version: 1; - admissionId: string; - ownerUserId: string; - ownerAuthProvenance: OwnerAuthProvenance; - intent: string; - /** Targets admitted by depth/dedup/ping-pong policy and safe to reconstruct. */ - targetCats: CatId[]; - /** Complete requested fan-out; absent on v1 records written before accepted-subset persistence. */ - requestedTargetCats?: CatId[]; - callerCatId?: CatId; - a2aParentInvocationId?: string; - receiptScope?: 'primary_trigger' | 'cross_thread_delivery'; - actionSuccessorFence?: ActionSuccessorFence; - priority: 'urgent' | 'normal'; - createdAt: number; -} - -/** - * Durable exact-group fence written before pre-start supersession begins. - * A surviving member is sufficient to keep the complete group non-dispatchable - * after process restart while idempotent retirement finishes. - */ -export interface QueuePrestartRetirementIntent { - id: string; - primaryEntryId: string; - entryIds: string[]; - targetCatId: string; - startedAt: number; -} - -export interface QueuedMessageCustody { - version: 1; - entryId: string; - revision: number; - /** Durable Queue owner principal. Automated message authorship is provenance and may differ. */ - ownerUserId?: string; - /** Immutable server-derived authentication fact. Missing only on legacy records. */ - ownerAuthProvenance?: OwnerAuthProvenance; - executionScope?: 'collective-participation' | 'collective-work'; - /** The message started this invocation; keep custody but suppress the work-period timeline dock. */ - receiptScope?: 'primary_trigger' | 'cross_thread_delivery'; - /** F264: per-target author-declared work disposition. Missing legacy records mean next_work. */ - authorIntentByCatId?: Record; - /** - * A cross-thread callback message can dispatch one independent A2A Queue - * carrier per target. This immutable map binds each target receipt to the - * exact carrier without cloning the durable message body. - */ - carrierByTargetCatId?: Record; - /** - * Mutable target-local runtime state. Cross-thread targets use their immutable - * carrier binding; standard targets share the immutable top-level entryId. - */ - carrierStateByTargetCatId?: Record; - intent: string; - status: QueuedMessageCustodyStatus; - allTargetCats: CatId[]; - pendingTargetCats: CatId[]; - notifiedByCatIds: CatId[]; - /** Exact child creation witness, persisted before prompt body exposure. */ - awakenedInvocationIdByCatId?: Record; - awakenedAtByCatId?: Record; - seenByCatIds: CatId[]; - seenInvocationIdByCatId: Record; - /** Append-only exact prompt-body exposure history; message id is the enclosing custody record. */ - bodyExposures?: QueueBodyExposure[]; - /** F1308: append-only target delivery attempts; retries always append. */ - targetAttempts?: QueueTargetAttempt[]; - failedByCatIds: CatId[]; - /** Author-cleared targets: terminal for Queue actionability, preserved in owner history. */ - withdrawnByCatIds?: CatId[]; - withdrawnAtByCatId?: Record; - /** Exact completed action fence that made a withdrawn target permanently non-retryable. */ - actionSuccessorTerminalFenceByTargetCatId?: Record; - handledByCatIds: CatId[]; - /** F264: exact successful invocation evidence; absent on legacy handled custody. */ - targetOutcomeByCatId?: Record; - /** F264: exact Steer replacement fence while the selected message is running. */ - steeredInvocationIdByCatId?: Record; - /** F264: Steer was accepted but the replacement invocation has not bound its id yet. */ - steerRequestedByCatIds?: CatId[]; - /** Exact pre-start group currently being durably retired. */ - prestartRetirement?: QueuePrestartRetirementIntent; - /** F264: append-only per-message reminder attempts, terminalized by the exact invocation. */ - reminderAttempts?: QueueReminderAttempt[]; - priority: 'urgent' | 'normal'; - position?: number; - processingStartedAt?: number; - createdAt: number; - updatedAt: number; -} - -export interface QueueCustodyTransitionInput { - /** Transient exact Task authority, consumed at the same boundary as this Queue CAS. */ - waitContinuationGuards?: readonly TypedWaitCustodyGuard[]; - expectedRevision: number; - next: QueuedMessageCustody; - deliveredAt?: number; - /** Exact action-authority adoption/advance for an otherwise immutable carrier. */ - actionSuccessorRebind?: QueueCustodyActionSuccessorRebindProof; - /** - * Gate 2: non-persisted proof that one exact queued source is moving from an - * absent carrier to its verified replacement. Ordinary transitions may not - * change entryId. - */ - replacement?: QueueCustodyReplacementProof; -} - -export interface QueueCustodyActionSuccessorRebindProof { - readonly kind: 'verified_action_successor'; - readonly entryId: string; - readonly targetCatIds: CatId[]; - readonly fence: ActionSuccessorFence; -} - -export interface QueueCustodyReplacementProof { - readonly kind: 'verified'; - readonly previousEntryId: string; - readonly replacementEntryId: string; - readonly sourceMessageId: string; -} - -function assertFiniteNonNegative(value: number, field: string): void { - if (!Number.isFinite(value) || value < 0) throw new Error(`${field} must be a finite non-negative number`); -} - -function assertUniqueTargets(values: readonly string[], field: string): void { - if (values.some((value) => typeof value !== 'string' || value.length === 0)) { - throw new Error(`${field} must contain non-empty cat ids`); - } - if (new Set(values).size !== values.length) throw new Error(`${field} must be unique`); -} - -function isValidActionSuccessorFence(fence: ActionSuccessorFence | undefined): boolean { - return ( - !!fence && - !!fence.leaseId && - Number.isInteger(fence.generation) && - fence.generation >= 1 && - !!fence.dispatchId && - (fence.terminalPredicateDigest === undefined || !!fence.terminalPredicateDigest) && - (fence.invocationLineageRef === undefined || !!fence.invocationLineageRef) - ); -} - -function assertAdmissionTargetPartition(intent: QueueCustodyAdmissionIntent): void { - assertUniqueTargets(intent.targetCats, 'queue custody admission targetCats'); - const requestedTargetCats = intent.requestedTargetCats ?? intent.targetCats; - assertUniqueTargets(requestedTargetCats, 'queue custody admission requestedTargetCats'); - if (requestedTargetCats.length === 0) throw new Error('queue custody admission requires at least one target'); - if (intent.targetCats.some((catId) => !requestedTargetCats.includes(catId))) { - throw new Error('queue custody admission accepted target must belong to requested targets'); - } -} - -export function assertQueueCustodyAdmissionIntent(intent: QueueCustodyAdmissionIntent): void { - if (intent.version !== 1) throw new Error('queue custody admission version must be 1'); - if (!intent.admissionId) throw new Error('queue custody admission id is required'); - if (!intent.ownerUserId) throw new Error('queue custody admission owner is required'); - if (normalizeOwnerAuthProvenance(intent.ownerAuthProvenance) !== intent.ownerAuthProvenance) { - throw new Error('invalid queue custody admission ownerAuthProvenance'); - } - if (!intent.intent) throw new Error('queue custody admission intent is required'); - assertAdmissionTargetPartition(intent); - if ( - intent.receiptScope !== undefined && - intent.receiptScope !== 'primary_trigger' && - intent.receiptScope !== 'cross_thread_delivery' - ) { - throw new Error(`invalid queue custody admission receipt scope: ${String(intent.receiptScope)}`); - } - if (intent.priority !== 'urgent' && intent.priority !== 'normal') { - throw new Error(`invalid queue custody admission priority: ${String(intent.priority)}`); - } - assertFiniteNonNegative(intent.createdAt, 'queue custody admission createdAt'); - const fence = intent.actionSuccessorFence; - if (fence && !isValidActionSuccessorFence(fence)) { - throw new Error('invalid queue custody admission action successor fence'); - } -} - -export function cloneQueueCustodyAdmissionIntent(intent: QueueCustodyAdmissionIntent): QueueCustodyAdmissionIntent { - assertQueueCustodyAdmissionIntent(intent); - return { - ...intent, - targetCats: [...intent.targetCats], - ...(intent.requestedTargetCats ? { requestedTargetCats: [...intent.requestedTargetCats] } : {}), - ...(intent.actionSuccessorFence ? { actionSuccessorFence: { ...intent.actionSuccessorFence } } : {}), - }; -} - -export function parseQueueCustodyAdmissionIntent(raw: string | undefined): QueueCustodyAdmissionIntent | undefined { - if (!raw) return undefined; - try { - const parsed = JSON.parse(raw) as QueueCustodyAdmissionIntent; - assertQueueCustodyAdmissionIntent(parsed); - return cloneQueueCustodyAdmissionIntent(parsed); - } catch { - return undefined; - } -} - -export function queueCustodyAdmissionIntentsMatch( - actual: QueueCustodyAdmissionIntent | undefined, - expected: QueueCustodyAdmissionIntent, -): boolean { - return !!actual && JSON.stringify(actual) === JSON.stringify(expected); -} - -function assertCustodyIdentity(custody: QueuedMessageCustody): void { - if (custody.version !== 1) throw new Error('queue custody version must be 1'); - if ( - custody.executionScope !== undefined && - custody.executionScope !== 'collective-participation' && - custody.executionScope !== 'collective-work' - ) - throw new Error('invalid queue executionScope'); - if ( - custody.executionScope && - (custody.allTargetCats.length !== 1 || - (custody.executionScope === 'collective-participation' - ? custody.ownerAuthProvenance !== 'unknown' - : custody.ownerAuthProvenance !== 'strict')) - ) { - throw new Error('Collective execution scope must preserve exact target and provenance'); - } - if (!custody.entryId) throw new Error('queue custody entryId is required'); - if (custody.ownerUserId !== undefined && custody.ownerUserId.length === 0) { - throw new Error('queue custody ownerUserId must be non-empty'); - } - if ( - custody.ownerAuthProvenance !== undefined && - normalizeOwnerAuthProvenance(custody.ownerAuthProvenance) !== custody.ownerAuthProvenance - ) { - throw new Error('invalid queue custody ownerAuthProvenance'); - } - if (!Number.isInteger(custody.revision) || custody.revision < 1) { - throw new Error('queue custody revision must be a positive integer'); - } - if (!custody.intent) throw new Error('queue custody intent is required'); - if ( - custody.receiptScope !== undefined && - custody.receiptScope !== 'primary_trigger' && - custody.receiptScope !== 'cross_thread_delivery' - ) { - throw new Error(`invalid queue custody receipt scope: ${String(custody.receiptScope)}`); - } - const carriers = custody.carrierByTargetCatId; - if (custody.receiptScope === 'cross_thread_delivery' && !carriers) { - throw new Error('cross-thread delivery custody requires per-target carrier bindings'); - } - if (carriers) { - const carrierTargets = Object.keys(carriers); - if (carrierTargets.some((catId) => !custody.allTargetCats.includes(catId as CatId))) { - throw new Error('queue custody carrier target must belong to allTargetCats'); - } - for (const [catId, binding] of Object.entries(carriers)) { - if ( - !binding.entryId || - binding.source !== 'agent' || - binding.sourceCategory !== 'a2a' || - !binding.a2aTriggerMessageId || - binding.autoExecute !== true - ) { - throw new Error('invalid cross-thread Queue carrier binding'); - } - if (binding.idempotencyKey !== undefined && binding.idempotencyKey.length === 0) { - throw new Error('cross-thread Queue carrier idempotency key must be non-empty'); - } - if (binding.actionSuccessorFence) { - if (!isValidActionSuccessorFence(binding.actionSuccessorFence)) { - throw new Error('invalid cross-thread Queue carrier action successor fence'); - } - const expectedActionKey = `action:${binding.actionSuccessorFence.leaseId}:${binding.actionSuccessorFence.generation}:${catId}`; - if (binding.idempotencyKey !== expectedActionKey) { - throw new Error('action-successor Queue carrier requires its exact durable idempotency key'); - } - } - assertFiniteNonNegative(binding.createdAt, 'carrier.createdAt'); - } - } - if (custody.carrierStateByTargetCatId) { - for (const [catId, state] of Object.entries(custody.carrierStateByTargetCatId)) { - if (!custody.allTargetCats.includes(catId as CatId) || !custody.pendingTargetCats.includes(catId as CatId)) { - throw new Error('queue custody carrier state requires a pending target'); - } - if (carriers && !carriers[catId]) { - throw new Error('cross-thread queue custody carrier state requires an immutable carrier binding'); - } - if (state.status !== 'queued' && state.status !== 'processing') { - throw new Error('invalid queue custody carrier state'); - } - if (state.processingStartedAt !== undefined) { - assertFiniteNonNegative(state.processingStartedAt, 'carrier.processingStartedAt'); - } - } - } - for (const [catId, invocationId] of Object.entries(custody.awakenedInvocationIdByCatId ?? {})) { - if (!custody.allTargetCats.includes(catId as CatId) || !invocationId) { - throw new Error('queue custody awakened child must bind a target and invocation'); - } - const awakenedAt = custody.awakenedAtByCatId?.[catId]; - if (awakenedAt === undefined) throw new Error('queue custody awakened child requires awakenedAt'); - assertFiniteNonNegative(awakenedAt, 'awakenedAt'); - } - for (const catId of Object.keys(custody.awakenedAtByCatId ?? {})) { - if (!custody.awakenedInvocationIdByCatId?.[catId]) { - throw new Error('queue custody awakenedAt requires an exact child invocation'); - } - } - if (custody.priority !== 'urgent' && custody.priority !== 'normal') { - throw new Error(`invalid queue custody priority: ${custody.priority}`); - } - assertFiniteNonNegative(custody.createdAt, 'createdAt'); - assertFiniteNonNegative(custody.updatedAt, 'updatedAt'); - if (custody.updatedAt < custody.createdAt) throw new Error('updatedAt cannot precede createdAt'); - if (custody.processingStartedAt !== undefined) { - assertFiniteNonNegative(custody.processingStartedAt, 'processingStartedAt'); - } - if (custody.position !== undefined && !Number.isFinite(custody.position)) { - throw new Error('queue custody position must be a finite number'); - } - if (custody.prestartRetirement) { - const retirement = custody.prestartRetirement; - if ( - !retirement.id || - !retirement.primaryEntryId || - !retirement.targetCatId || - retirement.entryIds.length === 0 || - retirement.entryIds.some((entryId) => !entryId) || - new Set(retirement.entryIds).size !== retirement.entryIds.length || - !retirement.entryIds.includes(retirement.primaryEntryId) - ) { - throw new Error('invalid pre-start retirement group identity'); - } - assertFiniteNonNegative(retirement.startedAt, 'prestartRetirement.startedAt'); - } -} - -function carrierBindingWithoutActionIdentity(binding: QueueTargetCarrierBinding): object { - const { idempotencyKey: _idempotencyKey, actionSuccessorFence: _actionSuccessorFence, ...base } = binding; - return base; -} - -function assertSelectedActionSuccessorCarrierRebind( - before: QueueTargetCarrierBinding | undefined, - after: QueueTargetCarrierBinding | undefined, - catId: string, - proof: QueueCustodyActionSuccessorRebindProof, -): void { - if ( - !before || - !after || - before.entryId !== proof.entryId || - after.entryId !== proof.entryId || - JSON.stringify(carrierBindingWithoutActionIdentity(after)) !== - JSON.stringify(carrierBindingWithoutActionIdentity(before)) - ) { - throw new Error('action-successor Queue carrier rebind cannot change immutable carrier identity'); - } - const priorFence = before.actionSuccessorFence; - if ( - priorFence && - (priorFence.leaseId !== proof.fence.leaseId || - priorFence.dispatchId !== proof.fence.dispatchId || - proof.fence.generation < priorFence.generation) - ) { - throw new Error('action-successor Queue carrier rebind cannot replace or rewind authority'); - } - if (JSON.stringify(after.actionSuccessorFence) !== JSON.stringify(proof.fence)) { - throw new Error('action-successor Queue carrier rebind must persist the exact verified fence'); - } - const expectedKey = `action:${proof.fence.leaseId}:${proof.fence.generation}:${catId}`; - if (after.idempotencyKey !== expectedKey) { - throw new Error('action-successor Queue carrier rebind must persist the exact action idempotency key'); - } -} - -function assertActionSuccessorCarrierRebind( - current: QueuedMessageCustody, - next: QueuedMessageCustody, - proof: QueueCustodyActionSuccessorRebindProof, -): void { - if (!proof.entryId || proof.targetCatIds.length === 0) { - throw new Error('action-successor Queue carrier rebind requires an exact carrier and targets'); - } - assertUniqueTargets(proof.targetCatIds, 'action-successor Queue carrier rebind targets'); - if (!isValidActionSuccessorFence(proof.fence)) { - throw new Error('action-successor Queue carrier rebind requires a valid fence'); - } - const selectedTargets = new Set(proof.targetCatIds); - const allTargets = new Set([ - ...Object.keys(current.carrierByTargetCatId ?? {}), - ...Object.keys(next.carrierByTargetCatId ?? {}), - ]); - for (const catId of allTargets) { - const before = current.carrierByTargetCatId?.[catId]; - const after = next.carrierByTargetCatId?.[catId]; - if (!selectedTargets.has(catId)) { - if (JSON.stringify(after) !== JSON.stringify(before)) { - throw new Error('action-successor Queue carrier rebind cannot mutate another target binding'); - } - continue; - } - assertSelectedActionSuccessorCarrierRebind(before, after, catId, proof); - } - if (proof.targetCatIds.some((catId) => !allTargets.has(catId))) { - throw new Error('action-successor Queue carrier rebind target is not custody-bound'); - } -} - -function assertTargetSubsets(custody: QueuedMessageCustody, allTargets: ReadonlySet): void { - const targetSets = [ - ['pendingTargetCats', custody.pendingTargetCats], - ['notifiedByCatIds', custody.notifiedByCatIds], - ['seenByCatIds', custody.seenByCatIds], - ['failedByCatIds', custody.failedByCatIds], - ['withdrawnByCatIds', custody.withdrawnByCatIds ?? []], - ['handledByCatIds', custody.handledByCatIds], - ] as const; - for (const [field, values] of targetSets) { - if (values.some((catId) => !allTargets.has(catId))) throw new Error(`${field} must be a subset of allTargetCats`); - } -} - -function assertInvocationBindings(custody: QueuedMessageCustody, allTargets: ReadonlySet): void { - for (const [catId, invocationId] of Object.entries(custody.seenInvocationIdByCatId)) { - if (!allTargets.has(catId) || !custody.seenByCatIds.includes(catId as CatId) || !invocationId) { - throw new Error('seenInvocationIdByCatId must bind seen targets to non-empty invocation ids'); - } - // Pre-F264 custody can carry a parent-level seen binding without an exact - // body exposure. Mixed records are valid during rolling migration: exact - // targets project seenAt, while legacy targets remain visible without one. - } - for (const [catId, invocationId] of Object.entries(custody.steeredInvocationIdByCatId ?? {})) { - if (!allTargets.has(catId) || !custody.pendingTargetCats.includes(catId as CatId) || !invocationId) { - throw new Error('steeredInvocationIdByCatId must bind pending targets to non-empty invocation ids'); - } - } - for (const [catId, invocationId] of Object.entries(custody.awakenedInvocationIdByCatId ?? {})) { - if (!allTargets.has(catId) || !custody.pendingTargetCats.includes(catId as CatId) || !invocationId) { - throw new Error('awakenedInvocationIdByCatId must bind pending targets to non-empty invocation ids'); - } - } - assertUniqueTargets(custody.steerRequestedByCatIds ?? [], 'steerRequestedByCatIds'); - if ((custody.steerRequestedByCatIds ?? []).some((catId) => !custody.pendingTargetCats.includes(catId))) { - throw new Error('steerRequestedByCatIds must contain pending targets only'); - } -} - -function assertTargetOutcomeExposure( - custody: QueuedMessageCustody, - catId: string, - outcome: QueueTargetOutcome, - allowLegacyMissingExposure: boolean, -): void { - const exposure = custody.bodyExposures?.find( - (candidate) => candidate.targetCatId === catId && candidate.invocationId === outcome.invocationId, - ); - if (!exposure) { - if (!allowLegacyMissingExposure) throw new Error('target outcome requires matching exact body exposure'); - return; - } - if (outcome.handledAt <= exposure.seenAt) { - throw new Error('target outcome handledAt must follow exact body exposure seenAt'); - } -} - -function assertTargetOutcomes( - custody: QueuedMessageCustody, - allTargets: ReadonlySet, - allowLegacyMissingExposure = false, -): void { - for (const [catId, outcome] of Object.entries(custody.targetOutcomeByCatId ?? {})) { - if (!allTargets.has(catId) || !custody.handledByCatIds.includes(catId as CatId)) { - throw new Error('targetOutcomeByCatId must contain handled targets only'); - } - if ( - !outcome.invocationId || - (outcome.evidenceRef?.kind !== 'invocation_lineage' && outcome.evidenceRef?.kind !== 'turn_execution') || - outcome.evidenceRef.invocationId !== outcome.invocationId - ) { - throw new Error('target outcome must carry matching invocation evidence'); - } - if ( - outcome.disposition !== 'responded' && - outcome.disposition !== 'completed_with_turn' && - outcome.disposition !== 'managed_hold_disposition' - ) { - throw new Error(`invalid target outcome disposition: ${outcome.disposition}`); - } - if (outcome.consumption !== undefined) { - if (outcome.consumption.kind === 'terminal_silent') { - if ( - outcome.consumption.projectionState !== 'covered_empty' || - outcome.consumption.wake !== 'coordination_terminal' - ) { - throw new Error('invalid target terminal consumption witness'); - } - } else if (outcome.consumption.kind === 'source_response') { - const outputMessageIds = outcome.consumption.outputMessageIds; - if ( - outputMessageIds.length === 0 || - outputMessageIds.some((messageId) => typeof messageId !== 'string' || messageId.length === 0) || - new Set(outputMessageIds).size !== outputMessageIds.length - ) { - throw new Error('source response consumption requires unique output message ids'); - } - } else if (outcome.consumption.kind === 'managed_hold_continued') { - if ( - !outcome.consumption.sourceMessageId || - !outcome.consumption.taskId || - !['reheld', 'event_wait', 'transferred'].includes(outcome.consumption.transition) - ) { - throw new Error('invalid managed hold continuation witness'); - } - } else if (outcome.consumption.kind === 'dispatch_handled_continuation') { - if ( - !outcome.consumption.sourceMessageId || - !outcome.consumption.dispositionEventId || - !Number.isFinite(outcome.consumption.dispositionAt) || - outcome.consumption.dispositionAt < 0 - ) { - throw new Error('invalid dispatch handled continuation witness'); - } - } else { - throw new Error('invalid target terminal consumption witness'); - } - } - assertFiniteNonNegative(outcome.handledAt, 'targetOutcome.handledAt'); - assertTargetOutcomeExposure(custody, catId, outcome, allowLegacyMissingExposure); - } -} - -function assertBodyExposures(custody: QueuedMessageCustody, allTargets: ReadonlySet): void { - const keys = new Set(); - for (const exposure of custody.bodyExposures ?? []) { - if (!allTargets.has(exposure.targetCatId) || !exposure.invocationId) { - throw new Error('body exposure must bind a target and non-empty invocation id'); - } - assertFiniteNonNegative(exposure.seenAt, 'bodyExposure.seenAt'); - const key = `${exposure.targetCatId}\u0000${exposure.invocationId}`; - if (keys.has(key)) throw new Error('body exposures must be unique by target invocation'); - keys.add(key); - } -} - -function assertReminderAttemptTimestamps(attempt: QueueReminderAttempt): void { - assertFiniteNonNegative(attempt.requestedAt, 'reminderAttempt.requestedAt'); - if (attempt.deliveredAt !== undefined) { - assertFiniteNonNegative(attempt.deliveredAt, 'reminderAttempt.deliveredAt'); - } - if (attempt.state === 'delivered' && attempt.deliveredAt === undefined) { - throw new Error('delivered reminder attempt requires deliveredAt'); - } - if (attempt.state === 'seen') { - if (attempt.seenAt === undefined) throw new Error('seen reminder attempt requires seenAt'); - assertFiniteNonNegative(attempt.seenAt, 'reminderAttempt.seenAt'); - } - if (attempt.state === 'missed') { - const validReason = - attempt.missedReason === 'invocation_ended_before_delivery' || - attempt.missedReason === 'delivered_not_read' || - attempt.missedReason === 'source_withdrawn'; - if (attempt.missedAt === undefined || !validReason) { - throw new Error('missed reminder attempt requires missedAt and a valid missedReason'); - } - assertFiniteNonNegative(attempt.missedAt, 'reminderAttempt.missedAt'); - } -} - -function assertReminderAttempts(custody: QueuedMessageCustody, allTargets: ReadonlySet): void { - const reminderKeys = new Set(); - const reminderIds = new Set(); - for (const attempt of custody.reminderAttempts ?? []) { - if (!attempt.id || !attempt.invocationId || !allTargets.has(attempt.targetCatId)) { - throw new Error('reminder attempt must bind a target and invocation with a non-empty id'); - } - const key = `${attempt.targetCatId}\u0000${attempt.invocationId}`; - if (reminderKeys.has(key) || reminderIds.has(attempt.id)) { - throw new Error('reminder attempts must be unique by id and target invocation'); - } - reminderKeys.add(key); - reminderIds.add(attempt.id); - if (!['requested', 'delivered', 'seen', 'missed'].includes(attempt.state)) { - throw new Error(`invalid reminder attempt state: ${attempt.state}`); - } - assertReminderAttemptTimestamps(attempt); - } -} - -const TARGET_ATTEMPT_NEXT_STATES: Readonly< - Record> -> = { - queued: new Set(['queued', 'starting', 'appended', 'failed', 'interrupted', 'cancelled', 'handled']), - starting: new Set(['starting', 'appended', 'failed', 'interrupted', 'cancelled', 'handled']), - appended: new Set(['appended', 'failed', 'interrupted', 'cancelled', 'handled']), - failed: new Set(['failed']), - interrupted: new Set(['interrupted']), - cancelled: new Set(['cancelled']), - handled: new Set(['handled']), -}; - -function assertTargetAttempts(custody: QueuedMessageCustody, allTargets: ReadonlySet): void { - const ids = new Set(); - const sequencesByTarget = new Map>(); - for (const attempt of custody.targetAttempts ?? []) { - if ( - !attempt.id || - !allTargets.has(attempt.targetCatId) || - !Number.isInteger(attempt.sequence) || - attempt.sequence < 1 - ) { - throw new Error('target attempt must bind a target with a positive sequence and non-empty id'); - } - if (!TARGET_ATTEMPT_NEXT_STATES[attempt.state]) throw new Error('invalid target attempt state'); - if (ids.has(attempt.id)) throw new Error('target attempt ids must be unique'); - ids.add(attempt.id); - const sequences = sequencesByTarget.get(attempt.targetCatId) ?? new Set(); - if (sequences.has(attempt.sequence)) throw new Error('target attempt sequences must be unique per target'); - sequences.add(attempt.sequence); - sequencesByTarget.set(attempt.targetCatId, sequences); - assertFiniteNonNegative(attempt.createdAt, 'targetAttempt.createdAt'); - assertFiniteNonNegative(attempt.updatedAt, 'targetAttempt.updatedAt'); - if (attempt.updatedAt < attempt.createdAt) throw new Error('target attempt updatedAt cannot precede createdAt'); - if (attempt.invocationId !== undefined && !attempt.invocationId) - throw new Error('target attempt invocation id cannot be empty'); - if (attempt.seenAt !== undefined) assertFiniteNonNegative(attempt.seenAt, 'targetAttempt.seenAt'); - if ( - attempt.terminalReason !== undefined && - !['invocation_failed', 'runtime_restart', 'invocation_cancelled', 'source_withdrawn'].includes( - attempt.terminalReason, - ) - ) { - throw new Error('invalid target attempt terminal reason'); - } - if (attempt.state === 'failed' && attempt.terminalReason !== 'invocation_failed') { - throw new Error('failed target attempt requires invocation_failed reason'); - } - if (attempt.state === 'interrupted' && attempt.terminalReason !== 'runtime_restart') { - throw new Error('interrupted target attempt requires runtime_restart reason'); - } - if ( - attempt.state === 'cancelled' && - !['invocation_cancelled', 'source_withdrawn'].includes(attempt.terminalReason ?? '') - ) { - throw new Error('cancelled target attempt requires a cancellation reason'); - } - if ( - (attempt.state === 'starting' || attempt.state === 'appended' || attempt.state === 'handled') && - !attempt.invocationId - ) { - throw new Error('started, appended, and handled target attempts require an invocation id'); - } - if (attempt.state === 'appended' && attempt.seenAt === undefined) { - throw new Error('appended target attempt requires exact body exposure time'); - } - } -} - -function assertAuthorIntentCarrierCapability(authorIntent: QueueAuthorIntent): void { - const capability = authorIntent.carrierCapability; - if (!capability) return; - if (!['openai_codex', 'anthropic', 'kimi', 'other'].includes(capability.provider)) { - throw new Error('invalid queue author intent carrier provider'); - } - if ( - ![ - 'codex_app_server', - 'codex_exec_json', - 'claude_print_sdk', - 'claude_stream_json', - 'kimi_stream_json', - 'mcp_result_piggyback', - 'other', - ].includes(capability.carrier) - ) { - throw new Error('invalid queue author intent carrier'); - } - if ( - !['exact_active_turn', 'queued_internal_turn', 'mcp_result_piggyback', 'unsupported', 'undeclared'].includes( - capability.deliverySemantics, - ) - ) { - throw new Error('invalid queue author intent carrier delivery semantics'); - } -} - -function assertAuthorIntentDisposition(authorIntent: QueueAuthorIntent): void { - if (authorIntent.requested !== 'continue_current' && authorIntent.requested !== 'next_work') { - throw new Error('invalid queue author intent disposition'); - } - if (authorIntent.requested === 'next_work' && authorIntent.boundParentInvocationId !== undefined) { - throw new Error('next-work author intent cannot bind a parent invocation'); - } - const allowsMissingParent = - authorIntent.fallbackReason === 'no_active_parent' || - authorIntent.fallbackReason === 'carrier_capability_undeclared' || - authorIntent.fallbackReason === 'unsupported_carrier'; - if (authorIntent.requested === 'continue_current' && !authorIntent.boundParentInvocationId && !allowsMissingParent) { - throw new Error('continue-current author intent requires an exact parent fence'); - } -} - -function assertAuthorIntentFallback(authorIntent: QueueAuthorIntent): void { - const validReasons = new Set([ - 'no_active_parent', - 'carrier_capability_undeclared', - 'unsupported_carrier', - 'parent_terminal_before_exposure', - 'parent_non_success_after_exposure', - ]); - if ((authorIntent.fallbackAt === undefined) !== (authorIntent.fallbackReason === undefined)) { - throw new Error('queue author intent fallback requires timestamp and reason together'); - } - if (authorIntent.fallbackAt !== undefined) { - assertFiniteNonNegative(authorIntent.fallbackAt, 'authorIntent.fallbackAt'); - } - if (authorIntent.fallbackReason !== undefined && !validReasons.has(authorIntent.fallbackReason)) { - throw new Error('invalid queue author intent fallback reason'); - } -} - -function assertAuthorIntents(custody: QueuedMessageCustody, allTargets: ReadonlySet): void { - for (const [catId, authorIntent] of Object.entries(custody.authorIntentByCatId ?? {})) { - if (!allTargets.has(catId)) throw new Error('queue author intent target must belong to allTargetCats'); - assertAuthorIntentDisposition(authorIntent); - assertAuthorIntentCarrierCapability(authorIntent); - assertAuthorIntentFallback(authorIntent); - } -} - -function assertCustodyTargets(custody: QueuedMessageCustody, allowLegacyMissingExposure = false): void { - assertUniqueTargets(custody.allTargetCats, 'allTargetCats'); - if (custody.allTargetCats.length === 0) throw new Error('queue custody requires at least one target'); - assertUniqueTargets(custody.pendingTargetCats, 'pendingTargetCats'); - assertUniqueTargets(custody.notifiedByCatIds, 'notifiedByCatIds'); - assertUniqueTargets(custody.seenByCatIds, 'seenByCatIds'); - assertUniqueTargets(custody.failedByCatIds, 'failedByCatIds'); - assertUniqueTargets(custody.withdrawnByCatIds ?? [], 'withdrawnByCatIds'); - assertUniqueTargets(custody.handledByCatIds, 'handledByCatIds'); - - const allTargets = new Set(custody.allTargetCats); - assertAuthorIntents(custody, allTargets); - for (const catId of custody.withdrawnByCatIds ?? []) { - const withdrawnAt = custody.withdrawnAtByCatId?.[catId]; - if (withdrawnAt === undefined) throw new Error('withdrawn target requires withdrawnAt'); - assertFiniteNonNegative(withdrawnAt, 'withdrawnAt'); - } - for (const catId of Object.keys(custody.withdrawnAtByCatId ?? {})) { - if (!custody.withdrawnByCatIds?.includes(catId as CatId)) { - throw new Error('withdrawnAt requires a withdrawn target'); - } - } - for (const [catId, terminalFence] of Object.entries(custody.actionSuccessorTerminalFenceByTargetCatId ?? {})) { - if (!custody.withdrawnByCatIds?.includes(catId as CatId)) { - throw new Error('action-successor terminal fence requires a withdrawn target'); - } - if (!isValidActionSuccessorFence(terminalFence)) { - throw new Error('invalid withdrawn action-successor terminal fence'); - } - if (!actionSuccessorFencesMatch(custody.carrierByTargetCatId?.[catId]?.actionSuccessorFence, terminalFence)) { - throw new Error('withdrawn action-successor terminal fence must match its immutable carrier'); - } - } - assertTargetSubsets(custody, allTargets); - assertBodyExposures(custody, allTargets); - assertInvocationBindings(custody, allTargets); - assertTargetOutcomes(custody, allTargets, allowLegacyMissingExposure); - assertReminderAttempts(custody, allTargets); - assertTargetAttempts(custody, allTargets); -} - -function assertCustodyLifecycle(custody: QueuedMessageCustody): void { - if (!['queued', 'processing', 'terminal'].includes(custody.status)) { - throw new Error(`invalid queue custody status: ${custody.status}`); - } - if (custody.pendingTargetCats.some((catId) => custody.handledByCatIds.includes(catId))) { - throw new Error('pending and handled targets must be disjoint'); - } - if (custody.pendingTargetCats.some((catId) => custody.withdrawnByCatIds?.includes(catId))) { - throw new Error('pending and withdrawn targets must be disjoint'); - } - if (custody.handledByCatIds.some((catId) => custody.withdrawnByCatIds?.includes(catId))) { - throw new Error('handled and withdrawn targets must be disjoint'); - } - if (custody.status === 'terminal' && custody.pendingTargetCats.length > 0) { - throw new Error('terminal queue custody cannot retain pending targets'); - } - if (custody.status !== 'terminal' && custody.pendingTargetCats.length === 0) { - throw new Error('active queue custody requires a pending target'); - } - const activeCarrierTargets = Object.keys(custody.carrierStateByTargetCatId ?? {}); - if (activeCarrierTargets.some((catId) => custody.handledByCatIds.includes(catId as CatId))) { - throw new Error('handled target cannot retain an active Queue carrier state'); - } - if (activeCarrierTargets.some((catId) => custody.withdrawnByCatIds?.includes(catId as CatId))) { - throw new Error('withdrawn target cannot retain an active Queue carrier state'); - } - if (custody.status === 'terminal' && activeCarrierTargets.length > 0) { - throw new Error('terminal queue custody cannot retain an active Queue carrier state'); - } - if ( - custody.status === 'terminal' && - ((custody.reminderAttempts ?? []).some( - (attempt) => attempt.state === 'requested' || attempt.state === 'delivered', - ) || - (custody.steerRequestedByCatIds?.length ?? 0) > 0 || - Object.keys(custody.steeredInvocationIdByCatId ?? {}).length > 0) - ) { - throw new Error('terminal queue custody cannot retain an active reminder or Steer state'); - } -} - -function assertTargetOutcomeMonotonicity(current: QueuedMessageCustody, next: QueuedMessageCustody): void { - for (const [catId, outcome] of Object.entries(current.targetOutcomeByCatId ?? {})) { - if (JSON.stringify(next.targetOutcomeByCatId?.[catId]) !== JSON.stringify(outcome)) { - throw new Error('queue custody target outcomes are append-only'); - } - } -} - -function assertBodyExposureMonotonicity(current: QueuedMessageCustody, next: QueuedMessageCustody): void { - const nextByKey = new Map( - (next.bodyExposures ?? []).map((exposure) => [`${exposure.targetCatId}\u0000${exposure.invocationId}`, exposure]), - ); - for (const exposure of current.bodyExposures ?? []) { - const key = `${exposure.targetCatId}\u0000${exposure.invocationId}`; - if (JSON.stringify(nextByKey.get(key)) !== JSON.stringify(exposure)) { - throw new Error('queue custody body exposures are append-only'); - } - } -} - -function assertAuthorIntentMonotonicity(current: QueuedMessageCustody, next: QueuedMessageCustody): void { - for (const [catId, authorIntent] of Object.entries(current.authorIntentByCatId ?? {})) { - const successor = next.authorIntentByCatId?.[catId]; - if (!successor) throw new Error('queue author intent is immutable once assigned'); - if ( - successor.requested !== authorIntent.requested || - successor.boundParentInvocationId !== authorIntent.boundParentInvocationId || - JSON.stringify(successor.carrierCapability) !== JSON.stringify(authorIntent.carrierCapability) - ) { - throw new Error('queue author intent request and parent fence are immutable'); - } - if ( - authorIntent.fallbackAt !== undefined && - (successor.fallbackAt !== authorIntent.fallbackAt || successor.fallbackReason !== authorIntent.fallbackReason) - ) { - throw new Error('queue author intent fallback is append-only'); - } - } -} - -function assertWithdrawalMonotonicity(current: QueuedMessageCustody, next: QueuedMessageCustody): void { - for (const catId of current.withdrawnByCatIds ?? []) { - if (next.withdrawnByCatIds?.includes(catId)) { - if (next.withdrawnAtByCatId?.[catId] !== current.withdrawnAtByCatId?.[catId]) { - throw new Error('queue custody withdrawals are append-only'); - } - continue; - } - const lateOutcome = next.targetOutcomeByCatId?.[catId]; - const exactExposure = lateOutcome - ? current.bodyExposures?.some( - (exposure) => exposure.targetCatId === catId && exposure.invocationId === lateOutcome.invocationId, - ) - : false; - const supersededByExactTerminalHandling = - current.status === 'terminal' && - next.status === 'terminal' && - !current.handledByCatIds.includes(catId) && - next.handledByCatIds.includes(catId) && - current.targetOutcomeByCatId?.[catId] === undefined && - lateOutcome !== undefined && - exactExposure === true && - lateOutcome.handledAt > (current.withdrawnAtByCatId?.[catId] ?? Number.POSITIVE_INFINITY); - if (!supersededByExactTerminalHandling) throw new Error('queue custody withdrawals are append-only'); - } -} - -function assertActionSuccessorTerminalMonotonicity(current: QueuedMessageCustody, next: QueuedMessageCustody): void { - for (const [catId, fence] of Object.entries(current.actionSuccessorTerminalFenceByTargetCatId ?? {})) { - if (!actionSuccessorFencesMatch(next.actionSuccessorTerminalFenceByTargetCatId?.[catId], fence)) { - throw new Error('queue custody action-successor terminal fences are append-only'); - } - } -} - -const REMINDER_NEXT_STATES: Readonly< - Record> -> = { - requested: new Set(['requested', 'delivered', 'seen', 'missed']), - delivered: new Set(['delivered', 'seen', 'missed']), - seen: new Set(['seen']), - missed: new Set(['missed']), -}; - -function sameReminderIdentity(left: QueueReminderAttempt, right: QueueReminderAttempt): boolean { - return ( - left.id === right.id && - left.targetCatId === right.targetCatId && - left.invocationId === right.invocationId && - left.requestedAt === right.requestedAt - ); -} - -function assertReminderAttemptMonotonicity(current: QueuedMessageCustody, next: QueuedMessageCustody): void { - const nextById = new Map((next.reminderAttempts ?? []).map((attempt) => [attempt.id, attempt])); - for (const attempt of current.reminderAttempts ?? []) { - const successor = nextById.get(attempt.id); - const preservesDeliveredAt = attempt.deliveredAt === undefined || successor?.deliveredAt === attempt.deliveredAt; - const terminalUnchanged = - (attempt.state !== 'seen' && attempt.state !== 'missed') || JSON.stringify(successor) === JSON.stringify(attempt); - if ( - !successor || - !sameReminderIdentity(attempt, successor) || - !REMINDER_NEXT_STATES[attempt.state].has(successor.state) || - !preservesDeliveredAt || - !terminalUnchanged - ) { - throw new Error('queue custody reminder attempts are append-only and monotonic'); - } - } -} - -function assertTargetAttemptMonotonicity(current: QueuedMessageCustody, next: QueuedMessageCustody): void { - const nextById = new Map((next.targetAttempts ?? []).map((attempt) => [attempt.id, attempt])); - for (const attempt of current.targetAttempts ?? []) { - const successor = nextById.get(attempt.id); - if ( - !successor || - successor.targetCatId !== attempt.targetCatId || - successor.sequence !== attempt.sequence || - successor.createdAt !== attempt.createdAt || - !TARGET_ATTEMPT_NEXT_STATES[attempt.state].has(successor.state) || - (attempt.invocationId !== undefined && successor.invocationId !== attempt.invocationId) || - (attempt.seenAt !== undefined && successor.seenAt !== attempt.seenAt) || - (attempt.terminalReason !== undefined && successor.terminalReason !== attempt.terminalReason) || - successor.updatedAt < attempt.updatedAt - ) { - throw new Error('queue custody target attempts are append-only and monotonic'); - } - if ( - ['failed', 'interrupted', 'cancelled', 'handled'].includes(attempt.state) && - JSON.stringify(successor) !== JSON.stringify(attempt) - ) { - throw new Error('terminal target attempts are immutable'); - } - } -} - -export function assertQueuedMessageCustody(custody: QueuedMessageCustody): void { - assertCustodyIdentity(custody); - assertCustodyTargets(custody); - assertCustodyLifecycle(custody); -} - -function normalizePreExposurePersistedCustody(custody: QueuedMessageCustody): QueuedMessageCustody { - if (custody.bodyExposures !== undefined || Object.keys(custody.targetOutcomeByCatId ?? {}).length === 0) { - return custody; - } - - // Records written before child-exact exposure persistence can carry a valid - // parent-level F264 outcome but have no bodyExposures field at all. Validate - // every other invariant before degrading that unprovable evidence to generic - // handled truth. New-format records remain strict and cannot use this path. - assertCustodyIdentity(custody); - assertCustodyTargets(custody, true); - assertCustodyLifecycle(custody); - const normalized = cloneQueuedMessageCustody(custody); - delete normalized.targetOutcomeByCatId; - return normalized; -} - -export function assertQueueCustodyMessageBinding(message: { - queueCustody?: QueuedMessageCustody; - queueCustodyAdmission?: QueueCustodyAdmissionIntent; - deliveryStatus?: 'queued' | 'delivered' | 'canceled'; -}): void { - if (message.queueCustodyAdmission) { - assertQueueCustodyAdmissionIntent(message.queueCustodyAdmission); - if (message.deliveryStatus !== 'queued') { - throw new Error('queue custody admission can only be stored on a queued message'); - } - if (message.queueCustody) throw new Error('queue custody admission and full custody are mutually exclusive'); - } - if (!message.queueCustody) return; - assertQueuedMessageCustody(message.queueCustody); - if (message.deliveryStatus !== 'queued') { - throw new Error('queue custody can only be stored on a queued message'); - } -} - -export function assertQueueCustodyTransition(current: QueuedMessageCustody, input: QueueCustodyTransitionInput): void { - assertQueuedMessageCustody(current); - assertQueuedMessageCustody(input.next); - if (input.next.entryId !== current.entryId) { - const replacement = input.replacement; - if ( - replacement?.kind !== 'verified' || - replacement.previousEntryId !== current.entryId || - replacement.replacementEntryId !== input.next.entryId || - replacement.previousEntryId === replacement.replacementEntryId - ) { - throw new Error('queue custody entryId requires exact verified replacement proof'); - } - if (current.status === 'terminal' || input.next.status !== 'queued') { - throw new Error('queue custody replacement requires active custody and a queued successor'); - } - if (current.carrierByTargetCatId || input.next.carrierByTargetCatId) { - throw new Error('per-target Queue carrier replacement must settle through its immutable target binding'); - } - } else if (input.replacement) { - throw new Error('queue custody replacement proof cannot be used without changing entryId'); - } - if (input.next.ownerAuthProvenance !== current.ownerAuthProvenance) { - throw new Error('queue custody ownerAuthProvenance is immutable'); - } - if (input.next.executionScope !== current.executionScope) throw new Error('queue executionScope is immutable'); - if (input.next.ownerUserId !== current.ownerUserId) { - throw new Error('queue custody ownerUserId is immutable'); - } - if (input.next.version !== current.version) throw new Error('queue custody version is immutable'); - if (input.next.createdAt !== current.createdAt) throw new Error('queue custody createdAt is immutable'); - if (input.next.intent !== current.intent) throw new Error('queue custody intent is immutable'); - if (current.receiptScope !== undefined && input.next.receiptScope !== current.receiptScope) { - throw new Error('queue custody receipt scope is immutable once assigned'); - } - if (JSON.stringify(input.next.allTargetCats) !== JSON.stringify(current.allTargetCats)) { - throw new Error('queue custody allTargetCats is immutable'); - } - if (JSON.stringify(input.next.carrierByTargetCatId) !== JSON.stringify(current.carrierByTargetCatId)) { - if (!input.actionSuccessorRebind) { - throw new Error('queue custody per-target carrier bindings are immutable'); - } - assertActionSuccessorCarrierRebind(current, input.next, input.actionSuccessorRebind); - } else if (input.actionSuccessorRebind) { - throw new Error('action-successor Queue carrier rebind must change durable carrier authority'); - } - if ( - current.prestartRetirement !== undefined && - JSON.stringify(input.next.prestartRetirement) !== JSON.stringify(current.prestartRetirement) - ) { - throw new Error('queue custody pre-start retirement identity is immutable once assigned'); - } - assertTargetOutcomeMonotonicity(current, input.next); - assertBodyExposureMonotonicity(current, input.next); - assertAuthorIntentMonotonicity(current, input.next); - assertWithdrawalMonotonicity(current, input.next); - assertActionSuccessorTerminalMonotonicity(current, input.next); - assertReminderAttemptMonotonicity(current, input.next); - assertTargetAttemptMonotonicity(current, input.next); - if (input.next.revision !== current.revision + 1) { - throw new Error('queue custody next revision must increment by one'); - } - if (input.deliveredAt !== undefined && input.next.status !== 'terminal') { - throw new Error('delivery transition requires terminal custody'); - } - if (input.next.status === 'terminal' && input.deliveredAt === undefined) { - const withdrawn = new Set(input.next.withdrawnByCatIds ?? []); - const handled = new Set(input.next.handledByCatIds); - const allTargetsSettled = input.next.allTargetCats.every((catId) => withdrawn.has(catId) || handled.has(catId)); - const exactLateHandled = input.next.handledByCatIds.filter( - (catId) => - !current.handledByCatIds.includes(catId) && - current.withdrawnByCatIds?.includes(catId) && - input.next.targetOutcomeByCatId?.[catId] !== undefined, - ); - if ((withdrawn.size === 0 && exactLateHandled.length === 0) || !allTargetsSettled) { - throw new Error('terminal custody without delivery requires exact author-withdrawal settlement'); - } - } - if (input.deliveredAt !== undefined) assertFiniteNonNegative(input.deliveredAt, 'deliveredAt'); -} - -export function cloneQueuedMessageCustody(custody: QueuedMessageCustody): QueuedMessageCustody { - return { - ...custody, - allTargetCats: [...custody.allTargetCats], - pendingTargetCats: [...custody.pendingTargetCats], - notifiedByCatIds: [...custody.notifiedByCatIds], - ...(custody.awakenedInvocationIdByCatId - ? { awakenedInvocationIdByCatId: { ...custody.awakenedInvocationIdByCatId } } - : {}), - ...(custody.awakenedAtByCatId ? { awakenedAtByCatId: { ...custody.awakenedAtByCatId } } : {}), - seenByCatIds: [...custody.seenByCatIds], - seenInvocationIdByCatId: { ...custody.seenInvocationIdByCatId }, - ...(custody.bodyExposures ? { bodyExposures: custody.bodyExposures.map((exposure) => ({ ...exposure })) } : {}), - ...(custody.targetAttempts ? { targetAttempts: custody.targetAttempts.map((attempt) => ({ ...attempt })) } : {}), - ...(custody.carrierByTargetCatId ? { carrierByTargetCatId: structuredClone(custody.carrierByTargetCatId) } : {}), - ...(custody.carrierStateByTargetCatId - ? { carrierStateByTargetCatId: structuredClone(custody.carrierStateByTargetCatId) } - : {}), - ...(custody.authorIntentByCatId ? { authorIntentByCatId: structuredClone(custody.authorIntentByCatId) } : {}), - failedByCatIds: [...custody.failedByCatIds], - ...(custody.withdrawnByCatIds ? { withdrawnByCatIds: [...custody.withdrawnByCatIds] } : {}), - ...(custody.withdrawnAtByCatId ? { withdrawnAtByCatId: { ...custody.withdrawnAtByCatId } } : {}), - ...(custody.actionSuccessorTerminalFenceByTargetCatId - ? { - actionSuccessorTerminalFenceByTargetCatId: structuredClone(custody.actionSuccessorTerminalFenceByTargetCatId), - } - : {}), - handledByCatIds: [...custody.handledByCatIds], - ...(custody.targetOutcomeByCatId ? { targetOutcomeByCatId: structuredClone(custody.targetOutcomeByCatId) } : {}), - ...(custody.steeredInvocationIdByCatId - ? { steeredInvocationIdByCatId: { ...custody.steeredInvocationIdByCatId } } - : {}), - ...(custody.steerRequestedByCatIds ? { steerRequestedByCatIds: [...custody.steerRequestedByCatIds] } : {}), - ...(custody.prestartRetirement - ? { - prestartRetirement: { - ...custody.prestartRetirement, - entryIds: [...custody.prestartRetirement.entryIds], - }, - } - : {}), - ...(custody.reminderAttempts - ? { reminderAttempts: custody.reminderAttempts.map((attempt) => ({ ...attempt })) } - : {}), - }; -} - -interface QueueCustodyWithdrawalOptions { - /** Recall must advance the custody revision even when every target was already settled. */ - forceRevision?: boolean; - /** Persist the exact business-terminal proof for matching action carriers. */ - actionSuccessorTerminalFence?: ActionSuccessorFence; -} - -function withoutSelectedEntries( - current: Readonly> | undefined, - selected: ReadonlySet, -): Record { - const next = { ...(current ?? {}) }; - for (const catId of selected) delete next[catId]; - return next; -} - -function includesSelected(values: readonly string[] | undefined, selected: ReadonlySet): boolean { - return (values ?? []).some((catId) => selected.has(catId)); -} - -function hasSelectedEntry(current: Readonly> | undefined, selected: ReadonlySet): boolean { - return Object.keys(current ?? {}).some((catId) => selected.has(catId)); -} - -function settleSelectedReminderAttempts( - current: readonly QueueReminderAttempt[] | undefined, - selected: ReadonlySet, - withdrawnAt: number, -): { attempts: QueueReminderAttempt[]; changed: boolean } { - let changed = false; - const attempts = (current ?? []).map((attempt) => { - const active = attempt.state === 'requested' || attempt.state === 'delivered'; - if (!selected.has(attempt.targetCatId) || !active) return attempt; - changed = true; - return { - ...attempt, - state: 'missed' as const, - missedAt: withdrawnAt, - missedReason: 'source_withdrawn' as const, - }; - }); - return { attempts, changed }; -} - -function settleSelectedTargetAttempts( - current: readonly QueueTargetAttempt[] | undefined, - selected: ReadonlySet, - withdrawnAt: number, -): { attempts: QueueTargetAttempt[]; changed: boolean } { - const latestSequenceByTarget = new Map(); - for (const attempt of current ?? []) { - latestSequenceByTarget.set( - attempt.targetCatId, - Math.max(latestSequenceByTarget.get(attempt.targetCatId) ?? 0, attempt.sequence), - ); - } - - let changed = false; - const attempts = (current ?? []).map((attempt) => { - const isLatest = latestSequenceByTarget.get(attempt.targetCatId) === attempt.sequence; - const isActive = attempt.state === 'queued' || attempt.state === 'starting' || attempt.state === 'appended'; - if (!selected.has(attempt.targetCatId) || !isLatest || !isActive) return attempt; - changed = true; - return { - ...attempt, - state: 'cancelled' as const, - terminalReason: 'source_withdrawn' as const, - updatedAt: Math.max(attempt.updatedAt, withdrawnAt), - }; - }); - return { attempts, changed }; -} - -/** - * Single source of truth for author-withdrawal terminalization. Queue withdraw - * and true recall both use this projection so newly-added active fields cannot - * remain live in one path while being settled in the other. - */ -export function settleQueueCustodyWithdrawal( - current: QueuedMessageCustody, - selectedTargetCats: readonly string[], - withdrawnAt: number, - options: QueueCustodyWithdrawalOptions = {}, -): QueuedMessageCustody { - assertFiniteNonNegative(withdrawnAt, 'withdrawnAt'); - const selected = new Set(selectedTargetCats); - const withdrawnNow = current.pendingTargetCats.filter((catId) => selected.has(catId)); - const pendingTargetCats = current.pendingTargetCats.filter((catId) => !selected.has(catId)); - const withdrawnByCatIds = [...new Set([...(current.withdrawnByCatIds ?? []), ...withdrawnNow])] as CatId[]; - const withdrawnAtByCatId = { ...(current.withdrawnAtByCatId ?? {}) }; - for (const catId of withdrawnNow) { - withdrawnAtByCatId[catId] ??= withdrawnAt; - } - const actionSuccessorTerminalFenceByTargetCatId = structuredClone( - current.actionSuccessorTerminalFenceByTargetCatId ?? {}, - ); - let actionSuccessorTerminalChanged = false; - if (options.actionSuccessorTerminalFence) { - for (const catId of selected) { - if (!withdrawnNow.includes(catId as CatId) && !current.withdrawnByCatIds?.includes(catId as CatId)) continue; - const carrierFence = current.carrierByTargetCatId?.[catId]?.actionSuccessorFence; - if (!actionSuccessorFencesMatch(carrierFence, options.actionSuccessorTerminalFence)) continue; - if (!actionSuccessorTerminalFenceByTargetCatId[catId]) { - actionSuccessorTerminalFenceByTargetCatId[catId] = { ...options.actionSuccessorTerminalFence }; - actionSuccessorTerminalChanged = true; - } - } - } - const awakenedInvocationIdByCatId = withoutSelectedEntries(current.awakenedInvocationIdByCatId, selected); - const awakenedAtByCatId = withoutSelectedEntries(current.awakenedAtByCatId, selected); - const steeredInvocationIdByCatId = withoutSelectedEntries(current.steeredInvocationIdByCatId, selected); - const carrierStateByTargetCatId = withoutSelectedEntries(current.carrierStateByTargetCatId, selected); - const reminderSettlement = settleSelectedReminderAttempts(current.reminderAttempts, selected, withdrawnAt); - const targetAttemptSettlement = settleSelectedTargetAttempts(current.targetAttempts, selected, withdrawnAt); - const activeStateChanged = - withdrawnNow.length > 0 || - reminderSettlement.changed || - targetAttemptSettlement.changed || - includesSelected(current.notifiedByCatIds, selected) || - includesSelected(current.failedByCatIds, selected) || - hasSelectedEntry(current.awakenedInvocationIdByCatId, selected) || - hasSelectedEntry(current.steeredInvocationIdByCatId, selected) || - hasSelectedEntry(current.carrierStateByTargetCatId, selected) || - includesSelected(current.steerRequestedByCatIds, selected) || - actionSuccessorTerminalChanged; - if (!activeStateChanged && !options.forceRevision) return current; - - const { - processingStartedAt: _processingStartedAt, - awakenedInvocationIdByCatId: _awakenedInvocationIdByCatId, - awakenedAtByCatId: _awakenedAtByCatId, - steerRequestedByCatIds: _steerRequestedByCatIds, - steeredInvocationIdByCatId: _steeredInvocationIdByCatId, - carrierStateByTargetCatId: _carrierStateByTargetCatId, - withdrawnByCatIds: _withdrawnByCatIds, - withdrawnAtByCatId: _withdrawnAtByCatId, - actionSuccessorTerminalFenceByTargetCatId: _actionSuccessorTerminalFenceByTargetCatId, - reminderAttempts: _reminderAttempts, - targetAttempts: _targetAttempts, - ...stableCurrent - } = current; - const next: QueuedMessageCustody = { - ...stableCurrent, - revision: current.revision + 1, - status: pendingTargetCats.length === 0 ? 'terminal' : 'queued', - pendingTargetCats, - notifiedByCatIds: current.notifiedByCatIds.filter((catId) => !selected.has(catId)), - ...(Object.keys(awakenedInvocationIdByCatId).length > 0 ? { awakenedInvocationIdByCatId } : {}), - ...(Object.keys(awakenedAtByCatId).length > 0 ? { awakenedAtByCatId } : {}), - failedByCatIds: current.failedByCatIds.filter((catId) => !selected.has(catId)), - withdrawnByCatIds, - withdrawnAtByCatId, - ...(Object.keys(actionSuccessorTerminalFenceByTargetCatId).length > 0 - ? { actionSuccessorTerminalFenceByTargetCatId } - : {}), - ...(Object.keys(carrierStateByTargetCatId).length > 0 ? { carrierStateByTargetCatId } : {}), - ...((current.steerRequestedByCatIds ?? []).some((catId) => !selected.has(catId)) - ? { steerRequestedByCatIds: (current.steerRequestedByCatIds ?? []).filter((catId) => !selected.has(catId)) } - : {}), - ...(Object.keys(steeredInvocationIdByCatId).length > 0 ? { steeredInvocationIdByCatId } : {}), - ...(reminderSettlement.attempts.length > 0 ? { reminderAttempts: reminderSettlement.attempts } : {}), - ...(targetAttemptSettlement.attempts.length > 0 ? { targetAttempts: targetAttemptSettlement.attempts } : {}), - updatedAt: withdrawnAt, - }; - return next; -} - -/** True recall withdraws every target that was not already durably handled. */ -export function terminalizeRecalledQueueCustody( - current: QueuedMessageCustody, - recalledAt: number, -): QueuedMessageCustody { - return settleQueueCustodyWithdrawal(current, current.allTargetCats, recalledAt, { forceRevision: true }); -} - -export class QueueCustodyParseError extends Error { - readonly code = 'INVALID_QUEUE_CUSTODY'; -} - -export function parseQueuedMessageCustody(raw: string | undefined): QueuedMessageCustody | undefined { - if (!raw) return undefined; - try { - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') throw new Error('queue custody must be an object'); - const custody = normalizePreExposurePersistedCustody(parsed as QueuedMessageCustody); - assertQueuedMessageCustody(custody); - return cloneQueuedMessageCustody(custody); - } catch (error) { - throw new QueueCustodyParseError( - `invalid persisted queue custody: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/packages/api/src/domains/cats/services/stores/ports/queued-message-receipt.ts b/packages/api/src/domains/cats/services/stores/ports/queued-message-receipt.ts deleted file mode 100644 index 63c5141e99..0000000000 --- a/packages/api/src/domains/cats/services/stores/ports/queued-message-receipt.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { - QueueMessageReceipt, - QueueReminderAttempt, - QueueReminderMissedReason, - QueueTargetOutcome, -} from '@cat-cafe/shared'; -import { cloneQueuedMessageCustody, type QueuedMessageCustody } from './queued-message-custody.js'; - -export interface RequestReminderAttemptInput { - id: string; - targetCatId: string; - invocationId: string; - requestedAt: number; -} - -interface QueueReceiptProjectionSets { - handled: ReadonlySet; - withdrawn: ReadonlySet; - steering: Readonly>; - steeringRequested: ReadonlySet; - failed: ReadonlySet; - seen: ReadonlySet; - awakened: Readonly>; - notified: ReadonlySet; -} - -function latestExposure( - custody: QueuedMessageCustody, - catId: string, - invocationId?: string, -): NonNullable[number] | undefined { - let latest: NonNullable[number] | undefined; - for (const exposure of custody.bodyExposures ?? []) { - if (exposure.targetCatId !== catId) continue; - if (invocationId !== undefined && exposure.invocationId !== invocationId) continue; - // Exposure history is append-only. When two reads share one millisecond, - // the later appended child is the deterministic successor. - if (!latest || exposure.seenAt >= latest.seenAt) latest = exposure; - } - return latest; -} - -function targetAttempts(custody: QueuedMessageCustody, catId: string) { - const attempts = (custody.targetAttempts ?? []) - .filter((attempt) => attempt.targetCatId === catId) - .sort((left, right) => left.sequence - right.sequence) - .map((attempt) => ({ ...attempt })); - return attempts.length > 0 ? { attempts } : {}; -} - -function latestTargetAttempt(custody: QueuedMessageCustody, catId: string) { - return (custody.targetAttempts ?? []) - .filter((attempt) => attempt.targetCatId === catId) - .sort((left, right) => left.sequence - right.sequence) - .at(-1); -} - -function projectQueueReceiptTarget( - custody: QueuedMessageCustody, - catId: string, - state: QueueReceiptProjectionSets, -): QueueMessageReceipt['targets'][number] { - const outcome: QueueTargetOutcome | undefined = custody.targetOutcomeByCatId?.[catId]; - const authorIntent = custody.authorIntentByCatId?.[catId]; - const authorIntentProjection = authorIntent - ? { - authorIntent: { - ...authorIntent, - effective: authorIntent.fallbackAt === undefined ? authorIntent.requested : ('next_work' as const), - }, - } - : {}; - const attemptsProjection = targetAttempts(custody, catId); - if (state.handled.has(catId)) { - const exposure = outcome ? latestExposure(custody, catId, outcome.invocationId) : undefined; - return { - catId, - state: 'handled', - ...authorIntentProjection, - ...attemptsProjection, - ...(exposure ? { invocationId: exposure.invocationId, seenAt: exposure.seenAt } : {}), - ...(outcome ? { outcome } : {}), - }; - } - if (state.withdrawn.has(catId)) { - const exposure = latestExposure(custody, catId); - const actionSuccessorRetired = custody.actionSuccessorTerminalFenceByTargetCatId?.[catId] !== undefined; - return { - catId, - state: 'withdrawn', - ...authorIntentProjection, - ...attemptsProjection, - ...(actionSuccessorRetired ? { retryable: false } : {}), - withdrawnAt: custody.withdrawnAtByCatId?.[catId], - ...(exposure ? { invocationId: exposure.invocationId, seenAt: exposure.seenAt } : {}), - }; - } - if (state.steering[catId]) { - const exposure = latestExposure(custody, catId, state.steering[catId]); - return { - catId, - state: 'steering', - ...authorIntentProjection, - ...attemptsProjection, - invocationId: state.steering[catId], - ...(exposure ? { seenAt: exposure.seenAt } : {}), - }; - } - if (state.steeringRequested.has(catId)) - return { catId, state: 'steering', ...authorIntentProjection, ...attemptsProjection }; - if (state.failed.has(catId)) { - const exposure = latestExposure(custody, catId); - const awakenedInvocationId = custody.awakenedInvocationIdByCatId?.[catId]; - const awakenedAt = custody.awakenedAtByCatId?.[catId]; - const interruptedAttempt = latestTargetAttempt(custody, catId); - const interrupted = interruptedAttempt?.state === 'interrupted'; - const retryable = !custody.carrierByTargetCatId || custody.carrierByTargetCatId[catId] !== undefined; - return { - catId, - state: interrupted ? 'interrupted' : 'failed', - ...authorIntentProjection, - ...attemptsProjection, - ...(interrupted || retryable ? {} : { retryable: false }), - ...(exposure - ? { invocationId: exposure.invocationId, seenAt: exposure.seenAt } - : interruptedAttempt?.invocationId - ? { invocationId: interruptedAttempt.invocationId } - : awakenedInvocationId - ? { invocationId: awakenedInvocationId, ...(awakenedAt !== undefined ? { awakenedAt } : {}) } - : {}), - }; - } - if (state.seen.has(catId)) { - const invocationId = custody.seenInvocationIdByCatId[catId]; - const exposure = invocationId ? latestExposure(custody, catId, invocationId) : latestExposure(custody, catId); - return { - catId, - state: 'seen', - ...authorIntentProjection, - ...attemptsProjection, - ...(invocationId ? { invocationId } : exposure ? { invocationId: exposure.invocationId } : {}), - ...(exposure ? { seenAt: exposure.seenAt } : {}), - }; - } - if (state.awakened[catId]) { - const awakenedAt = custody.awakenedAtByCatId?.[catId]; - return { - catId, - state: 'awakened', - ...authorIntentProjection, - ...attemptsProjection, - invocationId: state.awakened[catId], - ...(awakenedAt !== undefined ? { awakenedAt } : {}), - }; - } - if (state.notified.has(catId)) return { catId, state: 'notified', ...authorIntentProjection, ...attemptsProjection }; - return { catId, state: 'queued', ...authorIntentProjection, ...attemptsProjection }; -} - -export function projectQueueReceipt(custody: QueuedMessageCustody): QueueMessageReceipt { - const state: QueueReceiptProjectionSets = { - handled: new Set(custody.handledByCatIds), - withdrawn: new Set(custody.withdrawnByCatIds ?? []), - steering: custody.steeredInvocationIdByCatId ?? {}, - steeringRequested: new Set(custody.steerRequestedByCatIds ?? []), - failed: new Set(custody.failedByCatIds), - seen: new Set(custody.seenByCatIds), - awakened: custody.awakenedInvocationIdByCatId ?? {}, - notified: new Set(custody.notifiedByCatIds), - }; - return { - version: 1, - entryId: custody.entryId, - ...(custody.receiptScope ? { scope: custody.receiptScope } : {}), - targets: custody.allTargetCats.map((catId) => projectQueueReceiptTarget(custody, catId, state)), - reminderAttempts: (custody.reminderAttempts ?? []).map((attempt) => ({ ...attempt })), - }; -} - -export function requestReminderAttempt( - custody: QueuedMessageCustody, - input: RequestReminderAttemptInput, -): QueuedMessageCustody { - if (!custody.allTargetCats.includes(input.targetCatId as (typeof custody.allTargetCats)[number])) { - throw new Error('reminder target cat is not part of queue custody'); - } - const attempts = custody.reminderAttempts ?? []; - if ( - attempts.some((attempt) => attempt.targetCatId === input.targetCatId && attempt.invocationId === input.invocationId) - ) { - return custody; - } - const next = cloneQueuedMessageCustody(custody); - next.reminderAttempts = [ - ...(next.reminderAttempts ?? []), - { - id: input.id, - targetCatId: input.targetCatId, - invocationId: input.invocationId, - state: 'requested', - requestedAt: input.requestedAt, - }, - ]; - return next; -} - -function updateReminderAttempts( - custody: QueuedMessageCustody, - update: (attempt: QueueReminderAttempt) => QueueReminderAttempt, -): QueuedMessageCustody { - let changed = false; - const attempts = (custody.reminderAttempts ?? []).map((attempt) => { - const next = update(attempt); - changed = changed || next !== attempt; - return next; - }); - if (!changed) return custody; - const next = cloneQueuedMessageCustody(custody); - next.reminderAttempts = attempts; - return next; -} - -export function markReminderAttemptDelivered( - custody: QueuedMessageCustody, - targetCatId: string, - invocationId: string, - deliveredAt: number, -): QueuedMessageCustody { - return updateReminderAttempts(custody, (attempt) => - attempt.targetCatId === targetCatId && attempt.invocationId === invocationId && attempt.state === 'requested' - ? { ...attempt, state: 'delivered', deliveredAt } - : attempt, - ); -} - -export function markReminderAttemptSeen( - custody: QueuedMessageCustody, - targetCatId: string, - invocationId: string, - seenAt: number, -): QueuedMessageCustody { - return updateReminderAttempts(custody, (attempt) => { - if ( - attempt.targetCatId !== targetCatId || - attempt.invocationId !== invocationId || - (attempt.state !== 'requested' && attempt.state !== 'delivered') - ) { - return attempt; - } - return { - ...attempt, - state: 'seen', - ...(attempt.deliveredAt !== undefined ? { deliveredAt: attempt.deliveredAt } : {}), - seenAt, - }; - }); -} - -export function markReminderAttemptMissed( - custody: QueuedMessageCustody, - invocationId: string, - missedAt: number, -): QueuedMessageCustody { - return updateReminderAttempts(custody, (attempt) => { - if (attempt.invocationId !== invocationId || (attempt.state !== 'requested' && attempt.state !== 'delivered')) { - return attempt; - } - const missedReason: QueueReminderMissedReason = - attempt.state === 'delivered' ? 'delivered_not_read' : 'invocation_ended_before_delivery'; - return { ...attempt, state: 'missed', missedAt, missedReason }; - }); -} diff --git a/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts b/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts index 4c8a02a9c6..f43f55b5d2 100644 --- a/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts +++ b/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts @@ -29,8 +29,8 @@ export const MessageKeys = { ownerComposerDraft: (ownerUserId: string, threadId: string) => `msg:composer-draft:${encodeURIComponent(ownerUserId)}:${encodeURIComponent(threadId)}`, - /** F264 Gap F: content-free reverse index from exact child exposure to source message IDs. */ - queueExposureIndex: (threadId: string) => `msg:queue-exposures:${threadId}`, + /** Legacy F264 index retained only so thread deletion removes pre-migration data. */ + legacyQueueExposureIndex: (threadId: string) => `msg:queue-exposures:${threadId}`, /** * #1200 Visibility index: per-thread sorted set, member=messageId, score=visibilitySeq. diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts index 4f78f8c512..4ba9a002ca 100644 --- a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts @@ -1,8 +1,3 @@ -import { - assertTypedWaitCustodyBindings, - rejectTypedWaitCustody, -} from '../../../../ball-custody/TypedWaitCustodyGuard.js'; -import { ASSERT_TYPED_WAIT_CUSTODY_LUA, readRedisTypedWaitCustodyGuards } from './RedisTypedWaitCustodyGuard.js'; /** * Redis Message Store * Redis-backed message storage with same interface as in-memory MessageStore. @@ -17,20 +12,42 @@ import { ASSERT_TYPED_WAIT_CUSTODY_LUA, readRedisTypedWaitCustodyGuards } from ' * 消息 TTL 可配置 (默认 7 天)。 */ -import type { CatId, CustodyOfferV1 } from '@cat-cafe/shared'; -import { custodyOfferV1Schema } from '@cat-cafe/shared'; +import { isDeepStrictEqual } from 'node:util'; +import type { CatId, CustodyOfferV1, LifecycleStoredMessageMetadata } from '@cat-cafe/shared'; +import { custodyOfferV1Schema, isLifecycleStoredMessageMetadata } from '@cat-cafe/shared'; import type { RedisClient } from '@cat-cafe/shared/utils'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { normalizeJsonUnicode } from '../../../../../utils/json-unicode.js'; +import { + assertQueueLedgerEntry, + type QueueLedgerEntry, + type QueueLedgerStore, + queueLedgerAdmissionsMatch, +} from '../../agents/invocation/queue-ledger/QueueLedger.js'; +import { QueueLedgerKeys } from '../../agents/invocation/queue-ledger/queue-ledger-keys.js'; +import { + hydrateQueueLedgerEntry, + RedisQueueLedgerStore, +} from '../../agents/invocation/queue-ledger/RedisQueueLedgerStore.js'; import { cursorFor, parseCursor } from '../cursor.js'; +import { messageFrom } from '../message-from.js'; import type { + AdvanceLifecycleInputDispatchResult, AppendMessageInput, BoundedThreadMessagePage, ClearOwnerComposerDraftResult, + CommitLifecycleAppendAdmissionResult, + CommitLifecycleAppendRejectionResult, + CommitLifecyclePreAdmissionFailureResult, + CommitLifecycleResponseTerminalResult, CustodyOfferTransitionInput, CustodyOfferTransitionResult, HostMessageExtra, IdempotentAppendResult, + LifecycleAppendAdmissionInput, + LifecycleAppendRejectionInput, + LifecycleInputDispatchPatch, + LifecyclePreAdmissionFailureInput, + LifecycleResponseTerminalPatch, MarkCanceledResult, MarkDeliveredResult, MessageAppendListener, @@ -42,13 +59,9 @@ import type { PutOwnerComposerDraftInput, PutOwnerComposerDraftResult, QueueAdmissionPrepareResult, - QueueCustodyAdmissionInitializeResult, - QueueCustodyAdmissionIntent, - QueueCustodyInitializeResult, - QueueCustodyLifecycleRecord, - QueueCustodyTransitionInput, - QueueCustodyTransitionResult, - QueuedMessageCustody, + QueueLedgerAdmissionFactory, + QueueLedgerLifecycleAdmissionResult, + QueueLedgerMessageAdmissionResult, RecallMessageToComposerDraftInput, RecallMessageToComposerDraftResult, StoredMessage, @@ -61,21 +74,29 @@ import type { ThreadUnreadProjectionCursor, } from '../ports/MessageStore.js'; import { + advanceLifecycleInputDispatchMetadata, applyStreamMetadataAugment, - assertValidAppendMessageInput, assertValidStoredMessageTimestamp, + assignLifecycleDispatchTargetsMetadata, COORDINATION_TERMINAL_SCAN_PAGE_SIZE, + canonicalizeAppendMessageInput, DEFAULT_THREAD_ID, deriveGrowingSourceMessageRevision, generateSortableId, isDelivered, isValidCustodyOfferTransition, + lifecycleInputIdentityForStoredMessage, + matchesLifecyclePreAdmissionFailure, + mergeMessageExtra, + preAdmissionFailureIdempotencyKey, + prepareLifecycleAppendAdmission, + prepareLifecycleAppendRejection, + prepareLifecycleResponseTerminalMessage, + prepareLifecycleResponseTerminalWithLedgerTargets, + prepareQueueLedgerMessageAdmission, + prepareQueueLedgerSourceLifecycle, + settleAssignedLifecycleDispatchFailureMetadata, } from '../ports/MessageStore.js'; -import { - assertQueueCustodyMessageBinding, - assertQueueCustodyTransition, - terminalizeRecalledQueueCustody, -} from '../ports/queued-message-custody.js'; import { MessageKeys } from '../redis-keys/message-keys.js'; import { isDurableOwnerReadEvidence, @@ -86,7 +107,7 @@ import { resolveThreadMessageVisibility, } from '../visibility.js'; import { appendMessage } from './redis-message-append.js'; -import { CANCEL_LUA, DELIVER_LUA, REASSIGN_LUA } from './redis-message-delivery-lua-scripts.js'; +import { CANCEL_LUA, REASSIGN_LUA } from './redis-message-delivery-lua-scripts.js'; import { appendMessageAndObservePriorFrontier, appendMessageIfThreadFrontier, @@ -95,13 +116,14 @@ import { parseConnectorSourceField, safeParseContentBlocks, safeParseExtra, + safeParseLegacyLifecycleMessageFrom, + safeParseLifecycleMetadata, safeParseMentions, + safeParseMessageFrom, safeParseMessageRecall, safeParseMetadata, safeParsePawFeelSourceExtra, safeParsePluginMessage, - safeParseQueueCustody, - safeParseQueueCustodyAdmission, safeParseToolEvents, serializeExtra, } from './redis-message-parsers.js'; @@ -131,36 +153,14 @@ const PAW_FEEL_SOURCE_FIELDS = [ 'deliveryStatus', 'origin', 'source', - 'queueCustody', 'extra', ] as const; type PawFeelSourcePipelineResult = [Error | null, Array | null]; -const REDIS_NUMBER_ALIASES = new Map([ - ['', Number.NaN], - ['inf', Number.POSITIVE_INFINITY], - ['+inf', Number.POSITIVE_INFINITY], - ['-inf', Number.NEGATIVE_INFINITY], -]); - -/** Decode both Redis ZSCORE spellings and JavaScript number spellings without truncation. */ -function parseRedisNumber(raw: string): number { - const value = raw.trim(); - return REDIS_NUMBER_ALIASES.get(value) ?? Number(value); -} - -function parseStoredMessageTimestamp(raw: string | undefined): number { - return parseRedisNumber(raw ?? '0'); -} - function parseOptionalRedisNumber(raw: string | null): number | undefined { return raw === null ? undefined : parseRedisNumber(raw); } -function parsePawFeelSourceOrigin(raw: string | null): StoredMessage['origin'] | undefined { - return raw === 'stream' || raw === 'callback' || raw === 'briefing' ? raw : undefined; -} - function projectPawFeelSourceTiming( deliveredAt: string | null, timelineOrderAt: string | null, @@ -173,21 +173,10 @@ function projectPawFeelSourceTiming( > = {}; const parsedDeliveredAt = parseOptionalRedisNumber(deliveredAt); const parsedTimelineOrderAt = parseOptionalRedisNumber(timelineOrderAt); - const parsedOrigin = parsePawFeelSourceOrigin(origin); if (parsedDeliveredAt !== undefined) projection.deliveredAt = parsedDeliveredAt; if (parsedTimelineOrderAt !== undefined) projection.timelineOrderAt = parsedTimelineOrderAt; if (deliveryStatus) projection.deliveryStatus = deliveryStatus as StoredMessage['deliveryStatus']; - if (parsedOrigin) projection.origin = parsedOrigin; - return projection; -} - -function projectPawFeelSourcePresence( - source: string | null, - queueCustody: string | null, -): Pick { - const projection: Pick = {}; - if (parseConnectorSourceField(source ?? undefined).kind === 'valid') projection.source = true; - if (safeParseQueueCustody(queueCustody ?? undefined)) projection.queueCustody = true; + if (origin === 'stream' || origin === 'callback' || origin === 'briefing') projection.origin = origin; return projection; } @@ -204,7 +193,6 @@ function hydratePawFeelSourceProjection(fields: readonly (string | null)[]): Paw deliveryStatus, origin, source, - queueCustody, extra, ] = fields; if (!id) return null; @@ -217,49 +205,147 @@ function hydratePawFeelSourceProjection(fields: readonly (string | null)[]): Paw content: content ?? '', timestamp: parseStoredMessageTimestamp(timestamp ?? undefined), ...projectPawFeelSourceTiming(deliveredAt, timelineOrderAt, deliveryStatus, origin), - ...projectPawFeelSourcePresence(source, queueCustody), + ...(parseConnectorSourceField(source ?? undefined).kind === 'valid' ? { source: true } : {}), ...(parsedExtra ? { extra: parsedExtra } : {}), }; } -const INITIALIZE_QUEUE_CUSTODY_LUA = ` -local messageId = redis.call('HGET', KEYS[1], 'id') -if not messageId then - return -1 + +type ReservedAppendResult = + | { outcome: 'stored'; message: StoredMessage; replayed: boolean } + | { outcome: 'queue_full' | 'queue_conflict' }; + +interface QueueAppendOptions { + entries: readonly QueueLedgerEntry[]; + maxQueuedUserEntries?: number; +} + +const ENQUEUE_EXISTING_MESSAGE_WITH_LEDGER_LUA = ` +local messageKey = KEYS[1] +local rowsKey = KEYS[2] +local orderKey = KEYS[3] +local messageIndexKey = KEYS[4] +local messageId = ARGV[1] +local maxUserSources = tonumber(ARGV[2]) +local count = tonumber(ARGV[3]) +if redis.call('HGET', messageKey, 'id') ~= messageId then return -2 end +if not count or count < 1 then return redis.error_reply('QUEUE_ADMISSION_EMPTY') end + +local incoming = {} +local entryIds = {} +local existingCount = 0 +local incomingIds = {} +local incomingTargets = {} +local incomingUserSources = {} +for i = 1, count do + local raw = ARGV[3 + i] + local row = cjson.decode(raw) + if row.status ~= 'queued' or row.payload.sourceRecordId ~= messageId or row.payload.messageId ~= messageId then + return redis.error_reply('QUEUE_ADMISSION_INVALID_ROW') + end + if incomingIds[row.id] then return redis.error_reply('QUEUE_ADMISSION_DUPLICATE_ID') end + incomingIds[row.id] = true + incoming[i] = { id = row.id, raw = raw, row = row } + entryIds[i] = row.id + for _, targetId in ipairs(row.targets or {}) do incomingTargets[targetId] = true end + if redis.call('HEXISTS', rowsKey, row.id) == 1 then existingCount = existingCount + 1 end + if row.from and row.from.kind == 'user' then incomingUserSources[row.payload.sourceRecordId] = true end end -if redis.call('HGET', KEYS[1], 'deliveryStatus') ~= 'queued' then - return -2 +if existingCount > 0 and existingCount ~= count then return -1 end + +local incomingLifecycleRaw = ARGV[4 + count] +local incomingLifecycleOk, incomingLifecycle = pcall(cjson.decode, incomingLifecycleRaw or '') +if not incomingLifecycleOk or type(incomingLifecycle) ~= 'table' then + return redis.error_reply('QUEUE_ADMISSION_INVALID_LIFECYCLE') end -local existing = redis.call('HGET', KEYS[1], 'queueCustody') -if existing and existing ~= '' then - redis.call('HDEL', KEYS[1], 'queueCustodyAdmission') - return 0 +local storedLifecycleRaw = redis.call('HGET', messageKey, 'lifecycle') +local lifecycleNeedsSet = not storedLifecycleRaw or storedLifecycleRaw == '' +if not lifecycleNeedsSet then + local storedLifecycleOk, storedLifecycle = pcall(cjson.decode, storedLifecycleRaw) + if + not storedLifecycleOk or + type(storedLifecycle) ~= 'table' or + storedLifecycle.kind == 'delivery_failure' or + storedLifecycle.orderKey ~= incomingLifecycle.orderKey or + storedLifecycle.producerInvocationId ~= incomingLifecycle.producerInvocationId + then + return -4 + end + for _, dispatchRef in ipairs(storedLifecycle.dispatchRefs or {}) do + if incomingTargets[dispatchRef.targetId] then return -5 end + end end -redis.call('HSET', KEYS[1], 'queueCustody', ARGV[1], 'queueCustodyRevision', ARGV[2]) -redis.call('HDEL', KEYS[1], 'queueCustodyAdmission') -return 1 -`; -const INITIALIZE_QUEUE_CUSTODY_ADMISSION_LUA = ` -local messageId = redis.call('HGET', KEYS[1], 'id') -if not messageId then - return -1 -end -if redis.call('HGET', KEYS[1], 'deliveryStatus') ~= 'queued' then - return -2 +if existingCount == count then + if lifecycleNeedsSet then redis.call('HSET', messageKey, 'lifecycle', incomingLifecycleRaw) end + return 2 end -local custody = redis.call('HGET', KEYS[1], 'queueCustody') -if custody and custody ~= '' then +if redis.call('HEXISTS', messageIndexKey, messageId) == 1 then return -1 end + +local deliveryStatus = redis.call('HGET', messageKey, 'deliveryStatus') +local preservePublishedAgent = false +if not deliveryStatus or deliveryStatus == '' then + local rawFrom = redis.call('HGET', messageKey, 'from') + local decoded, storedFrom = pcall(cjson.decode, rawFrom or '') + if not decoded or type(storedFrom) ~= 'table' then return -3 end + preservePublishedAgent = storedFrom.kind == 'agent' +elseif deliveryStatus ~= 'queued' then return -3 end -local existing = redis.call('HGET', KEYS[1], 'queueCustodyAdmission') -if existing and existing ~= '' then - if existing == ARGV[1] then return 0 end - return -3 + +if maxUserSources and maxUserSources >= 0 then + local queuedUserSources = {} + local activeIds = redis.call('LRANGE', orderKey, 0, -1) + for i = 1, #activeIds do + local currentRaw = redis.call('HGET', rowsKey, activeIds[i]) + if not currentRaw then return redis.error_reply('QUEUE_ORDER_ROW_MISSING') end + local row = cjson.decode(currentRaw) + if row.status == 'queued' and row.from and row.from.kind == 'user' then + queuedUserSources[row.payload.sourceRecordId] = true + end + end + for sourceId, _ in pairs(incomingUserSources) do queuedUserSources[sourceId] = true end + local queuedUserCount = 0 + for _, _ in pairs(queuedUserSources) do queuedUserCount = queuedUserCount + 1 end + if queuedUserCount > maxUserSources then return 0 end +end + +if lifecycleNeedsSet then redis.call('HSET', messageKey, 'lifecycle', incomingLifecycleRaw) end +if not preservePublishedAgent then redis.call('HSET', messageKey, 'deliveryStatus', 'queued') end +for i = 1, count do + redis.call('HSET', rowsKey, incoming[i].id, incoming[i].raw) + redis.call('RPUSH', orderKey, incoming[i].id) end -redis.call('HSET', KEYS[1], 'queueCustodyAdmission', ARGV[1]) +redis.call('HSET', messageIndexKey, messageId, cjson.encode(entryIds)) return 1 `; +const HARD_DELETE_MESSAGE_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end +if redis.call('HGET', KEYS[1], '_tombstone') == '1' then return 2 end +redis.call('HSET', KEYS[1], + 'content', '', 'contentBlocks', '', 'toolEvents', '', 'metadata', '', + 'extra', '', 'pluginMessage', '', 'lifecycle', '', 'thinking', '', 'mentions', '[]', + 'deletedAt', ARGV[1], 'deletedBy', ARGV[2], '_tombstone', '1') +redis.call('HDEL', KEYS[1], 'from', 'routingFact', 'provenance') +return 1 +`; + +const REDIS_NUMBER_ALIASES = new Map([ + ['', Number.NaN], + ['inf', Number.POSITIVE_INFINITY], + ['+inf', Number.POSITIVE_INFINITY], + ['-inf', Number.NEGATIVE_INFINITY], +]); + +/** Decode both Redis ZSCORE spellings and JavaScript number spellings without truncation. */ +function parseRedisNumber(raw: string): number { + const value = raw.trim(); + return REDIS_NUMBER_ALIASES.get(value) ?? Number(value); +} + +function parseStoredMessageTimestamp(raw: string | undefined): number { + return parseRedisNumber(raw ?? '0'); +} const PREPARE_QUEUE_ADMISSION_LUA = ` local messageId = redis.call('HGET', KEYS[1], 'id') if not messageId then @@ -269,128 +355,454 @@ local deliveryStatus = redis.call('HGET', KEYS[1], 'deliveryStatus') if deliveryStatus == 'queued' then return 0 end -local custody = redis.call('HGET', KEYS[1], 'queueCustody') -if deliveryStatus or (custody and custody ~= '') then +if deliveryStatus then return -2 end redis.call('HSET', KEYS[1], 'deliveryStatus', 'queued') return 1 `; -const TRANSITION_QUEUE_CUSTODY_LUA = ` -local messageId = redis.call('HGET', KEYS[1], 'id') -local custody = redis.call('HGET', KEYS[1], 'queueCustody') -if not messageId or not custody then - return {-1, -1} -end -local currentRevision = tonumber(redis.call('HGET', KEYS[1], 'queueCustodyRevision') or '0') -if currentRevision ~= tonumber(ARGV[1]) then - return {0, currentRevision} -end -local deliveryStatus = redis.call('HGET', KEYS[1], 'deliveryStatus') -local recallRaw = redis.call('HGET', KEYS[1], 'recall') -local recallExposed = false -if recallRaw and recallRaw ~= '' then - local okRecall, recall = pcall(cjson.decode, recallRaw) - recallExposed = okRecall and type(recall) == 'table' and recall.exposure == 'seen' +const UPDATE_PLUGIN_MESSAGE_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end +local current = nil +local raw = redis.call('HGET', KEYS[1], 'pluginMessage') +if raw and raw ~= '' then + local ok, decoded = pcall(cjson.decode, raw) + if ok then current = decoded end +else + local legacyRaw = redis.call('HGET', KEYS[1], 'extra') + if legacyRaw and legacyRaw ~= '' then + local ok, decoded = pcall(cjson.decode, legacyRaw) + if ok then current = decoded.pluginMessage end + end end -local exposedRecallSettlement = deliveryStatus == 'canceled' and recallExposed and ARGV[4] == '' -if deliveryStatus ~= 'queued' and not exposedRecallSettlement then - return {-2, currentRevision} +if not current or tonumber(current.revision) ~= tonumber(ARGV[2]) then return -1 end +redis.call('HSET', KEYS[1], 'pluginMessage', ARGV[1]) +return 1 +`; + +const COMMIT_LIFECYCLE_RESPONSE_TERMINAL_LUA = ` +local messageKey = KEYS[1] +if redis.call('EXISTS', messageKey) == 0 then return -1 end +if redis.call('HGET', messageKey, 'recall') or redis.call('HGET', messageKey, '_tombstone') then return -2 end +local existingRaw = redis.call('HGET', messageKey, 'lifecycle') +if not existingRaw or existingRaw == '' then return -2 end +local ok, existing = pcall(cjson.decode, existingRaw) +if not ok or type(existing) ~= 'table' or existing.kind ~= 'response' then return -2 end +if existing.invocationId ~= ARGV[1] then return -3 end + +local function sameField(field, expected) + local actual = redis.call('HGET', messageKey, field) + if actual == false then actual = '' end + return actual == expected end -local nextRevision = tonumber(ARGV[3]) -local okNextCustody, nextCustody = pcall(cjson.decode, ARGV[2]) -if not okNextCustody or type(nextCustody) ~= 'table' then return redis.error_reply('INVALID_NEXT_QUEUE_CUSTODY') end -${ASSERT_TYPED_WAIT_CUSTODY_LUA} +local lifecycleNeedsWrite = false +if existing.status == 'processing' then + -- A child dispatch can settle while its parent response is still processing. + -- Never replace that newer lifecycle snapshot with a terminal state prepared + -- from an older read; make the caller re-read and preserve the child transition. + if existingRaw ~= ARGV[14] then return 0 end + lifecycleNeedsWrite = true +elseif not (existingRaw == ARGV[2] and + sameField('content', ARGV[3]) and + sameField('contentBlocks', ARGV[4]) and + sameField('toolEvents', ARGV[5]) and + sameField('metadata', ARGV[6]) and + sameField('extra', ARGV[7]) and + sameField('thinking', ARGV[8]) and + sameField('origin', ARGV[9]) and + sameField('mentions', ARGV[10]) and + sameField('mentionsUser', ARGV[11]) and + sameField('replyTo', ARGV[12]) and + sameField('pluginMessage', ARGV[13])) then + return -4 +end --- #1269 R8 P1-3: pre-mutation guard — compute visibilitySeq BEFORE any HSET/ZADD. --- redis.error_reply() does NOT rollback prior writes, so all validation must --- precede all mutations. Previously custody HSET was before HWM validation, --- meaning a corrupt HWM would leave custody already mutated. +-- Validate the visibility allocator before the first mutation. The response +-- placeholder is intentionally absent from cursor reads until its terminal body +-- is durable, so lifecycle + publication share this one linearization point. local seq = nil -local kp = ARGV[6] -local threadId = nil -if ARGV[4] ~= '' then - threadId = redis.call('HGET', KEYS[1], 'threadId') - local existingVis = redis.call('HGET', KEYS[1], 'visibilitySeq') - if existingVis == false and threadId then - local metaKey = kp .. 'msg:visibility-meta:' .. threadId - local hwmRaw = redis.call('HGET', metaKey, 'hwm') - local hwm = 0 - if hwmRaw ~= false then - hwm = tonumber(hwmRaw) - if hwm == nil then - return redis.error_reply('VISIBILITY_HWM_UNPARSEABLE: raw=' .. tostring(hwmRaw) .. ' metaKey=' .. metaKey) - end - if hwm ~= hwm then - return redis.error_reply('VISIBILITY_HWM_NAN: metaKey=' .. metaKey) - end - if hwm ~= math.floor(hwm) or hwm < 0 then - return redis.error_reply('VISIBILITY_HWM_INVALID: hwm=' .. tostring(hwm) .. ' metaKey=' .. metaKey) - end +local existingVis = redis.call('HGET', messageKey, 'visibilitySeq') +if not existingVis or existingVis == '' then + local hwmRaw = redis.call('HGET', KEYS[3], 'hwm') + local hwm = 0 + if hwmRaw ~= false then + hwm = tonumber(hwmRaw) + if hwm == nil or hwm ~= hwm or hwm ~= math.floor(hwm) or hwm < 0 then + return redis.error_reply('VISIBILITY_HWM_INVALID: raw=' .. tostring(hwmRaw)) end - local t = redis.call('TIME') - local now_ms = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000) - seq = math.max(hwm + 1, now_ms) - if seq > 9007199254730991 then - return redis.error_reply('VISIBILITY_SEQ_EXHAUSTED: seq=' .. tostring(seq)) + end + local timeArr = redis.call('TIME') + local nowMs = tonumber(timeArr[1]) * 1000 + math.floor(tonumber(timeArr[2]) / 1000) + seq = math.max(hwm + 1, nowMs) + if seq > 9007199254730991 then + return redis.error_reply('VISIBILITY_SEQ_EXHAUSTED: seq=' .. tostring(seq)) + end +end + +local function replaceOptional(field, value) + if value == '' then redis.call('HDEL', messageKey, field) + else redis.call('HSET', messageKey, field, value) end +end +if lifecycleNeedsWrite then + redis.call('HSET', messageKey, + 'lifecycle', ARGV[2], + 'content', ARGV[3], + 'mentions', ARGV[10]) + replaceOptional('contentBlocks', ARGV[4]) + replaceOptional('toolEvents', ARGV[5]) + replaceOptional('metadata', ARGV[6]) + replaceOptional('extra', ARGV[7]) + replaceOptional('thinking', ARGV[8]) + replaceOptional('origin', ARGV[9]) + replaceOptional('mentionsUser', ARGV[11]) + replaceOptional('replyTo', ARGV[12]) + replaceOptional('pluginMessage', ARGV[13]) +end +if seq then + local messageId = redis.call('HGET', messageKey, 'id') + redis.call('ZADD', KEYS[2], seq, messageId) + redis.call('HSET', KEYS[3], 'hwm', tostring(seq)) + redis.call('HSET', messageKey, 'visibilitySeq', tostring(seq)) +end +return lifecycleNeedsWrite and 1 or 2 +`; + +/** + * Atomically terminalize an existing response and admit its complete outbound + * Queue fan-out. The caller supplies an exact pre-read ledger snapshot; a + * concurrent claim/commit makes the script return -10 before either key moves. + */ +const COMMIT_LIFECYCLE_RESPONSE_WITH_LEDGER_LUA = ` +local messageKey = KEYS[1] +local rowsKey = KEYS[2] +local orderKey = KEYS[3] +local messageIndexKey = KEYS[4] +local messageId = redis.call('HGET', messageKey, 'id') +if not messageId then return -1 end +if redis.call('HGET', messageKey, 'recall') or redis.call('HGET', messageKey, '_tombstone') then return -2 end + +local existingLifecycleRaw = redis.call('HGET', messageKey, 'lifecycle') +if not existingLifecycleRaw or existingLifecycleRaw == '' then return -2 end +local lifecycleOk, existingLifecycle = pcall(cjson.decode, existingLifecycleRaw) +if not lifecycleOk or type(existingLifecycle) ~= 'table' or existingLifecycle.kind ~= 'response' then return -2 end +if existingLifecycle.invocationId ~= ARGV[1] then return -3 end + +local maxUserSources = tonumber(ARGV[15]) +local count = tonumber(ARGV[16]) +local expectedMode = ARGV[17] +if not count or count < 1 then return redis.error_reply('QUEUE_ADMISSION_EMPTY') end + +local incoming = {} +local entryIds = {} +local incomingIds = {} +local incomingUserSources = {} +for i = 1, count do + local raw = ARGV[17 + i] + local row = cjson.decode(raw) + if row.status ~= 'queued' or not row.payload or row.payload.sourceRecordId ~= messageId or + row.payload.messageId ~= messageId then + return redis.error_reply('QUEUE_ADMISSION_INVALID_ROW') + end + if incomingIds[row.id] then return redis.error_reply('QUEUE_ADMISSION_DUPLICATE_ID') end + incomingIds[row.id] = true + incoming[i] = { id = row.id, raw = raw, row = row } + entryIds[i] = row.id + if row.from and row.from.kind == 'user' then incomingUserSources[row.payload.sourceRecordId] = true end +end + +for i = 1, count do + local currentRaw = redis.call('HGET', rowsKey, incoming[i].id) + if expectedMode == 'absent' then + if currentRaw then return -10 end + elseif expectedMode == 'present' then + local expectedRaw = ARGV[17 + count + i] + if not currentRaw or currentRaw ~= expectedRaw then return -10 end + else + return redis.error_reply('QUEUE_ADMISSION_EXPECTATION_INVALID') + end +end + +if expectedMode == 'absent' and redis.call('HEXISTS', messageIndexKey, messageId) == 1 then return -10 end + +if expectedMode == 'absent' and maxUserSources and maxUserSources >= 0 then + local queuedUserSources = {} + local activeIds = redis.call('LRANGE', orderKey, 0, -1) + for i = 1, #activeIds do + local currentRaw = redis.call('HGET', rowsKey, activeIds[i]) + if not currentRaw then return redis.error_reply('QUEUE_ORDER_ROW_MISSING') end + local row = cjson.decode(currentRaw) + if row.status == 'queued' and row.from and row.from.kind == 'user' then + queuedUserSources[row.payload.sourceRecordId] = true end end + for sourceId, _ in pairs(incomingUserSources) do queuedUserSources[sourceId] = true end + local queuedUserCount = 0 + for _, _ in pairs(queuedUserSources) do queuedUserCount = queuedUserCount + 1 end + if queuedUserCount > maxUserSources then return 0 end +end + +local function sameField(field, expected) + local actual = redis.call('HGET', messageKey, field) + if actual == false then actual = '' end + return actual == expected end --- All validation passed — write mutations atomically -redis.call('HSET', KEYS[1], 'queueCustody', ARGV[2], 'queueCustodyRevision', ARGV[3]) -for _, exposure in ipairs(nextCustody.bodyExposures or cjson.decode('[]')) do - local field = tostring(exposure.targetCatId) .. string.char(0) .. tostring(exposure.invocationId) - local existingRaw = redis.call('HGET', KEYS[5], field) - local ids = cjson.decode('[]') - if existingRaw and existingRaw ~= '' then - local okExisting, existing = pcall(cjson.decode, existingRaw) - if okExisting and type(existing) == 'table' then ids = existing end +local lifecycleNeedsWrite = false +if existingLifecycle.status == 'processing' then + if existingLifecycleRaw ~= ARGV[14] then return -10 end + lifecycleNeedsWrite = true +elseif not (existingLifecycleRaw == ARGV[2] and + sameField('content', ARGV[3]) and + sameField('contentBlocks', ARGV[4]) and + sameField('toolEvents', ARGV[5]) and + sameField('metadata', ARGV[6]) and + sameField('extra', ARGV[7]) and + sameField('thinking', ARGV[8]) and + sameField('origin', ARGV[9]) and + sameField('mentions', ARGV[10]) and + sameField('mentionsUser', ARGV[11]) and + sameField('replyTo', ARGV[12]) and + sameField('pluginMessage', ARGV[13])) then + return -4 +end + +-- Validate terminal-response publication before the first Message or Queue +-- mutation. Processing placeholders have no cursor position until this commit. +local visibilitySeq = nil +local existingVisibility = redis.call('HGET', messageKey, 'visibilitySeq') +if not existingVisibility or existingVisibility == '' then + local hwmRaw = redis.call('HGET', KEYS[6], 'hwm') + local hwm = 0 + if hwmRaw ~= false then + hwm = tonumber(hwmRaw) + if hwm == nil or hwm ~= hwm or hwm ~= math.floor(hwm) or hwm < 0 then + return redis.error_reply('VISIBILITY_HWM_INVALID: raw=' .. tostring(hwmRaw)) + end + end + local timeArr = redis.call('TIME') + local nowMs = tonumber(timeArr[1]) * 1000 + math.floor(tonumber(timeArr[2]) / 1000) + visibilitySeq = math.max(hwm + 1, nowMs) + if visibilitySeq > 9007199254730991 then + return redis.error_reply('VISIBILITY_SEQ_EXHAUSTED: seq=' .. tostring(visibilitySeq)) end - local found = false - for _, existingId in ipairs(ids) do if existingId == messageId then found = true end end - if not found then table.insert(ids, messageId) end - redis.call('HSET', KEYS[5], field, cjson.encode(ids)) end -if ARGV[4] ~= '' then - redis.call('HSET', KEYS[1], 'deliveredAt', ARGV[4], 'timelineOrderAt', ARGV[5], 'deliveryStatus', 'delivered') - redis.call('ZADD', KEYS[2], ARGV[5], messageId) - redis.call('ZADD', KEYS[3], ARGV[5], messageId) - redis.call('ZADD', KEYS[4], ARGV[5], messageId) - - if seq then - local visKey = kp .. 'msg:visibility:' .. threadId - local metaKey = kp .. 'msg:visibility-meta:' .. threadId - redis.call('ZADD', visKey, seq, messageId) - redis.call('HSET', metaKey, 'hwm', tostring(seq)) - redis.call('HSETNX', metaKey, 'migrated', '1') - redis.call('HSET', KEYS[1], 'visibilitySeq', tostring(seq)) +if lifecycleNeedsWrite then + redis.call('HSET', messageKey, 'lifecycle', ARGV[2], 'content', ARGV[3], 'mentions', ARGV[10]) + local function replaceOptional(field, value) + if value == '' then redis.call('HDEL', messageKey, field) + else redis.call('HSET', messageKey, field, value) end end + replaceOptional('contentBlocks', ARGV[4]) + replaceOptional('toolEvents', ARGV[5]) + replaceOptional('metadata', ARGV[6]) + replaceOptional('extra', ARGV[7]) + replaceOptional('thinking', ARGV[8]) + replaceOptional('origin', ARGV[9]) + replaceOptional('mentionsUser', ARGV[11]) + replaceOptional('replyTo', ARGV[12]) + replaceOptional('pluginMessage', ARGV[13]) +end +if visibilitySeq then + redis.call('ZADD', KEYS[5], visibilitySeq, messageId) + redis.call('HSET', KEYS[6], 'hwm', tostring(visibilitySeq)) + redis.call('HSET', messageKey, 'visibilitySeq', tostring(visibilitySeq)) +end - return {2, nextRevision} +if expectedMode == 'absent' then + for i = 1, count do + redis.call('HSET', rowsKey, incoming[i].id, incoming[i].raw) + redis.call('RPUSH', orderKey, incoming[i].id) + end + redis.call('HSET', messageIndexKey, messageId, cjson.encode(entryIds)) end -return {1, nextRevision} + +if lifecycleNeedsWrite and expectedMode == 'absent' then return 1 end +if not lifecycleNeedsWrite and expectedMode == 'present' then return 2 end +if not lifecycleNeedsWrite and expectedMode == 'absent' then return 3 end +return 4 `; -const UPDATE_PLUGIN_MESSAGE_LUA = ` -if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end -local current = nil -local raw = redis.call('HGET', KEYS[1], 'pluginMessage') -if raw and raw ~= '' then - local ok, decoded = pcall(cjson.decode, raw) - if ok then current = decoded end +/** + * Atomically publishes a queued input and its adjacent pre-admission failure. + * Every fail-closed guard runs before the first mutation because Redis Lua + * errors do not roll back writes performed earlier in the script. + * + * KEYS[1] source message hash (ioredis applies keyPrefix) + * ARGV: keyPrefix, failureId, threadId, sourceUserId, expectedEntryId, + * failedAt, expectedSourceLifecycleJson, settledSourceLifecycleJson, + * failureLifecycleJson, failureContent, + * failureContentBlocksJson, idempotencyRawKey, ttlSeconds, sourceMode, + * failureFromJson + * + * Returns { 1, failureId } on apply, { 2, failureId } on replay, or + * { negativeReasonCode, '' } on a guarded conflict. + */ +const COMMIT_LIFECYCLE_PRE_ADMISSION_FAILURE_LUA = ` +local sourceHash = KEYS[1] +local kp = ARGV[1] +local failureId = ARGV[2] +local threadId = ARGV[3] +local sourceUserId = ARGV[4] +local expectedEntryId = ARGV[5] +local failedAt = ARGV[6] +local expectedSourceLifecycleRaw = ARGV[7] +local settledSourceLifecycleRaw = ARGV[8] +local failureLifecycleRaw = ARGV[9] +local failureContent = ARGV[10] +local failureContentBlocksRaw = ARGV[11] +local idempotencyKey = kp .. ARGV[12] +local ttlSec = tonumber(ARGV[13]) +local sourceMode = ARGV[14] +local failureFromRaw = ARGV[15] + +if redis.call('EXISTS', sourceHash) == 0 then return {-1, ''} end +if redis.call('HGET', sourceHash, 'threadId') ~= threadId or + redis.call('HGET', sourceHash, 'userId') ~= sourceUserId then + return {-3, ''} +end + +-- A live idempotency winner proves the full transaction committed. Check it +-- before queued-state guards so retries after the source became delivered replay. +local existingFailureId = redis.call('GET', idempotencyKey) +if existingFailureId and redis.call('EXISTS', kp .. 'msg:' .. existingFailureId) == 1 then + return {2, existingFailureId} +end + +local existingVisibility = redis.call('HGET', sourceHash, 'visibilitySeq') +local existingLifecycle = redis.call('HGET', sourceHash, 'lifecycle') +if existingLifecycle == false then existingLifecycle = '' end +if sourceMode == 'queued_input' then + if redis.call('HGET', sourceHash, 'deliveryStatus') ~= 'queued' then return {-2, ''} end + if (existingVisibility and existingVisibility ~= '') or existingLifecycle ~= expectedSourceLifecycleRaw or + settledSourceLifecycleRaw == '' then + return {-4, ''} + end +elseif sourceMode == 'public_wake' then + local deliveryStatus = redis.call('HGET', sourceHash, 'deliveryStatus') + local catId = redis.call('HGET', sourceHash, 'catId') + if (deliveryStatus and deliveryStatus ~= '') or + not existingVisibility or existingVisibility == '' or + not catId or catId == '' or catId == 'system' or + existingLifecycle ~= expectedSourceLifecycleRaw or settledSourceLifecycleRaw == '' then + return {-4, ''} + end else - local legacyRaw = redis.call('HGET', KEYS[1], 'extra') - if legacyRaw and legacyRaw ~= '' then - local ok, decoded = pcall(cjson.decode, legacyRaw) - if ok then current = decoded.pluginMessage end + return {-4, ''} +end + +local failureHash = kp .. 'msg:' .. failureId +if redis.call('EXISTS', failureHash) == 1 then return {-4, ''} end + +-- Validate both visibility positions before any write. +local metaKey = kp .. 'msg:visibility-meta:' .. threadId +local hwmRaw = redis.call('HGET', metaKey, 'hwm') +local hwm = 0 +if hwmRaw ~= false then + hwm = tonumber(hwmRaw) + if hwm == nil or hwm ~= hwm or hwm ~= math.floor(hwm) or hwm < 0 then + return redis.error_reply('VISIBILITY_HWM_INVALID: raw=' .. tostring(hwmRaw) .. ' metaKey=' .. metaKey) end end -if not current or tonumber(current.revision) ~= tonumber(ARGV[2]) then return -1 end -redis.call('HSET', KEYS[1], 'pluginMessage', ARGV[1]) +local timeArr = redis.call('TIME') +local nowMs = tonumber(timeArr[1]) * 1000 + math.floor(tonumber(timeArr[2]) / 1000) +local sourceSeq = nil +local failureSeq = nil +if sourceMode == 'queued_input' then + sourceSeq = math.max(hwm + 1, nowMs) + failureSeq = sourceSeq + 1 +else + failureSeq = math.max(hwm + 1, nowMs) +end +if failureSeq > 9007199254730991 then + return redis.error_reply('VISIBILITY_SEQ_EXHAUSTED: seq=' .. tostring(failureSeq)) +end + +-- All guards passed: publish a queued input, or settle an already-public wake. +if sourceMode == 'queued_input' then + redis.call('HSET', sourceHash, + 'deliveryStatus', 'delivered', + 'deliveredAt', failedAt, + 'timelineOrderAt', failedAt, + 'lifecycle', settledSourceLifecycleRaw, + 'visibilitySeq', tostring(sourceSeq)) +else + redis.call('HSET', sourceHash, 'lifecycle', settledSourceLifecycleRaw) +end + +redis.call('HSET', failureHash, + 'id', failureId, + 'threadId', threadId, + 'userId', 'system', + 'from', failureFromRaw, + 'catId', '', + 'content', failureContent, + 'lifecycle', failureLifecycleRaw, + 'mentions', '[]', + 'timestamp', failedAt, + 'visibilitySeq', tostring(failureSeq)) +if failureContentBlocksRaw ~= '' then + redis.call('HSET', failureHash, 'contentBlocks', failureContentBlocksRaw) +end + +local timelineKey = kp .. 'msg:timeline' +local sourceUserKey = kp .. 'msg:user:' .. sourceUserId +local systemUserKey = kp .. 'msg:user:system' +local threadKey = kp .. 'msg:thread:' .. threadId +local visibilityKey = kp .. 'msg:visibility:' .. threadId +if sourceMode == 'queued_input' then + redis.call('ZADD', timelineKey, failedAt, redis.call('HGET', sourceHash, 'id')) + redis.call('ZADD', sourceUserKey, failedAt, redis.call('HGET', sourceHash, 'id')) + redis.call('ZADD', threadKey, failedAt, redis.call('HGET', sourceHash, 'id')) + redis.call('ZADD', visibilityKey, sourceSeq, redis.call('HGET', sourceHash, 'id')) +end +redis.call('ZADD', timelineKey, failedAt, failureId) +redis.call('ZADD', systemUserKey, failedAt, failureId) +redis.call('ZADD', threadKey, failedAt, failureId) +redis.call('ZADD', visibilityKey, failureSeq, failureId) +redis.call('HSET', metaKey, 'hwm', tostring(failureSeq), 'migrated', '1') +redis.call('SET', idempotencyKey, failureId) + +if ttlSec > 0 then + redis.call('EXPIRE', failureHash, ttlSec) + redis.call('EXPIRE', idempotencyKey, ttlSec) + local cutoff = nowMs - ttlSec * 1000 + redis.call('ZREMRANGEBYSCORE', timelineKey, '-inf', cutoff) + redis.call('ZREMRANGEBYSCORE', sourceUserKey, '-inf', cutoff) + redis.call('ZREMRANGEBYSCORE', systemUserKey, '-inf', cutoff) + redis.call('ZREMRANGEBYSCORE', threadKey, '-inf', cutoff) + redis.call('EXPIRE', timelineKey, ttlSec) + redis.call('EXPIRE', sourceUserKey, ttlSec) + redis.call('EXPIRE', systemUserKey, ttlSec) + redis.call('EXPIRE', threadKey, ttlSec) +end + +return {1, failureId} +`; + +const CAS_LIFECYCLE_METADATA_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end +if redis.call('HGET', KEYS[1], 'recall') or redis.call('HGET', KEYS[1], '_tombstone') then return -2 end +local current = redis.call('HGET', KEYS[1], 'lifecycle') +if current == false then current = '' end +if current ~= ARGV[1] then return 0 end +redis.call('HSET', KEYS[1], 'lifecycle', ARGV[2]) +return 1 +`; + +const CAS_LIFECYCLE_APPEND_ADMISSION_LUA = ` +for index = 1, #KEYS do + if redis.call('EXISTS', KEYS[index]) == 0 then return -1 end + if redis.call('HGET', KEYS[index], 'recall') or redis.call('HGET', KEYS[index], '_tombstone') then return -2 end + local current = redis.call('HGET', KEYS[index], 'lifecycle') + if current == false then current = '' end + if current ~= ARGV[(index - 1) * 2 + 1] then return 0 end +end +for index = 1, #KEYS do + redis.call('HSET', KEYS[index], 'lifecycle', ARGV[(index - 1) * 2 + 2]) +end return 1 `; @@ -446,25 +858,11 @@ if redis.call('HGET', KEYS[1], 'catId') ~= '' or redis.call('HGET', KEYS[1], '_t return {-3, 0} end local deliveryStatus = redis.call('HGET', KEYS[1], 'deliveryStatus') -if deliveryStatus ~= 'queued' and deliveryStatus ~= 'delivered' then return {-3, 0} end -local custodyRaw = redis.call('HGET', KEYS[1], 'queueCustody') -if not custodyRaw or custodyRaw == '' then return {-3, 0} end +if deliveryStatus ~= 'queued' then return {-3, 0} end local currentDraftRevision = tonumber(redis.call('HGET', KEYS[2], 'revision') or '0') if currentDraftRevision ~= tonumber(ARGV[3]) then return {0, currentDraftRevision} end -local okCustody, custody = pcall(cjson.decode, custodyRaw) -if not okCustody or type(custody) ~= 'table' then return redis.error_reply('INVALID_QUEUE_CUSTODY') end -local currentCustodyRevision = tonumber(custody.revision or 0) -if currentCustodyRevision ~= tonumber(ARGV[6]) then return {3, currentCustodyRevision} end -local nextCustodyRaw = ARGV[7] -local okNextCustody, nextCustody = pcall(cjson.decode, nextCustodyRaw) -if not okNextCustody or type(nextCustody) ~= 'table' then return redis.error_reply('INVALID_NEXT_QUEUE_CUSTODY') end -if tonumber(nextCustody.revision or 0) ~= currentCustodyRevision + 1 or - nextCustody.entryId ~= custody.entryId or nextCustody.status ~= 'terminal' or - #(nextCustody.pendingTargetCats or cjson.decode('[]')) ~= 0 then - return redis.error_reply('INVALID_NEXT_QUEUE_CUSTODY') -end local sourceText = redis.call('HGET', KEYS[1], 'content') or '' local existingText = redis.call('HGET', KEYS[2], 'text') or '' local nextText = sourceText @@ -489,11 +887,7 @@ local nextReplyTo = '' if ARGV[4] == 'append' then nextReplyTo = redis.call('HGET', KEYS[2], 'replyTo') or '' end if nextReplyTo == '' then nextReplyTo = redis.call('HGET', KEYS[1], 'replyTo') or '' end -local exposures = custody.bodyExposures or cjson.decode('[]') -local exposed = #exposures > 0 or #(custody.seenByCatIds or cjson.decode('[]')) > 0 - -local recall = { version = 1, exposure = exposed and 'seen' or 'none', recalledAt = tonumber(ARGV[5]) } -if #exposures > 0 then recall.exposures = exposures end +local recall = { version = 1, exposure = 'none', recalledAt = tonumber(ARGV[5]) } local nextDraftRevision = currentDraftRevision + 1 -- All validation/derivation passed. Mutations below form the single terminal CAS. @@ -513,32 +907,15 @@ redis.call('HSET', KEYS[1], 'mentions', '[]', 'deliveryStatus', 'canceled', '_tombstone', '1', - 'queueCustody', nextCustodyRaw, - 'queueCustodyRevision', tostring(nextCustody.revision), 'recall', cjson.encode(recall)) local messageId = redis.call('HGET', KEYS[1], 'id') -for keyIndex = 5, #KEYS do +for keyIndex = 4, #KEYS do redis.call('ZREM', KEYS[keyIndex], messageId) end -for _, exposure in ipairs(exposures) do - local field = tostring(exposure.targetCatId) .. string.char(0) .. tostring(exposure.invocationId) - local existingRaw = redis.call('HGET', KEYS[4], field) - local ids = cjson.decode('[]') - if existingRaw and existingRaw ~= '' then - local okExisting, existing = pcall(cjson.decode, existingRaw) - if okExisting and type(existing) == 'table' then ids = existing end - end - local found = false - for _, existingId in ipairs(ids) do if existingId == messageId then found = true end end - if not found then table.insert(ids, messageId) end - redis.call('HSET', KEYS[4], field, cjson.encode(ids)) -end -redis.call('HDEL', KEYS[1], 'contentBlocks', 'toolEvents', 'metadata', 'extra', 'pluginMessage', 'custodyOfferV1', 'thinking', 'replyTo') -if not exposed then - redis.call('ZREM', KEYS[3], redis.call('HGET', KEYS[1], 'id')) -end +redis.call('HDEL', KEYS[1], 'contentBlocks', 'toolEvents', 'metadata', 'extra', 'pluginMessage', 'lifecycle', 'thinking', 'replyTo') +redis.call('ZREM', KEYS[3], redis.call('HGET', KEYS[1], 'id')) -return {1, exposed and 1 or 0, sourceText, existingText, nextDraftRevision} +return {1, 0, sourceText, existingText, nextDraftRevision} `; const UPDATE_EXTRA_IF_NOT_RECALLED_LUA = ` @@ -564,10 +941,8 @@ for _, field in ipairs(fields) do table.insert(result, field) end return result `; -type PersistedHostMessageExtra = Omit, 'pluginMessage' | 'custodyOfferV1'>; - function splitMessageExtra(extra: StoredMessage['extra'] | undefined): { - hostExtra: PersistedHostMessageExtra; + hostExtra: HostMessageExtra; pluginMessage: StoredPluginMessage | undefined; custodyOfferV1: CustodyOfferV1 | undefined; } { @@ -580,6 +955,79 @@ function serializeHostExtra(extra: StoredMessage['extra'] | undefined): string { return Object.keys(hostExtra).length > 0 ? serializeExtra(hostExtra) : ''; } +type PreparedRedisLifecycleResponseTerminal = + | { kind: 'write'; nextMessage: StoredMessage } + | { kind: 'result'; result: CommitLifecycleResponseTerminalResult }; + +function prepareRedisLifecycleResponseTerminal( + current: StoredMessage, + patch: LifecycleResponseTerminalPatch, +): PreparedRedisLifecycleResponseTerminal { + if (current.lifecycle?.kind !== 'response') { + return { kind: 'result', result: { kind: 'conflict', reason: 'not_response', message: current } }; + } + if (current.lifecycle.invocationId !== patch.invocationId) { + return { kind: 'result', result: { kind: 'conflict', reason: 'invocation_mismatch', message: current } }; + } + if ( + !Number.isFinite(patch.completedAt) || + patch.completedAt < current.lifecycle.startedAt || + (patch.reason !== undefined && patch.reason.length === 0) + ) { + return { kind: 'result', result: { kind: 'conflict', reason: 'invalid_terminal', message: current } }; + } + const nextMessage = prepareLifecycleResponseTerminalMessage(current, patch); + if (current.lifecycle.status === 'processing') return { kind: 'write', nextMessage }; + return { + kind: 'result', + result: isDeepStrictEqual(current, nextMessage) + ? { kind: 'replayed', message: current } + : { kind: 'conflict', reason: 'different_terminal', message: current }, + }; +} + +function lifecycleResponseTerminalLuaArgs( + patch: LifecycleResponseTerminalPatch, + nextMessage: StoredMessage, + expectedLifecycleRaw: string, +): string[] { + const { pluginMessage } = splitMessageExtra(patch.extra); + return [ + patch.invocationId, + JSON.stringify(nextMessage.lifecycle), + patch.content, + patch.contentBlocks === undefined ? '' : JSON.stringify(patch.contentBlocks), + patch.toolEvents === undefined ? '' : JSON.stringify(patch.toolEvents), + patch.metadata === undefined ? '' : JSON.stringify(patch.metadata), + patch.extra === undefined ? '' : serializeHostExtra(patch.extra), + patch.thinking ?? '', + patch.origin ?? '', + JSON.stringify(patch.mentions), + patch.mentionsUser ? '1' : '', + patch.replyTo ?? '', + pluginMessage ? JSON.stringify(pluginMessage) : '', + expectedLifecycleRaw, + ]; +} + +function lifecycleResponseTerminalResultFromRedis( + outcome: number, + message: StoredMessage, +): CommitLifecycleResponseTerminalResult { + switch (outcome) { + case 1: + return { kind: 'applied', message }; + case 2: + return { kind: 'replayed', message }; + case -2: + return { kind: 'conflict', reason: 'not_response', message }; + case -3: + return { kind: 'conflict', reason: 'invocation_mismatch', message }; + default: + throw new Error(`unexpected lifecycle response terminal outcome: ${outcome}`); + } +} + type ParsedCustodyOffer = { kind: 'absent' } | { kind: 'valid'; offer: CustodyOfferV1 } | { kind: 'invalid' }; function parseCustodyOfferRaw(raw: string | undefined | null): ParsedCustodyOffer { @@ -702,6 +1150,7 @@ function parseCustodyOfferCasReceipt(result: unknown): CustodyOfferCasReceipt { }; } +/** Redis-backed canonical message and lifecycle store. */ export class RedisMessageStore { private readonly redis: RedisClient; /** null means no expiration/pruning (persistent retention). */ @@ -738,12 +1187,259 @@ export class RedisMessageStore { } async append(input: AppendMessageInput): Promise { - return (await this.appendIdempotent(input)).message; + const result = await this.appendWithReservedId(input); + if (result.outcome !== 'stored') throw new Error('plain message append returned a Queue admission outcome'); + return result.message; } async appendIdempotent(input: AppendMessageInput): Promise { - const msg = normalizeJsonUnicode(input); - assertValidAppendMessageInput(msg); + const result = await this.appendWithReservedId(input); + if (result.outcome !== 'stored') throw new Error('idempotent message append returned a Queue admission outcome'); + return { message: result.message, idempotent: result.replayed }; + } + + async appendWithQueueLedgerAdmission( + input: AppendMessageInput, + buildAdmission: QueueLedgerAdmissionFactory, + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): Promise { + if (!(ledgerStore instanceof RedisQueueLedgerStore) || !ledgerStore.usesRedisClient(this.redis)) { + throw new Error('Redis message admission requires the matching Redis Queue ledger'); + } + const messageId = generateSortableId(input.timestamp); + const entries = [...buildAdmission(messageId)]; + for (const entry of entries) { + assertQueueLedgerEntry(entry); + if ( + entry.threadId !== (input.threadId ?? DEFAULT_THREAD_ID) || + entry.payload.sourceRecordId !== messageId || + entry.payload.messageId !== messageId + ) { + throw new Error('Queue admission row must be bound to its exact message identity'); + } + } + const result = await this.appendWithReservedId( + prepareQueueLedgerMessageAdmission(input, messageId, entries), + messageId, + { + entries, + maxQueuedUserEntries, + }, + ); + if (result.outcome === 'queue_full') return { outcome: 'full' }; + if (result.outcome === 'queue_conflict') { + throw new Error(`Queue admission identity conflict for message ${messageId}`); + } + if (result.outcome !== 'stored') throw new Error('unreachable Queue admission outcome'); + if (!result.replayed) { + return { outcome: 'enqueued', message: result.message, entries, deduped: false }; + } + const replayEntries = [...buildAdmission(result.message.id)]; + const persisted = await Promise.all(replayEntries.map((entry) => ledgerStore.get(entry.threadId, entry.id))); + if ( + persisted.some((entry) => entry === null) || + !persisted.every((entry, index) => queueLedgerAdmissionsMatch(entry!, replayEntries[index]!)) + ) { + throw new Error(`Queue admission identity conflict for replayed message ${result.message.id}`); + } + return { + outcome: 'enqueued', + message: result.message, + entries: persisted.filter((entry): entry is QueueLedgerEntry => entry !== null), + deduped: true, + }; + } + + async enqueueExistingMessageWithQueueLedgerAdmission( + messageId: string, + entries: readonly QueueLedgerEntry[], + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): Promise { + if (!(ledgerStore instanceof RedisQueueLedgerStore) || !ledgerStore.usesRedisClient(this.redis)) { + throw new Error('Redis message admission requires the matching Redis Queue ledger'); + } + if (entries.length === 0) throw new Error('Queue admission requires at least one row'); + const threadId = entries[0]?.threadId; + for (const entry of entries) { + assertQueueLedgerEntry(entry); + if ( + entry.threadId !== threadId || + entry.payload.sourceRecordId !== messageId || + entry.payload.messageId !== messageId + ) { + throw new Error('Queue admission row must be bound to its exact existing message identity'); + } + } + const source = await this.getById(messageId); + if (!source) throw new Error(`Queue source message does not exist: ${messageId}`); + const sourceLifecycle = prepareQueueLedgerSourceLifecycle(source, entries); + const outcome = Number( + await this.redis.eval( + ENQUEUE_EXISTING_MESSAGE_WITH_LEDGER_LUA, + 4, + MessageKeys.detail(messageId), + QueueLedgerKeys.entries(threadId!), + QueueLedgerKeys.order(threadId!), + QueueLedgerKeys.messageIndex(threadId!), + messageId, + maxQueuedUserEntries === undefined ? '-1' : String(maxQueuedUserEntries), + String(entries.length), + ...entries.map((entry) => JSON.stringify(entry)), + JSON.stringify(sourceLifecycle), + ), + ); + if (outcome === 0) return { outcome: 'full' }; + if (outcome === -2) throw new Error(`Queue source message does not exist: ${messageId}`); + if (outcome === -3) throw new Error(`Queue source message already has delivery ownership: ${messageId}`); + if (outcome === -4) throw new Error(`Queue source message has conflicting lifecycle identity: ${messageId}`); + if (outcome === -5) throw new Error(`Queue source target was already dispatched: ${messageId}`); + if (outcome === -1) throw new Error(`Queue admission identity conflict for existing message ${messageId}`); + if (outcome !== 1 && outcome !== 2) + throw new Error(`unexpected existing Message/Queue admission result: ${outcome}`); + const message = await this.getById(messageId); + if (!message) throw new Error(`Queue source message disappeared after admission: ${messageId}`); + const persisted = + outcome === 1 + ? entries.map((entry) => structuredClone(entry)) + : await Promise.all(entries.map((entry) => ledgerStore.get(entry.threadId, entry.id))); + if ( + persisted.some((entry) => entry === null) || + !persisted.every((entry, index) => queueLedgerAdmissionsMatch(entry!, entries[index]!)) + ) { + throw new Error(`Queue admission identity conflict for replayed existing message ${messageId}`); + } + return { + outcome: 'enqueued', + message, + entries: persisted.filter((entry): entry is QueueLedgerEntry => entry !== null), + deduped: outcome === 2, + }; + } + + async commitLifecycleResponseTerminalWithQueueLedgerAdmission( + messageId: string, + patch: LifecycleResponseTerminalPatch, + entries: readonly QueueLedgerEntry[], + ledgerStore: QueueLedgerStore, + maxQueuedUserEntries?: number, + ): Promise { + if (!(ledgerStore instanceof RedisQueueLedgerStore) || !ledgerStore.usesRedisClient(this.redis)) { + throw new Error('Redis lifecycle admission requires the matching Redis Queue ledger'); + } + if (entries.length === 0) throw new Error('lifecycle Queue admission requires at least one row'); + const threadId = entries[0]?.threadId; + for (const entry of entries) { + assertQueueLedgerEntry(entry); + if ( + entry.threadId !== threadId || + entry.payload.sourceRecordId !== messageId || + entry.payload.messageId !== messageId + ) { + throw new Error('lifecycle Queue admission row must bind its exact response message'); + } + } + await this.ensureVisibilityMigrated(threadId!); + + for (let attempt = 0; attempt < 4; attempt += 1) { + const key = MessageKeys.detail(messageId); + const expectedLifecycleRaw = (await this.redis.hget(key, 'lifecycle')) ?? ''; + const current = await this.getById(messageId); + if (!current) return { kind: 'not_found' }; + const prepared = prepareLifecycleResponseTerminalWithLedgerTargets(current, patch, entries); + if (prepared.kind !== 'prepared') return prepared; + + if (prepared.entries.length === 0) { + const activeRows = await Promise.all(entries.map((entry) => ledgerStore.get(entry.threadId, entry.id))); + if (activeRows.some((entry) => entry !== null)) { + return { kind: 'conflict', reason: 'different_terminal', message: current }; + } + return { + kind: prepared.lifecycleReplayed ? 'replayed' : 'applied', + message: prepared.message, + entries: [], + ledgerReplayed: true, + }; + } + + const currentRaws = await this.redis.hmget( + QueueLedgerKeys.entries(threadId!), + ...prepared.entries.map((entry) => entry.id), + ); + const presentCount = currentRaws.filter((raw) => typeof raw === 'string').length; + if (presentCount !== 0 && presentCount !== prepared.entries.length) { + return { kind: 'conflict', reason: 'different_terminal', message: current }; + } + if (presentCount === prepared.entries.length) { + const existing = currentRaws.map((raw) => hydrateQueueLedgerEntry(raw as string)); + if (!existing.every((entry, index) => queueLedgerAdmissionsMatch(entry, prepared.entries[index]!))) { + return { kind: 'conflict', reason: 'different_terminal', message: current }; + } + } + + const expectedMode = presentCount === 0 ? 'absent' : 'present'; + // A terminal replay is semantically equal even when object-property order + // differs after hydrate -> prepare. Redis stores lifecycle JSON verbatim, + // so use the exact hydrated ordering for the replay byte comparison. + const terminalMessageForLua = prepared.lifecycleReplayed ? current : prepared.message; + const outcome = Number( + await this.redis.eval( + COMMIT_LIFECYCLE_RESPONSE_WITH_LEDGER_LUA, + 6, + key, + QueueLedgerKeys.entries(threadId!), + QueueLedgerKeys.order(threadId!), + QueueLedgerKeys.messageIndex(threadId!), + MessageKeys.threadVisibility(threadId!), + MessageKeys.threadVisibilityMeta(threadId!), + ...lifecycleResponseTerminalLuaArgs(patch, terminalMessageForLua, expectedLifecycleRaw), + maxQueuedUserEntries === undefined ? '-1' : String(maxQueuedUserEntries), + String(prepared.entries.length), + expectedMode, + ...prepared.entries.map((entry) => JSON.stringify(entry)), + ...currentRaws.map((raw) => raw ?? ''), + ), + ); + if (outcome === -10) continue; + if (outcome === 0) return { kind: 'full' }; + if (outcome === -1) return { kind: 'not_found' }; + if (outcome === -2) return { kind: 'conflict', reason: 'not_response', message: current }; + if (outcome === -3) return { kind: 'conflict', reason: 'invocation_mismatch', message: current }; + // A concurrent source→target dispatch changes only lifecycle.dispatchRefs. + // Re-read so the next pass can subtract that target from Queue admission; + // a genuinely different terminal is rejected by the pure prepare step. + if (outcome === -4) continue; + if (outcome === -5) { + return { kind: 'conflict', reason: 'different_terminal', message: current }; + } + if (![1, 2, 3, 4].includes(outcome)) { + throw new Error(`unexpected lifecycle Message/Queue admission result: ${outcome}`); + } + const message = await this.getById(messageId); + if (!message) throw new Error(`lifecycle response disappeared after Queue admission: ${messageId}`); + const persisted = await Promise.all(prepared.entries.map((entry) => ledgerStore.get(entry.threadId, entry.id))); + if (persisted.some((entry) => entry === null)) { + throw new Error(`lifecycle Queue admission rows disappeared after commit: ${messageId}`); + } + return { + kind: outcome === 1 || outcome === 4 ? 'applied' : 'replayed', + message, + entries: persisted.filter((entry): entry is QueueLedgerEntry => entry !== null), + ledgerReplayed: outcome === 2 || outcome === 4, + }; + } + const message = await this.getById(messageId); + if (!message) return { kind: 'not_found' }; + return { kind: 'conflict', reason: 'different_terminal', message }; + } + + private async appendWithReservedId( + input: AppendMessageInput, + reservedId?: string, + queue?: QueueAppendOptions, + ): Promise { + const msg = canonicalizeAppendMessageInput(input); const threadId = msg.threadId ?? DEFAULT_THREAD_ID; const idempotencyIndexKey = msg.idempotencyKey ? MessageKeys.idempotency(msg.userId, threadId, msg.idempotencyKey) @@ -756,17 +1452,21 @@ export class RedisMessageStore { if (existingId) { const existingMessage = await this.getById(existingId); if (existingMessage) { - return { message: existingMessage, idempotent: true }; + return { outcome: 'stored', message: existingMessage, replayed: true }; } } // Stale reference: do NOT delete here (avoids a check-then-act race). // APPEND_WITH_VISIBILITY_LUA will reclaim it atomically (#1210). } - const id = generateSortableId(msg.timestamp); + const id = reservedId ?? generateSortableId(msg.timestamp); const { idempotencyKey, ...payload } = msg; void idempotencyKey; - const stored: StoredMessage = { ...payload, id, threadId }; + const stored: StoredMessage = { + ...payload, + id, + threadId, + }; const score = msg.timestamp; const hashKey = MessageKeys.detail(id); const { custodyOfferV1, ...appendExtra } = msg.extra ?? {}; @@ -779,10 +1479,14 @@ export class RedisMessageStore { threadId, 'userId', msg.userId, + 'from', + JSON.stringify(msg.from), 'catId', msg.catId ?? '', 'content', msg.content, + 'lifecycle', + msg.lifecycle ? JSON.stringify(msg.lifecycle) : '', 'contentBlocks', msg.contentBlocks ? JSON.stringify(msg.contentBlocks) : '', 'toolEvents', @@ -803,15 +1507,7 @@ export class RedisMessageStore { if (msg.source) hashFields.push('source', JSON.stringify(msg.source)); if (msg.mentionsUser) hashFields.push('mentionsUser', '1'); if (msg.deliveryStatus) hashFields.push('deliveryStatus', msg.deliveryStatus); - if (custodyOfferV1) hashFields.push('custodyOfferV1', JSON.stringify(custodyOfferV1)); - // #1269 P1-1: restore queueCustody serialization that createStoredMessageData() provided. - // Without these, F254 custody CAS operations fail on messages appended with initial custody. - if (msg.queueCustody) { - hashFields.push('queueCustody', JSON.stringify(msg.queueCustody)); - hashFields.push('queueCustodyRevision', String(msg.queueCustody.revision)); - } if (msg.replyTo) hashFields.push('replyTo', msg.replyTo); - // Mention catIds for ZADD into per-cat mention sets const mentionCatIds = msg.mentions as readonly string[]; const ttlSec = this.ttlSeconds ?? 0; @@ -836,6 +1532,9 @@ export class RedisMessageStore { ...mentionCatIds, // [10..9+N] mention catIds String(hashFields.length / 2), // [10+N] hashFieldPairCount ...hashFields, // [11+N..] hash field pairs + queue?.maxQueuedUserEntries === undefined ? '-1' : String(queue.maxQueuedUserEntries), + String(queue?.entries.length ?? 0), + ...(queue?.entries.map((entry) => JSON.stringify(entry)) ?? []), ]; // #1200/#1269: ensure visibility migration is complete BEFORE the append. @@ -849,13 +1548,33 @@ export class RedisMessageStore { // Lua returns: // - string (existing msgId) → idempotency replay, return existing message // - number (visibilitySeq) → new message created, seq > 0 for non-queued - const result = await this.redis.eval(APPEND_WITH_VISIBILITY_LUA, 1, hashKey, ...argv); + const queueKeys = queue + ? [QueueLedgerKeys.entries(threadId), QueueLedgerKeys.order(threadId), QueueLedgerKeys.messageIndex(threadId)] + : []; + const result = await this.redis.eval(APPEND_WITH_VISIBILITY_LUA, queue ? 4 : 1, hashKey, ...queueKeys, ...argv); + + if (queue) { + if (!Array.isArray(result)) throw new Error('invalid combined message/Queue admission reply'); + const outcome = Number(result[0]); + if (outcome === 0) return { outcome: 'queue_full' }; + if (outcome === -1) return { outcome: 'queue_conflict' }; + if ((outcome !== 1 && outcome !== 2) || typeof result[1] !== 'string') { + throw new Error('invalid combined message/Queue admission outcome'); + } + if (outcome === 2) { + const existingMessage = await this.getById(result[1]); + if (!existingMessage) { + throw new Error(`Idempotency winner ${result[1]} vanished before Queue replay hydration`); + } + return { outcome: 'stored', message: existingMessage, replayed: true }; + } + } // #1210 idempotency: if Lua returned a string, a concurrent caller won. if (typeof result === 'string' && result !== String(0)) { const existingMessage = await this.getById(result); if (existingMessage) { - return { message: existingMessage, idempotent: true }; + return { outcome: 'stored', message: existingMessage, replayed: true }; } // The concurrent winner's hash vanished (deleteByThread / TTL) between the // Lua claim and this hydration. Do not fall through to the created path, @@ -865,7 +1584,8 @@ export class RedisMessageStore { // #1200 P2-6: Lua returns allocated visibilitySeq (number) or 0 for queued. // Inject into returned message so callers get canonical position without re-read. - const seq = typeof result === 'number' ? result : Number(result); + const seqValue = queue && Array.isArray(result) ? result[1] : result; + const seq = typeof seqValue === 'number' ? seqValue : Number(seqValue); if (seq > 0) { stored.visibilitySeq = seq; } @@ -879,7 +1599,7 @@ export class RedisMessageStore { } } - return { message: stored, idempotent: false }; + return { outcome: 'stored', message: stored, replayed: false }; } async getLatestThreadMessageIdIncludingQueued(threadId: string): Promise { @@ -896,7 +1616,9 @@ export class RedisMessageStore { msg: AppendMessageInput, expectedLatestMessageId: string | null, ): Promise { - return appendMessageIfThreadFrontier({ + const canonical = canonicalizeAppendMessageInput(msg); + await this.ensureVisibilityMigrated(canonical.threadId ?? DEFAULT_THREAD_ID); + const result = await appendMessageIfThreadFrontier({ redis: this.redis, message: msg, expectedLatestMessageId, @@ -904,25 +1626,20 @@ export class RedisMessageStore { loadById: (messageId) => this.getById(messageId), ...(this.onAppend ? { onAppend: this.onAppend } : {}), }); + return result; } async appendAndObservePriorFrontier(msg: AppendMessageInput): Promise { - const normalized = normalizeJsonUnicode(msg); - assertValidAppendMessageInput(normalized); - assertQueueCustodyMessageBinding(normalized); - const threadId = normalized.threadId ?? DEFAULT_THREAD_ID; - const timelinePublished = isTimelinePublished({ ...normalized, id: '', threadId }); - if (timelinePublished) { - await this.ensureVisibilityMigrated(threadId); - } - return appendMessageAndObservePriorFrontier({ + const canonical = canonicalizeAppendMessageInput(msg); + await this.ensureVisibilityMigrated(canonical.threadId ?? DEFAULT_THREAD_ID); + const result = await appendMessageAndObservePriorFrontier({ redis: this.redis, - message: normalized, - timelinePublished, + message: msg, ttlSeconds: this.ttlSeconds, loadById: (messageId) => this.getById(messageId), ...(this.onAppend ? { onAppend: this.onAppend } : {}), }); + return result; } async getById(id: string): Promise { @@ -930,11 +1647,7 @@ export class RedisMessageStore { return this.hydrateHash(data); } - /** - * F278 reads marker sources in bulk. The inbox only consumes this narrow - * projection, so full history-only payloads such as toolEvents and contentBlocks - * must not cross Redis or be parsed on every global inbox request. - */ + /** Read F278 marker sources in bounded, narrow Redis batches. */ async getPawFeelSourceProjections(ids: readonly string[]): Promise> { const reads = new Map(); const uniqueIds = [...new Set(ids)]; @@ -971,7 +1684,6 @@ export class RedisMessageStore { try { message = hydratePawFeelSourceProjection(fields); } catch { - // Persisted per-message corruption is one unavailable source, never a poisoned batch. reads.set(id, { kind: 'unavailable', reason: 'read_failed' }); continue; } @@ -1073,57 +1785,46 @@ export class RedisMessageStore { if (!Number.isInteger(input.expectedDraftRevision) || input.expectedDraftRevision < 0) { throw new RangeError('expected draft revision must be a non-negative integer'); } - for (let attempt = 0; attempt < 3; attempt++) { - const beforeRecall = await this.getById(id); - const currentCustody = beforeRecall?.queueCustody; - const nextCustody = currentCustody - ? terminalizeRecalledQueueCustody(currentCustody, input.recalledAt) - : undefined; - const mentionKeys = [...new Set(beforeRecall?.mentions ?? [])].map((catId) => MessageKeys.mentions(catId)); - const result = (await this.redis.eval( - RECALL_MESSAGE_TO_COMPOSER_DRAFT_LUA, - 4 + mentionKeys.length, - MessageKeys.detail(id), - MessageKeys.ownerComposerDraft(input.ownerUserId, input.threadId), - MessageKeys.threadVisibility(input.threadId), - MessageKeys.queueExposureIndex(input.threadId), - ...mentionKeys, - input.ownerUserId, - input.threadId, - String(input.expectedDraftRevision), - input.merge, - String(input.recalledAt), - String(currentCustody?.revision ?? -1), - nextCustody ? JSON.stringify(nextCustody) : '', - )) as [number, number, string?, string?, number?]; - const outcome = Number(result[0]); - if (outcome === 3) continue; - if (outcome === -1) return { kind: 'not_found' }; - if (outcome === -2) return { kind: 'unauthorized' }; - if (outcome === -3) return { kind: 'not_recallable' }; - if (outcome === 0) return { kind: 'draft_revision_mismatch', actualRevision: Number(result[1]) }; - if (outcome === 2) { - const message = await this.getById(id); - return message ? { kind: 'already_recalled', message } : { kind: 'not_found' }; - } - - const [message, draft] = await Promise.all([ - this.getById(id), - this.getOwnerComposerDraft(input.ownerUserId, input.threadId), - ]); - if (!message || !draft) throw new Error('true recall CAS committed but terminal hydration failed'); - const sourceText = result[2] ?? ''; - const previousText = result[3] ?? ''; - const insertedStart = input.merge === 'append' && previousText ? previousText.length + 2 : 0; - return { - kind: 'recalled', - verdict: Number(result[1]) === 1 ? 'exposed' : 'zero_exposure', - message, - draft, - insertedRange: { start: insertedStart, end: insertedStart + sourceText.length }, - }; - } - throw new Error('true recall custody CAS retry exhausted'); + const beforeRecall = await this.getById(id); + const mentionKeys = [...new Set(beforeRecall?.mentions ?? [])].map((catId) => MessageKeys.mentions(catId)); + const result = (await this.redis.eval( + RECALL_MESSAGE_TO_COMPOSER_DRAFT_LUA, + 3 + mentionKeys.length, + MessageKeys.detail(id), + MessageKeys.ownerComposerDraft(input.ownerUserId, input.threadId), + MessageKeys.threadVisibility(input.threadId), + ...mentionKeys, + input.ownerUserId, + input.threadId, + String(input.expectedDraftRevision), + input.merge, + String(input.recalledAt), + )) as [number, number, string?, string?, number?]; + const outcome = Number(result[0]); + if (outcome === -1) return { kind: 'not_found' }; + if (outcome === -2) return { kind: 'unauthorized' }; + if (outcome === -3) return { kind: 'not_recallable' }; + if (outcome === 0) return { kind: 'draft_revision_mismatch', actualRevision: Number(result[1]) }; + if (outcome === 2) { + const message = await this.getById(id); + return message ? { kind: 'already_recalled', message } : { kind: 'not_found' }; + } + + const [message, draft] = await Promise.all([ + this.getById(id), + this.getOwnerComposerDraft(input.ownerUserId, input.threadId), + ]); + if (!message || !draft) throw new Error('true recall CAS committed but terminal hydration failed'); + const sourceText = result[2] ?? ''; + const previousText = result[3] ?? ''; + const insertedStart = input.merge === 'append' && previousText ? previousText.length + 2 : 0; + return { + kind: 'recalled', + verdict: 'zero_exposure', + message, + draft, + insertedRange: { start: insertedStart, end: insertedStart + sourceText.length }, + }; } /** @@ -1134,21 +1835,34 @@ export class RedisMessageStore { if (!data || !data.id) return null; const contentBlocks = safeParseContentBlocks(data.contentBlocks); + const lifecycle = safeParseLifecycleMetadata(data.lifecycle); const toolEvents = safeParseToolEvents(data.toolEvents); const parsedMetadata = safeParseMetadata(data.metadata); const extraHydration = hydrateExtra(data.extra, data.pluginMessage, data.custodyOfferV1); const sourceField = parseConnectorSourceField(data.source); const parsedSource = sourceField.kind === 'valid' ? sourceField.source : undefined; - const parsedQueueCustody = safeParseQueueCustody(data.queueCustody); - const parsedQueueCustodyAdmission = safeParseQueueCustodyAdmission(data.queueCustodyAdmission); const parsedRecall = safeParseMessageRecall(data.recall); + const from = + safeParseMessageFrom(data.from) ?? + safeParseLegacyLifecycleMessageFrom(data.lifecycle) ?? + messageFrom({ + userId: data.userId ?? 'unknown', + catId: (data.catId || null) as CatId | null, + ...(parsedSource ? { source: parsedSource } : {}), + ...(sourceField.kind === 'invalid' ? { sourceParseFailure: true as const } : {}), + ...(data.origin === 'stream' || data.origin === 'callback' || data.origin === 'briefing' + ? { origin: data.origin } + : {}), + }); const deletedAt = data.deletedAt ? parseInt(data.deletedAt, 10) : undefined; return { id: data.id, threadId: data.threadId || DEFAULT_THREAD_ID, userId: data.userId ?? 'unknown', + ...(from ? { from } : {}), catId: (data.catId || null) as CatId | null, content: data.content ?? '', + ...(lifecycle ? { lifecycle } : {}), ...(contentBlocks ? { contentBlocks } : {}), ...(toolEvents ? { toolEvents } : {}), ...(parsedMetadata ? { metadata: parsedMetadata } : {}), @@ -1168,8 +1882,6 @@ export class RedisMessageStore { ...(data.deliveredAt ? { deliveredAt: parseRedisNumber(data.deliveredAt) } : {}), ...(data.timelineOrderAt !== undefined ? { timelineOrderAt: parseRedisNumber(data.timelineOrderAt) } : {}), ...(data.deliveryStatus ? { deliveryStatus: data.deliveryStatus as StoredMessage['deliveryStatus'] } : {}), - ...(parsedQueueCustody ? { queueCustody: parsedQueueCustody } : {}), - ...(parsedQueueCustodyAdmission ? { queueCustodyAdmission: parsedQueueCustodyAdmission } : {}), ...(parsedRecall ? { recall: parsedRecall } : {}), ...(parsedSource ? { source: parsedSource } : {}), ...(sourceField.kind === 'invalid' ? { sourceParseFailure: true as const } : {}), @@ -1340,33 +2052,6 @@ export class RedisMessageStore { }; } - async listOwnerQueueCustodyLifecycles(ownerUserId: string): Promise { - const ids = await this.redis.zrange(MessageKeys.user(ownerUserId), 0, -1); - const records: QueueCustodyLifecycleRecord[] = []; - const batchSize = 500; - for (let offset = 0; offset < ids.length; offset += batchSize) { - const messages = await this.hydrateMessages(ids.slice(offset, offset + batchSize)); - for (const message of messages) { - const custody = message.queueCustody; - if ( - message.userId !== ownerUserId || - message.deletedAt || - !custody || - (custody.ownerUserId !== undefined && custody.ownerUserId !== ownerUserId) - ) { - continue; - } - records.push({ - messageId: message.id, - threadId: message.threadId, - userId: message.userId, - custody: structuredClone(custody), - }); - } - } - return records; - } - /** * Get mentions for a cat, ascending (oldest first after cursor). * #1200 §8.7 migration: scans INSIDE the visibility relation with match-counted @@ -1598,7 +2283,7 @@ export class RedisMessageStore { const messages = await this.hydrateMessages(ids); for (const msg of messages) { if (msg.deletedAt) continue; - if (msg.deliveryStatus === 'canceled') continue; + if (msg.deliveryStatus === 'canceled' && msg.lifecycle?.kind !== 'response') continue; if (!passesManagedHoldViewerBoundary(msg, userId)) { continue; } @@ -1630,10 +2315,7 @@ export class RedisMessageStore { userId?: string, options?: ThreadMessageReadOptions, ): Promise { - if ( - options?.includeQueuedUserMessages === true || - options?.includeExposedQueuedUserMessagesForCatId !== undefined - ) { + if (options?.includeQueuedUserMessages === true) { return this.getByThreadAfterRawTimeline(threadId, afterId, limit, userId, options); } @@ -1801,20 +2483,6 @@ export class RedisMessageStore { return result; } - async getByQueueExposure(threadId: string, targetCatId: string, invocationId: string): Promise { - const field = `${targetCatId}\0${invocationId}`; - const raw = await this.redis.hget(MessageKeys.queueExposureIndex(threadId), field); - if (!raw) return []; - let ids: string[]; - try { - const parsed = JSON.parse(raw) as unknown; - ids = Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []; - } catch { - return []; - } - return this.hydrateMessages(ids); - } - async getUnreadSummaryProjection( cursors: readonly ThreadUnreadProjectionCursor[], userId: string, @@ -2315,10 +2983,8 @@ export class RedisMessageStore { const pipeline = this.redis.multi(); - // Delete each message hash - for (const id of ids) { - pipeline.del(MessageKeys.detail(id)); - } + // Delete each authority hash. + for (const id of ids) pipeline.del(MessageKeys.detail(id)); // Delete the thread sorted set (even if empty — may still exist as empty key) pipeline.del(key); @@ -2328,7 +2994,7 @@ export class RedisMessageStore { // but no messages (e.g. after all messages were individually deleted). pipeline.del(MessageKeys.threadVisibility(threadId)); pipeline.del(MessageKeys.threadVisibilityMeta(threadId)); - pipeline.del(MessageKeys.queueExposureIndex(threadId)); + pipeline.del(MessageKeys.legacyQueueExposureIndex(threadId)); // Note: We don't clean up global timeline, user timeline, or mention sets // as those will auto-expire via TTL. Cleaning them would be O(n) expensive. @@ -2360,26 +3026,24 @@ export class RedisMessageStore { const msg = await this.getById(id); if (!msg) return null; const now = Date.now(); - await this.redis.hset(MessageKeys.detail(id), { - content: '', - contentBlocks: '', - toolEvents: '', - metadata: '', - extra: '', - pluginMessage: '', - custodyOfferV1: '', - thinking: '', - mentions: '[]', - deletedAt: String(now), - deletedBy, - _tombstone: '1', - }); + const transition = Number( + await this.redis.eval(HARD_DELETE_MESSAGE_LUA, 1, MessageKeys.detail(id), String(now), deletedBy), + ); + if (transition !== 1) return null; + const pipeline = this.redis.multi(); + for (const catId of msg.mentions) pipeline.zrem(MessageKeys.mentions(catId), id); + const cleanupResults = await pipeline.exec(); + if (!cleanupResults) throw new Error('message hard delete: pipeline exec aborted'); + for (const [error] of cleanupResults) { + if (error) throw error; + } msg.content = ''; msg.mentions = []; delete msg.contentBlocks; delete msg.toolEvents; delete msg.metadata; delete msg.extra; + delete msg.lifecycle; delete msg.thinking; msg.deletedAt = now; msg.deletedBy = deletedBy; @@ -2432,7 +3096,7 @@ export class RedisMessageStore { evolutionPreparationSubmissionV1: _stripPreparation, deliveryBoundary: _stripBoundary, collectiveOwnerAdmissionV1: _stripCollectiveAdmission, - collectiveWorkInvocationV1: _stripCollectiveInvocation, + collectiveWorkInvocationV1: _stripCollectiveWork, collectiveAuthorizationInvalid: _stripCollectiveInvalid, ...hostPatch } = extra as Record; @@ -2441,7 +3105,7 @@ export class RedisMessageStore { UPDATE_EXTRA_IF_NOT_RECALLED_LUA, 1, MessageKeys.detail(id), - serializeExtra(merged), + serializeExtra(merged ?? {}), pluginMessage ? JSON.stringify(pluginMessage) : '', ); if (Number(updated) !== 1) return null; @@ -2527,6 +3191,336 @@ export class RedisMessageStore { return (await this.getById(id)) ?? augmented; } + async commitLifecycleResponseTerminal( + id: string, + patch: LifecycleResponseTerminalPatch, + ): Promise { + return this.commitLifecycleResponseTerminalInternal(id, patch); + } + + private async commitLifecycleResponseTerminalInternal( + id: string, + patch: LifecycleResponseTerminalPatch, + ): Promise { + const key = MessageKeys.detail(id); + for (let attempt = 0; attempt < 4; attempt += 1) { + // Read the expected lifecycle before hydrating the full message. If a child + // settles between these reads, the Lua comparison fails instead of blessing + // a terminal state derived from mismatched snapshots. + const expectedLifecycleRaw = (await this.redis.hget(key, 'lifecycle')) ?? ''; + const current = await this.getById(id); + if (!current) return { kind: 'not_found' }; + const prepared = prepareRedisLifecycleResponseTerminal(current, patch); + if (prepared.kind === 'result') { + if (prepared.result.kind !== 'replayed') return prepared.result; + await this.ensureVisibilityMigrated(current.threadId); + return { + kind: 'replayed', + message: (await this.getById(id)) ?? prepared.result.message, + }; + } + await this.ensureVisibilityMigrated(current.threadId); + const outcome = Number( + await this.redis.eval( + COMMIT_LIFECYCLE_RESPONSE_TERMINAL_LUA, + 3, + key, + MessageKeys.threadVisibility(current.threadId), + MessageKeys.threadVisibilityMeta(current.threadId), + ...lifecycleResponseTerminalLuaArgs(patch, prepared.nextMessage, expectedLifecycleRaw), + ), + ); + // Both codes mean the snapshot changed after our read. Re-read even when + // another writer terminalized first: the next pass can distinguish an + // exact replay from a genuinely different terminal without stale data. + if (outcome === 0 || outcome === -4) continue; + if (outcome === -1) return { kind: 'not_found' }; + const message = await this.getById(id); + if (!message) throw new Error(`lifecycle response terminal committed but message vanished: ${id}`); + return lifecycleResponseTerminalResultFromRedis(outcome, message); + } + const message = await this.getById(id); + if (!message) return { kind: 'not_found' }; + return { kind: 'conflict', reason: 'different_terminal', message }; + } + + async commitLifecyclePreAdmissionFailure( + input: LifecyclePreAdmissionFailureInput, + ): Promise { + const source = await this.getById(input.sourceMessageId); + if (!source) return { kind: 'not_found' }; + const idempotencyKey = preAdmissionFailureIdempotencyKey(input.expectedEntryId); + const existingFailure = await this.getByIdempotencyKey('system', source.threadId, idempotencyKey); + if (existingFailure) { + if (!matchesLifecyclePreAdmissionFailure(existingFailure, source, input)) { + return { + kind: 'conflict', + reason: 'different_failure', + inputMessage: source, + failureMessage: existingFailure, + }; + } + const targetProjectionReplayed = input.requestedTargets.every((targetId) => + source.lifecycle?.dispatchRefs?.some( + (ref) => ref.targetId === targetId && ref.phase === 'settled' && ref.statusMessageId === existingFailure.id, + ), + ); + const queuedInputReplayed = + source.deliveryStatus === 'delivered' && + source.lifecycle?.kind === 'input' && + (input.requestedTargets.length === 0 || targetProjectionReplayed); + const publicWakeReplayed = + source.deliveryStatus === undefined && messageFrom(source).kind === 'agent' && targetProjectionReplayed; + if (!queuedInputReplayed && !publicWakeReplayed) { + return { + kind: 'conflict', + reason: 'invalid_failure', + inputMessage: source, + failureMessage: existingFailure, + }; + } + return { kind: 'replayed', inputMessage: source, failureMessage: existingFailure }; + } + + const isQueuedInput = source.deliveryStatus === 'queued'; + const isPublicAgentWake = + source.deliveryStatus === undefined && + messageFrom(source).kind === 'agent' && + source.visibility !== 'whisper' && + source.lifecycle?.kind !== 'delivery_failure'; + + const uniqueTargets = new Set(input.requestedTargets); + if ( + !Number.isInteger(input.failedAt) || + input.failedAt < source.timestamp || + input.requestedTargets.some((target) => typeof target !== 'string' || target.length === 0) || + uniqueTargets.size !== input.requestedTargets.length || + input.requestedTargets.length > 1 || + (isPublicAgentWake && input.requestedTargets.length === 0) || + (!isQueuedInput && !isPublicAgentWake) + ) { + return { kind: 'conflict', reason: 'invalid_failure', inputMessage: source }; + } + const inputIdentity = lifecycleInputIdentityForStoredMessage(source); + const assigned = assignLifecycleDispatchTargetsMetadata(source.lifecycle, inputIdentity, input.requestedTargets); + if (assigned.kind === 'conflict') { + return { kind: 'conflict', reason: 'invalid_failure', inputMessage: source }; + } + const failureId = generateSortableId(input.failedAt); + const settledSourceLifecycle = + input.requestedTargets.length === 0 + ? assigned.lifecycle + : settleAssignedLifecycleDispatchFailureMetadata( + assigned.lifecycle, + input.requestedTargets, + failureId, + input.failedAt, + ); + if (!settledSourceLifecycle) { + return { kind: 'conflict', reason: 'invalid_failure', inputMessage: source }; + } + const failureLifecycle: LifecycleStoredMessageMetadata = { + kind: 'delivery_failure', + orderKey: `${input.failedAt}:${source.id}:failure`, + status: 'failed', + sourceEntryId: input.expectedEntryId, + inputMessageId: source.id, + requestedTargets: [...input.requestedTargets], + reason: input.reason, + createdAt: input.failedAt, + }; + const failureInput = canonicalizeAppendMessageInput({ + from: { kind: 'system', service: 'message_delivery' }, + userId: 'system', + threadId: source.threadId, + content: input.content, + ...(input.contentBlocks ? { contentBlocks: input.contentBlocks } : {}), + mentions: [], + timestamp: input.failedAt, + lifecycle: failureLifecycle, + idempotencyKey, + }); + if ( + !isLifecycleStoredMessageMetadata(settledSourceLifecycle) || + !isLifecycleStoredMessageMetadata(failureLifecycle) + ) { + return { kind: 'conflict', reason: 'invalid_failure', inputMessage: source }; + } + await this.ensureVisibilityMigrated(source.threadId); + const result = (await this.redis.eval( + COMMIT_LIFECYCLE_PRE_ADMISSION_FAILURE_LUA, + 1, + MessageKeys.detail(source.id), + this.keyPrefix, + failureId, + source.threadId, + source.userId, + input.expectedEntryId, + String(input.failedAt), + source.lifecycle === undefined ? '' : JSON.stringify(source.lifecycle), + JSON.stringify(settledSourceLifecycle), + JSON.stringify(failureLifecycle), + failureInput.content, + failureInput.contentBlocks === undefined ? '' : JSON.stringify(failureInput.contentBlocks), + MessageKeys.idempotency('system', source.threadId, idempotencyKey), + String(this.ttlSeconds ?? 0), + isPublicAgentWake ? 'public_wake' : 'queued_input', + JSON.stringify(failureInput.from), + )) as [number | string, string]; + const outcome = Number(result[0]); + if (outcome === -1) return { kind: 'not_found' }; + + const inputMessage = await this.getById(source.id); + if (!inputMessage) { + throw new Error(`pre-admission failure transaction lost source message: ${source.id}`); + } + if (outcome < 0) { + const reason = outcome === -2 ? 'not_queued' : 'invalid_failure'; + return { kind: 'conflict', reason, inputMessage }; + } + + const committedFailureId = result[1]; + const failureMessage = await this.getById(committedFailureId); + if (!failureMessage) { + throw new Error(`pre-admission failure transaction lost failure message: ${committedFailureId}`); + } + if (!matchesLifecyclePreAdmissionFailure(failureMessage, inputMessage, input)) { + return { + kind: 'conflict', + reason: 'different_failure', + inputMessage, + failureMessage, + }; + } + const targetProjectionCommitted = input.requestedTargets.every((targetId) => + inputMessage.lifecycle?.dispatchRefs?.some( + (ref) => ref.targetId === targetId && ref.phase === 'settled' && ref.statusMessageId === failureMessage.id, + ), + ); + const queuedInputCommitted = + inputMessage.deliveryStatus === 'delivered' && + inputMessage.lifecycle?.kind === 'input' && + (input.requestedTargets.length === 0 || targetProjectionCommitted); + const publicWakeCommitted = + inputMessage.deliveryStatus === undefined && + messageFrom(inputMessage).kind === 'agent' && + targetProjectionCommitted; + if (!queuedInputCommitted && !publicWakeCommitted) { + return { + kind: 'conflict', + reason: 'invalid_failure', + inputMessage, + failureMessage, + }; + } + if (outcome === 1 && this.onAppend) { + try { + void Promise.resolve(this.onAppend(failureMessage)).catch(() => {}); + } catch { + /* best-effort */ + } + } + if (outcome === 1) return { kind: 'applied', inputMessage, failureMessage }; + if (outcome === 2) return { kind: 'replayed', inputMessage, failureMessage }; + throw new Error(`unexpected lifecycle pre-admission failure outcome: ${outcome}`); + } + + async advanceLifecycleInputDispatch( + id: string, + patch: LifecycleInputDispatchPatch, + ): Promise { + const key = MessageKeys.detail(id); + for (let attempt = 0; attempt < 4; attempt++) { + const raw = await this.redis.hget(key, 'lifecycle'); + const current = raw ? safeParseLifecycleMetadata(raw) : undefined; + const message = await this.getById(id); + if (!message) return { kind: 'not_found' }; + if (raw && !current) return { kind: 'conflict', reason: 'not_input', message }; + const transition = advanceLifecycleInputDispatchMetadata(current, patch); + if (transition.kind === 'conflict') return { ...transition, message }; + if (transition.kind === 'replayed') return { kind: 'replayed', message }; + const outcome = Number( + await this.redis.eval(CAS_LIFECYCLE_METADATA_LUA, 1, key, raw ?? '', JSON.stringify(transition.lifecycle)), + ); + if (outcome === 0) continue; + if (outcome === -1) return { kind: 'not_found' }; + if (outcome === -2) return { kind: 'conflict', reason: 'not_input', message }; + if (outcome !== 1) throw new Error(`unexpected lifecycle input dispatch outcome: ${outcome}`); + const applied = await this.getById(id); + if (!applied) throw new Error(`lifecycle input dispatch committed but message vanished: ${id}`); + return { kind: 'applied', message: applied }; + } + const message = await this.getById(id); + if (!message) return { kind: 'not_found' }; + return { kind: 'conflict', reason: 'identity_mismatch', message }; + } + + async commitLifecycleAppendAdmission( + input: LifecycleAppendAdmissionInput, + ): Promise { + const ids = [...input.inputMessageIds, ...input.runs.map((run) => run.responseMessageId)]; + const keys = ids.map((id) => MessageKeys.detail(id)); + for (let attempt = 0; attempt < 4; attempt += 1) { + const [messages, rawLifecycles] = await Promise.all([ + Promise.all(ids.map((id) => this.getById(id))), + Promise.all(keys.map((key) => this.redis.hget(key, 'lifecycle'))), + ]); + if (messages.some((message) => !message)) return { kind: 'not_found' }; + const prepared = prepareLifecycleAppendAdmission(messages as StoredMessage[], input); + if (prepared.kind !== 'prepared') return prepared; + if (prepared.replayed) { + return { kind: 'replayed', messages: messages as StoredMessage[] }; + } + const argv = prepared.lifecycles.flatMap((lifecycle, index) => [ + rawLifecycles[index] ?? '', + JSON.stringify(lifecycle), + ]); + const outcome = Number(await this.redis.eval(CAS_LIFECYCLE_APPEND_ADMISSION_LUA, keys.length, ...keys, ...argv)); + if (outcome === 0) continue; + if (outcome === -1) return { kind: 'not_found' }; + if (outcome === -2) return { kind: 'conflict', reason: 'scope_mismatch' }; + if (outcome !== 1) throw new Error(`unexpected lifecycle Append admission outcome: ${outcome}`); + const applied = await Promise.all(ids.map((id) => this.getById(id))); + if (applied.some((message) => !message)) { + throw new Error('lifecycle Append admission committed but a message vanished'); + } + return { kind: 'applied', messages: applied as StoredMessage[] }; + } + return { kind: 'conflict', reason: 'response_lifecycle_conflict' }; + } + + async commitLifecycleAppendRejection( + input: LifecycleAppendRejectionInput, + ): Promise { + const ids = [...input.inputMessageIds, input.run.responseMessageId]; + const keys = ids.map((id) => MessageKeys.detail(id)); + for (let attempt = 0; attempt < 4; attempt += 1) { + const [messages, rawLifecycles] = await Promise.all([ + Promise.all(ids.map((id) => this.getById(id))), + Promise.all(keys.map((key) => this.redis.hget(key, 'lifecycle'))), + ]); + if (messages.some((message) => !message)) return { kind: 'not_found' }; + const prepared = prepareLifecycleAppendRejection(messages as StoredMessage[], input); + if (prepared.kind !== 'prepared') return prepared; + if (prepared.replayed) return { kind: 'replayed', messages: messages as StoredMessage[] }; + const argv = prepared.lifecycles.flatMap((lifecycle, index) => [ + rawLifecycles[index] ?? '', + JSON.stringify(lifecycle), + ]); + const outcome = Number(await this.redis.eval(CAS_LIFECYCLE_APPEND_ADMISSION_LUA, keys.length, ...keys, ...argv)); + if (outcome === 0) continue; + if (outcome === -1) return { kind: 'not_found' }; + if (outcome === -2) return { kind: 'conflict', reason: 'scope_mismatch' }; + if (outcome !== 1) throw new Error(`unexpected lifecycle Append rejection outcome: ${outcome}`); + const applied = await Promise.all(ids.map((id) => this.getById(id))); + if (applied.some((message) => !message)) { + throw new Error('lifecycle Append rejection committed but a message vanished'); + } + return { kind: 'applied', messages: applied as StoredMessage[] }; + } + return { kind: 'conflict', reason: 'lifecycle_conflict' }; + } + /** * F098-D: Atomically mark a queued message delivered while preserving the * Clowder AI publication score and returning an applied/no-op receipt. @@ -2577,116 +3571,11 @@ export class RedisMessageStore { return { kind: 'prepared', message }; } - async initializeQueueCustodyAdmission( - id: string, - admission: QueueCustodyAdmissionIntent, - ): Promise { - assertQueueCustodyMessageBinding({ deliveryStatus: 'queued', queueCustodyAdmission: admission }); - const outcome = Number( - await this.redis.eval( - INITIALIZE_QUEUE_CUSTODY_ADMISSION_LUA, - 1, - MessageKeys.detail(id), - JSON.stringify(admission), - ), - ); - if (outcome === -1) return { kind: 'not_found' }; - if (outcome === -2) return { kind: 'not_queued' }; - if (outcome === -3) return { kind: 'conflict' }; - const message = await this.getById(id); - if (!message) return { kind: 'not_found' }; - if (outcome === 0) return { kind: 'existing', message }; - if (outcome !== 1) throw new Error(`unexpected queue custody admission initialize result: ${outcome}`); - return { kind: 'initialized', message }; - } - - async initializeQueueCustody(id: string, custody: QueuedMessageCustody): Promise { - assertQueueCustodyMessageBinding({ deliveryStatus: 'queued', queueCustody: custody }); - const outcome = Number( - await this.redis.eval( - INITIALIZE_QUEUE_CUSTODY_LUA, - 1, - MessageKeys.detail(id), - JSON.stringify(custody), - String(custody.revision), - ), - ); - if (outcome === -1) return { kind: 'not_found' }; - if (outcome === -2) return { kind: 'not_queued' }; - const message = await this.getById(id); - if (!message) return { kind: 'not_found' }; - if (outcome === 0) return { kind: 'existing', message }; - if (outcome !== 1) throw new Error(`unexpected queue custody initialize result: ${outcome}`); - return { kind: 'initialized', message }; - } - - async transitionQueueCustody(id: string, input: QueueCustodyTransitionInput): Promise { - if (input.deliveredAt !== undefined) assertValidStoredMessageTimestamp(input.deliveredAt); - const current = await this.getById(id); - if (!current?.queueCustody) return { kind: 'not_found' }; - if (current.queueCustody.revision !== input.expectedRevision) { - return { kind: 'revision_mismatch', actualRevision: current.queueCustody.revision }; - } - const isExposedRecallSettlement = - current.deliveryStatus === 'canceled' && - current.recall?.exposure === 'seen' && - current.queueCustody.status === 'terminal' && - input.next.status === 'terminal' && - input.deliveredAt === undefined; - if (current.deliveryStatus !== 'queued' && !isExposedRecallSettlement) { - throw new Error('queue custody transition requires a queued message or exposed recall tombstone'); - } - if (input.replacement && input.replacement.sourceMessageId !== id) { - throw new Error('queue custody replacement proof source message mismatch'); - } - assertQueueCustodyTransition(current.queueCustody, input); - assertTypedWaitCustodyBindings(current, input.next, input.waitContinuationGuards); - const timelineScore = - input.deliveredAt === undefined ? undefined : resolveDeliveryTimelineScore(current, input.deliveredAt); - - // #1269 P1-2: ensure visibility migration before delivery (same as markDelivered). - // Custody transitions without delivery skip this (no visibility allocation needed). - if (input.deliveredAt !== undefined) { - await this.ensureVisibilityMigrated(current.threadId); - } - - const waitGuards = await readRedisTypedWaitCustodyGuards(this.redis, input.waitContinuationGuards); - const rawResult = (await this.redis.eval( - TRANSITION_QUEUE_CUSTODY_LUA, - 5 + waitGuards.keys.length, - MessageKeys.detail(id), - MessageKeys.thread(current.threadId), - MessageKeys.TIMELINE, - MessageKeys.user(current.userId), - MessageKeys.queueExposureIndex(current.threadId), - ...waitGuards.keys, - String(input.expectedRevision), - JSON.stringify(input.next), - String(input.next.revision), - input.deliveredAt === undefined ? '' : String(input.deliveredAt), - timelineScore === undefined ? '' : String(timelineScore), - this.keyPrefix, // [6] keyPrefix for visibility key construction inside Lua - JSON.stringify(waitGuards.witnesses), - )) as [number | string, number | string]; - const outcome = Number(rawResult[0]); - const actualRevision = Number(rawResult[1]); - if (outcome === -1) return { kind: 'not_found' }; - if (outcome === 0) return { kind: 'revision_mismatch', actualRevision }; - if (outcome === -2) { - throw new Error('queue custody transition requires a queued message or exposed recall tombstone'); - } - if (outcome === -3) rejectTypedWaitCustody('authority_changed'); - if (outcome !== 1 && outcome !== 2) throw new Error(`unexpected queue custody transition result: ${outcome}`); - - const updated = await this.getById(id); - if (!updated) throw new Error(`queue custody transition lost stored message: ${id}`); - return { kind: 'updated', message: updated, deliveryTransitioned: outcome === 2 }; - } - /** * F117: Mark a queued message as canceled (withdraw/clear). - * #1200: CANCEL_WITH_VISIBILITY_LUA extends the original to ZREM from the - * visibility index (handles backfilled legacy queued members). + * #1200: canceled source work is removed from visibility. Lifecycle response + * rows retain their index position because cancellation is their visible + * member-owned terminal state. */ async markCanceled(id: string): Promise { const hashKey = MessageKeys.detail(id); @@ -2784,57 +3673,12 @@ export class RedisMessageStore { const d = data as Record; if (!d.id) continue; - const deletedAt = d.deletedAt ? parseInt(d.deletedAt, 10) : undefined; + const message = this.hydrateHash(d); + if (!message) continue; // ADR-008 D3: skip soft-deleted messages unless includeDeleted - if (deletedAt && !options?.includeDeleted) continue; - - const contentBlocks = safeParseContentBlocks(d.contentBlocks); - const toolEvents = safeParseToolEvents(d.toolEvents); - const parsedMetadata = safeParseMetadata(d.metadata); - const extraHydration = hydrateExtra(d.extra, d.pluginMessage, d.custodyOfferV1); - const sourceField = parseConnectorSourceField(d.source); - const parsedSource = sourceField.kind === 'valid' ? sourceField.source : undefined; - const parsedQueueCustody = safeParseQueueCustody(d.queueCustody); - const parsedQueueCustodyAdmission = safeParseQueueCustodyAdmission(d.queueCustodyAdmission); - const parsedRecall = safeParseMessageRecall(d.recall); - messages.push({ - id: d.id, - threadId: d.threadId || DEFAULT_THREAD_ID, - userId: d.userId ?? 'unknown', - catId: (d.catId || null) as CatId | null, - content: d.content ?? '', - ...(contentBlocks ? { contentBlocks } : {}), - ...(toolEvents ? { toolEvents } : {}), - ...(parsedMetadata ? { metadata: parsedMetadata } : {}), - ...(extraHydration.extra ? { extra: extraHydration.extra } : {}), - ...(extraHydration.custodyOfferParseFailure ? { custodyOfferParseFailure: true as const } : {}), - mentions: safeParseMentions(d.mentions), - timestamp: parseStoredMessageTimestamp(d.timestamp), - ...(deletedAt ? { deletedAt, deletedBy: d.deletedBy ?? '' } : {}), - ...(d._tombstone === '1' ? { _tombstone: true as const } : {}), - ...(d.thinking ? { thinking: d.thinking } : {}), - ...(d.origin === 'stream' || d.origin === 'callback' || d.origin === 'briefing' - ? { origin: d.origin as 'stream' | 'callback' | 'briefing' } - : {}), - ...(d.visibility === 'whisper' ? { visibility: 'whisper' as const } : {}), - ...(d.whisperTo ? { whisperTo: safeParseMentions(d.whisperTo) } : {}), - ...(d.revealedAt ? { revealedAt: parseInt(d.revealedAt, 10) } : {}), - ...(d.deliveredAt ? { deliveredAt: parseRedisNumber(d.deliveredAt) } : {}), - ...(d.timelineOrderAt !== undefined ? { timelineOrderAt: parseRedisNumber(d.timelineOrderAt) } : {}), - ...(d.deliveryStatus ? { deliveryStatus: d.deliveryStatus as StoredMessage['deliveryStatus'] } : {}), - ...(parsedQueueCustody ? { queueCustody: parsedQueueCustody } : {}), - ...(parsedQueueCustodyAdmission ? { queueCustodyAdmission: parsedQueueCustodyAdmission } : {}), - ...(parsedRecall ? { recall: parsedRecall } : {}), - ...(parsedSource ? { source: parsedSource } : {}), - ...(sourceField.kind === 'invalid' ? { sourceParseFailure: true as const } : {}), - ...(d.mentionsUser === '1' ? { mentionsUser: true } : {}), - ...(d.replyTo ? { replyTo: d.replyTo } : {}), - // #1200 Sol R6 P2-1: Inject visibilitySeq from hash (parity with hydrateHash). - // Without this, getRecentMentionsFor returns items without visibilitySeq, - // causing cursorFor to emit v1 while getter cursors are v2 → cross-format. - ...(d.visibilitySeq ? { visibilitySeq: parseInt(d.visibilitySeq, 10) } : {}), - }); + if (message.deletedAt && !options?.includeDeleted) continue; + messages.push(message); } return messages; } diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisThreadReadStateStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisThreadReadStateStore.ts index b0b9b38a9a..fdf1c4124f 100644 --- a/packages/api/src/domains/cats/services/stores/redis/RedisThreadReadStateStore.ts +++ b/packages/api/src/domains/cats/services/stores/redis/RedisThreadReadStateStore.ts @@ -4,6 +4,7 @@ */ import { type RedisClient, VISIBILITY_RESOLVE_SEQ_LUA } from '@cat-cafe/shared/utils'; +import { messageFrom } from '../message-from.js'; import type { IMessageStore } from '../ports/MessageStore.js'; import type { IThreadReadStateStore, @@ -283,10 +284,9 @@ export class RedisThreadReadStateStore implements IThreadReadStateStore { includeQueuedCatMessages: true, unresolvedCursorPolicy: 'empty', }); - // P1-2 fix: exclude user's own typed messages + deleted/tombstone messages - // Cat messages (catId !== null) and connector messages (source) are counted as unread. - // Only the user's own direct messages (catId === null, no source) are excluded. - const relevant = unreadMessages.filter((m) => !m.deletedAt && (m.catId !== null || !!m.source)); + // Exclude the authenticated user's own messages. MessageFrom is the + // identity authority; catId/source are retained only for legacy rows. + const relevant = unreadMessages.filter((m) => !m.deletedAt && messageFrom(m).kind !== 'user'); const unreadCount = relevant.length; const hasUserMention = relevant.some((m) => !!m.mentionsUser); diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisTypedWaitCustodyGuard.ts b/packages/api/src/domains/cats/services/stores/redis/RedisTypedWaitCustodyGuard.ts deleted file mode 100644 index 2c48c7ccd6..0000000000 --- a/packages/api/src/domains/cats/services/stores/redis/RedisTypedWaitCustodyGuard.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { AwaitStateV1 } from '@cat-cafe/shared'; -import type { RedisClient } from '@cat-cafe/shared/utils'; -import { rejectTypedWaitCustody, type TypedWaitCustodyGuard } from '../../../../ball-custody/TypedWaitCustodyGuard.js'; -import { - isLiveTypedWaitRegistration, - parseTypedWaitRegistration, - TYPED_WAIT_REGISTRATION_FIELD, -} from '../../../../ball-custody/TypedWaitRegistration.js'; -import { TaskKeys } from '../redis-keys/task-keys.js'; -import { hydrateTask } from './RedisTaskCodec.js'; - -/** Read private proof and Task together; the Queue Lua script rechecks these fields atomically. */ -export async function readRedisTypedWaitCustodyGuards( - redis: RedisClient, - guards: readonly TypedWaitCustodyGuard[] = [], -) { - const keys: string[] = []; - const witnesses: Array<{ - fields: Record; - expiresAt: number; - identity: TypedWaitCustodyGuard['identity']; - active: AwaitStateV1; - }> = []; - for (const guard of guards) { - const key = TaskKeys.detail(guard.reference.taskId); - let raw: Record; - try { - raw = await redis.hgetall(key); - } catch (error) { - rejectTypedWaitCustody('query_failed', error); - } - const receipt = parseTypedWaitRegistration(raw[TYPED_WAIT_REGISTRATION_FIELD]); - const task = raw.id ? hydrateTask(raw) : null; - const active = task?.automationState?.await; - if ( - !task || - !active || - !receipt || - !isLiveTypedWaitRegistration({ task, receipt }, guard.identity, Date.now(), guard.reference) - ) { - rejectTypedWaitCustody('authority_stale'); - } - const fields: Record = {}; - for (const field of [ - 'id', - 'kind', - 'threadId', - 'userId', - 'ownerCatId', - 'status', - 'subjectKey', - TYPED_WAIT_REGISTRATION_FIELD, - ]) { - fields[field] = raw[field] ?? ''; - } - keys.push(key); - witnesses.push({ fields, expiresAt: receipt.expiresAt, identity: guard.identity, active }); - } - return { keys, witnesses }; -} - -/** Insert before ALL mutations; Redis errors do not roll back earlier script writes. */ -export const ASSERT_TYPED_WAIT_CUSTODY_LUA = ` -local function sameWaitJson(left, right) - if type(left) ~= type(right) then return false end - if type(left) ~= 'table' then return left == right end - for key, value in pairs(left) do - if not sameWaitJson(value, right[key]) then return false end - end - for key, _ in pairs(right) do if left[key] == nil then return false end end - return true -end -local waitGuards = cjson.decode(ARGV[7]) -if #waitGuards > 0 then -local time = redis.call('TIME') -local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) -local currentCustody = cjson.decode(custody) -local source = cjson.decode(redis.call('HGET', KEYS[1], 'source') or '{}') -for index, witness in ipairs(waitGuards) do - if tonumber(witness.expiresAt) <= now then return {-3, currentRevision} end - local okState, state = pcall(cjson.decode, redis.call('HGET', KEYS[5 + index], 'automationState') or '{}') - if not okState or type(state) ~= 'table' or not sameWaitJson(state.await, witness.active) - or (type(state.waitOutcome) == 'table' and state.waitOutcome.generation == witness.active.generation) then - return {-3, currentRevision} - end - if messageId ~= witness.identity.sourceMessageId - or redis.call('HGET', KEYS[1], 'threadId') ~= witness.identity.threadId - or currentCustody.ownerUserId ~= witness.identity.userId - or source.connector ~= 'hold-ball' or not source.meta or source.meta.wakeWhen ~= true - or source.meta.threadId ~= witness.identity.threadId or source.meta.catId ~= witness.identity.catId - or source.meta.taskId ~= witness.identity.holdTaskId then return {-3, currentRevision} end - for field, value in pairs(witness.fields) do - if (redis.call('HGET', KEYS[5 + index], field) or '') ~= value then - return {-3, currentRevision} - end - end -end -end -`; diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-message-append.ts b/packages/api/src/domains/cats/services/stores/redis/redis-message-append.ts index 1c99493683..98ce2e57e4 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-message-append.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-message-append.ts @@ -1,8 +1,6 @@ import type { RedisClient } from '@cat-cafe/shared/utils'; -import { normalizeJsonUnicode } from '../../../../../utils/json-unicode.js'; import type { AppendMessageInput, MessageAppendListener, StoredMessage } from '../ports/MessageStore.js'; -import { assertValidAppendMessageInput, DEFAULT_THREAD_ID, generateSortableId } from '../ports/MessageStore.js'; -import { assertQueueCustodyMessageBinding } from '../ports/queued-message-custody.js'; +import { canonicalizeAppendMessageInput, DEFAULT_THREAD_ID, generateSortableId } from '../ports/MessageStore.js'; import { MessageKeys } from '../redis-keys/message-keys.js'; import { serializeExtra } from './redis-message-parsers.js'; @@ -68,7 +66,11 @@ end return {'committed', messageId} `; -function serializeMessage(message: AppendMessageInput, id: string, threadId: string): Record { +function serializeMessage( + message: ReturnType, + id: string, + threadId: string, +): Record { // F288: split pluginMessage from host extra — stored as independent hash field const { pluginMessage, ...hostExtra } = message.extra ?? {}; const hasHostExtra = Object.keys(hostExtra).length > 0; @@ -76,8 +78,10 @@ function serializeMessage(message: AppendMessageInput, id: string, threadId: str id, threadId, userId: message.userId, + from: JSON.stringify(message.from), catId: message.catId ?? '', content: message.content, + ...(message.lifecycle ? { lifecycle: JSON.stringify(message.lifecycle) } : {}), mentions: JSON.stringify(message.mentions), timestamp: String(message.timestamp), ...(message.contentBlocks !== undefined ? { contentBlocks: JSON.stringify(message.contentBlocks) } : {}), @@ -92,13 +96,6 @@ function serializeMessage(message: AppendMessageInput, id: string, threadId: str ...(message.source ? { source: JSON.stringify(message.source) } : {}), ...(message.mentionsUser ? { mentionsUser: '1' } : {}), ...(message.deliveryStatus ? { deliveryStatus: message.deliveryStatus } : {}), - ...(message.queueCustody - ? { - queueCustody: JSON.stringify(message.queueCustody), - queueCustodyRevision: String(message.queueCustody.revision), - } - : {}), - ...(message.queueCustodyAdmission ? { queueCustodyAdmission: JSON.stringify(message.queueCustodyAdmission) } : {}), ...(message.replyTo ? { replyTo: message.replyTo } : {}), }; } @@ -111,9 +108,7 @@ export async function appendMessage(input: { onAppend?: MessageAppendListener; }): Promise { const { redis, ttlSeconds, loadById, onAppend } = input; - const message = normalizeJsonUnicode(input.message); - assertValidAppendMessageInput(message); - assertQueueCustodyMessageBinding(message); + const message = canonicalizeAppendMessageInput(input.message); const threadId = message.threadId ?? DEFAULT_THREAD_ID; const id = generateSortableId(message.timestamp); diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts b/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts index 7ed3a83492..ec53aca340 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts @@ -30,7 +30,7 @@ * ARGV[2] = deliveredAt (string number) * ARGV[3] = keyPrefix (e.g. "cat-cafe:") — for constructing zset keys inside Lua * - * Returns: {-1, {}} when missing, {0, HGETALL} on a state/custody no-op, or + * Returns: {-1, {}} when missing, {0, HGETALL} on a state no-op, or * {1, HGETALL} on a CAS win. Returning the canonical hash with the outcome * preserves Clowder AI's deliveryTransitioned receipt without a post-EVAL read gap. */ @@ -49,39 +49,18 @@ if status ~= 'queued' then return {0, redis.call('HGETALL', hash)} end --- A durable pre-CAS fan-out intent is active Queue work, not legacy speech --- whose visibility can be repaired by marking it delivered. -local admission = redis.call('HGET', hash, 'queueCustodyAdmission') -if admission and admission ~= '' then - return {0, redis.call('HGETALL', hash)} -end - --- F254: legacy markDelivered must not bypass active execution custody. -local custody = redis.call('HGET', hash, 'queueCustody') -if custody and custody ~= '' then - local custodyProjection = cjson.decode(custody) - if custodyProjection.status ~= 'terminal' then - return {0, redis.call('HGETALL', hash)} - end -end - local userId = redis.call('HGET', hash, 'userId') local threadId = redis.call('HGET', hash, 'threadId') local timestamp = redis.call('HGET', hash, 'timestamp') local catId = redis.call('HGET', hash, 'catId') local origin = redis.call('HGET', hash, 'origin') -local source = redis.call('HGET', hash, 'source') --- Preserve Clowder AI publication order: already-published real-cat speech and --- owner-visible queued user receipts keep their authored timestamp; private --- queued work enters the timeline at delivery. +-- Preserve Clowder AI publication order only for already-published real-cat +-- speech. Undelivered owner work enters History at actual delivery. local isRealCatSpeech = catId and catId ~= '' and catId ~= 'system' and userId ~= 'system' and userId ~= 'scheduler' and origin ~= 'briefing' -local isQueuedUserReceipt = (not catId or catId == '') and (not source or source == '') - and userId ~= 'system' and userId ~= 'scheduler' and origin ~= 'briefing' - and custody and custody ~= '' local timelineScore = deliveredAt -if isRealCatSpeech or isQueuedUserReceipt then +if isRealCatSpeech then timelineScore = timestamp end @@ -112,7 +91,6 @@ if status ~= 'queued' then return {0, redis.call('HGETALL', hash)} end redis.call('HSET', hash, 'deliveryStatus', 'canceled') -redis.call('HDEL', hash, 'queueCustody', 'queueCustodyRevision', 'queueCustodyAdmission') return {1, redis.call('HGETALL', hash)} `; diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-message-frontier-append.ts b/packages/api/src/domains/cats/services/stores/redis/redis-message-frontier-append.ts index b81cfd7878..67c0e0ea2f 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-message-frontier-append.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-message-frontier-append.ts @@ -1,13 +1,11 @@ import type { RedisClient } from '@cat-cafe/shared/utils'; -import { normalizeJsonUnicode } from '../../../../../utils/json-unicode.js'; import type { AppendMessageInput, StoredMessage, ThreadFrontierAppendResult, ThreadObservedAppendResult, } from '../ports/MessageStore.js'; -import { assertValidAppendMessageInput, DEFAULT_THREAD_ID, generateSortableId } from '../ports/MessageStore.js'; -import { assertQueueCustodyMessageBinding } from '../ports/queued-message-custody.js'; +import { canonicalizeAppendMessageInput, DEFAULT_THREAD_ID, generateSortableId } from '../ports/MessageStore.js'; import { MessageKeys } from '../redis-keys/message-keys.js'; import { serializeExtra } from './redis-message-parsers.js'; @@ -20,13 +18,37 @@ local latestRows = redis.call('ZREVRANGE', KEYS[1], 0, 0) local actual = latestRows[1] or '' local expected = ARGV[1] == '__NULL__' and '' or ARGV[1] if actual ~= expected then return {'frontier', actual} end + +local visibilitySeq = nil +if ARGV[7] == '1' then + local hwmRaw = redis.call('HGET', KEYS[7], 'hwm') + local hwm = 0 + if hwmRaw ~= false then + hwm = tonumber(hwmRaw) + if hwm == nil or hwm ~= hwm or hwm ~= math.floor(hwm) or hwm < 0 then + return redis.error_reply('VISIBILITY_HWM_INVALID: raw=' .. tostring(hwmRaw)) + end + end + local timeArr = redis.call('TIME') + local nowMs = tonumber(timeArr[1]) * 1000 + math.floor(tonumber(timeArr[2]) / 1000) + visibilitySeq = math.max(hwm + 1, nowMs) + if visibilitySeq > 9007199254730991 then + return redis.error_reply('VISIBILITY_SEQ_EXHAUSTED: seq=' .. tostring(visibilitySeq)) + end +end + if ARGV[4] == '1' then redis.call('SET', KEYS[5], ARGV[2]) end local fields = cjson.decode(ARGV[6]) for field, value in pairs(fields) do redis.call('HSET', KEYS[2], field, value) end redis.call('ZADD', KEYS[1], ARGV[3], ARGV[2]) redis.call('ZADD', KEYS[3], ARGV[3], ARGV[2]) redis.call('ZADD', KEYS[4], ARGV[3], ARGV[2]) -for index = 6, #KEYS do redis.call('ZADD', KEYS[index], ARGV[3], ARGV[2]) end +for index = 8, #KEYS do redis.call('ZADD', KEYS[index], ARGV[3], ARGV[2]) end +if visibilitySeq then + redis.call('ZADD', KEYS[6], visibilitySeq, ARGV[2]) + redis.call('HSET', KEYS[7], 'migrated', '1', 'hwm', tostring(visibilitySeq)) + redis.call('HSET', KEYS[2], 'visibilitySeq', tostring(visibilitySeq)) +end local ttl = tonumber(ARGV[5]) if ttl and ttl > 0 then redis.call('EXPIRE', KEYS[2], ttl) @@ -34,7 +56,7 @@ if ttl and ttl > 0 then redis.call('EXPIRE', KEYS[3], ttl) redis.call('EXPIRE', KEYS[4], ttl) if ARGV[4] == '1' then redis.call('EXPIRE', KEYS[5], ttl) end - for index = 6, #KEYS do redis.call('EXPIRE', KEYS[index], ttl) end + for index = 8, #KEYS do redis.call('EXPIRE', KEYS[index], ttl) end end return {'committed', ARGV[2]} `; @@ -120,9 +142,7 @@ export async function appendMessageIfThreadFrontier(input: { onAppend?: (message: StoredMessage) => void | Promise; }): Promise { const { redis, expectedLatestMessageId, ttlSeconds, loadById, onAppend } = input; - const message = normalizeJsonUnicode(input.message); - assertValidAppendMessageInput(message); - assertQueueCustodyMessageBinding(message); + const message = canonicalizeAppendMessageInput(input.message); const threadId = message.threadId ?? DEFAULT_THREAD_ID; const id = generateSortableId(message.timestamp); // F288: split pluginMessage from host extra — stored as independent hash field @@ -132,8 +152,10 @@ export async function appendMessageIfThreadFrontier(input: { id, threadId, userId: message.userId, + from: JSON.stringify(message.from), catId: message.catId ?? '', content: message.content, + ...(message.lifecycle ? { lifecycle: JSON.stringify(message.lifecycle) } : {}), contentBlocks: message.contentBlocks ? JSON.stringify(message.contentBlocks) : '', toolEvents: message.toolEvents ? JSON.stringify(message.toolEvents) : '', metadata: message.metadata ? JSON.stringify(message.metadata) : '', @@ -148,13 +170,6 @@ export async function appendMessageIfThreadFrontier(input: { ...(message.source ? { source: JSON.stringify(message.source) } : {}), ...(message.mentionsUser ? { mentionsUser: '1' } : {}), ...(message.deliveryStatus ? { deliveryStatus: message.deliveryStatus } : {}), - ...(message.queueCustody - ? { - queueCustody: JSON.stringify(message.queueCustody), - queueCustodyRevision: String(message.queueCustody.revision), - } - : {}), - ...(message.queueCustodyAdmission ? { queueCustodyAdmission: JSON.stringify(message.queueCustodyAdmission) } : {}), ...(message.replyTo ? { replyTo: message.replyTo } : {}), }; const idempotencyRedisKey = message.idempotencyKey @@ -166,6 +181,8 @@ export async function appendMessageIfThreadFrontier(input: { MessageKeys.TIMELINE, MessageKeys.user(message.userId), idempotencyRedisKey, + MessageKeys.threadVisibility(threadId), + MessageKeys.threadVisibilityMeta(threadId), ...message.mentions.map((catId) => MessageKeys.mentions(catId)), ]; const [kind, value] = (await redis.eval( @@ -178,6 +195,7 @@ export async function appendMessageIfThreadFrontier(input: { message.idempotencyKey ? '1' : '0', String(ttlSeconds ?? 0), JSON.stringify(hashFields), + shouldPublishImmediately(message) ? '1' : '0', )) as [string, string]; if (kind === 'frontier') return { kind: 'frontier_advanced', actualLatestMessageId: value || null }; const stored = await loadById(value); @@ -197,6 +215,12 @@ function readStoredPriorFrontier(message: StoredMessage): string | null { return freshness && 'priorFrontierMessageId' in freshness ? freshness.priorFrontierMessageId : null; } +function shouldPublishImmediately(message: AppendMessageInput): boolean { + if (message.lifecycle?.kind === 'response' && message.lifecycle.status === 'processing') return false; + if (message.deliveryStatus !== 'queued') return true; + return message.from.kind === 'agent' && message.origin !== 'briefing'; +} + /** * ADR-042 D5: append is unconditional. The Lua step records the raw thread * frontier that existed immediately before the new row and stamps that exact @@ -206,15 +230,12 @@ function readStoredPriorFrontier(message: StoredMessage): string | null { export async function appendMessageAndObservePriorFrontier(input: { redis: RedisClient; message: AppendMessageInput; - timelinePublished: boolean; ttlSeconds: number | null; loadById: (messageId: string) => Promise; onAppend?: (message: StoredMessage) => void | Promise; }): Promise { - const { redis, timelinePublished, ttlSeconds, loadById, onAppend } = input; - const message = normalizeJsonUnicode(input.message); - assertValidAppendMessageInput(message); - assertQueueCustodyMessageBinding(message); + const { redis, ttlSeconds, loadById, onAppend } = input; + const message = canonicalizeAppendMessageInput(input.message); const threadId = message.threadId ?? DEFAULT_THREAD_ID; const id = generateSortableId(message.timestamp); // F288: split pluginMessage from host extra — stored as independent hash field @@ -224,8 +245,10 @@ export async function appendMessageAndObservePriorFrontier(input: { id, threadId, userId: message.userId, + from: JSON.stringify(message.from), catId: message.catId ?? '', content: message.content, + ...(message.lifecycle ? { lifecycle: JSON.stringify(message.lifecycle) } : {}), contentBlocks: message.contentBlocks ? JSON.stringify(message.contentBlocks) : '', toolEvents: message.toolEvents ? JSON.stringify(message.toolEvents) : '', metadata: message.metadata ? JSON.stringify(message.metadata) : '', @@ -240,13 +263,6 @@ export async function appendMessageAndObservePriorFrontier(input: { ...(message.source ? { source: JSON.stringify(message.source) } : {}), ...(message.mentionsUser ? { mentionsUser: '1' } : {}), ...(message.deliveryStatus ? { deliveryStatus: message.deliveryStatus } : {}), - ...(message.queueCustody - ? { - queueCustody: JSON.stringify(message.queueCustody), - queueCustodyRevision: String(message.queueCustody.revision), - } - : {}), - ...(message.queueCustodyAdmission ? { queueCustodyAdmission: JSON.stringify(message.queueCustodyAdmission) } : {}), ...(message.replyTo ? { replyTo: message.replyTo } : {}), }; const idempotencyRedisKey = message.idempotencyKey @@ -274,7 +290,7 @@ export async function appendMessageAndObservePriorFrontier(input: { message.idempotencyKey ? '1' : '0', String(ttlSeconds ?? 0), JSON.stringify(hashFields), - timelinePublished ? '1' : '0', + shouldPublishImmediately(message) ? '1' : '0', )) as [string, string, string]; const stored = await loadById(value); if (stored) { diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts b/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts index 97a85d475c..b1eeb9866e 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts @@ -7,9 +7,12 @@ import type { AsrPersonMemoryDynamicSceneEntryV1, CatId, + CatRoutingError, ConnectorSource, CrossThreadCoordination, + LifecycleStoredMessageMetadata, MessageContent, + MessageFrom, RichMessageExtra, WriteOpportunityPresentationRetryCarrierV1, WriteOpportunityReentryCarrierV1, @@ -18,11 +21,15 @@ import { acceptedRevisionSchema, acceptedSourceRefSchema, asrPersonMemoryDynamicSceneEntryV1Schema, + CatRoutingErrorSchema, catOwnedSeedCueCarrierV1Schema, collectiveOwnerAdmissionV1Schema, collectiveWorkInvocationV1Schema, deliveryDecisionCueCarrierV1Schema, evolutionPreparationSubmissionV1Schema, + isCloudBridgeRetryV1, + isLifecycleStoredMessageMetadata, + isMessageFrom, isProviderSemanticEvent, isValidAcceptedSource, isValidReviewSubjectRef, @@ -36,16 +43,20 @@ import { import { parsePluginMessageExtra } from '../../../../messaging/envelope.js'; import { MAX_PERSISTED_SUBEXECUTION_EVENTS, type MessageMetadata } from '../../types.js'; import { parseMessageDeliveryBoundary } from '../message-delivery-boundary.js'; -import type { - MessageRecallMarker, - StoredMessage, - StoredPluginMessage, - StoredToolEvent, -} from '../ports/MessageStore.js'; -import { parseQueueCustodyAdmissionIntent, parseQueuedMessageCustody } from '../ports/queued-message-custody.js'; +import { MessageRecallMarker, StoredMessage, StoredPluginMessage, StoredToolEvent } from '../ports/MessageStore.js'; import type { TurnExecutionMessageProjection } from '../ports/TurnExecutionStore.js'; import { parseRecoveryMarker } from './redis-message-recovery-parser.js'; +export function safeParseMessageFrom(raw: string | undefined | null): MessageFrom | undefined { + if (raw === undefined || raw === null) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + return isMessageFrom(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + function parsePluginMessage(value: unknown): StoredPluginMessage | undefined { return (parsePluginMessageExtra(value) as StoredPluginMessage | null) ?? undefined; } @@ -90,8 +101,53 @@ export function safeParseContentBlocks(raw: string | undefined): readonly Messag } } -export const safeParseQueueCustody = parseQueuedMessageCustody; -export const safeParseQueueCustodyAdmission = parseQueueCustodyAdmissionIntent; +export function safeParseLifecycleMetadata(raw: string | undefined): LifecycleStoredMessageMetadata | undefined { + if (!raw) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const lifecycle = parsed as Record; + if (Array.isArray(lifecycle.dispatchRefs)) { + // v1 wrote routing intent as `assigned`; that was never proof of + // delivery. Drop it while preserving actual dispatched/settled refs. + lifecycle.dispatchRefs = lifecycle.dispatchRefs.flatMap((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return [value]; + const ref = value as Record; + if (ref.phase === 'assigned') return []; + if ( + (ref.phase === 'dispatched' || ref.phase === 'settled') && + typeof ref.targetId === 'string' && + typeof ref.statusMessageId === 'string' + ) { + const canonical = { ...ref }; + if (typeof canonical.dispatchedAt !== 'number' || !Number.isFinite(canonical.dispatchedAt)) { + delete canonical.dispatchedAt; + } + return [canonical]; + } + return [value]; + }); + } + } + if (!isLifecycleStoredMessageMetadata(parsed)) return undefined; + const { from: _legacyFrom, ...canonical } = parsed as LifecycleStoredMessageMetadata & { from?: unknown }; + void _legacyFrom; + return canonical as LifecycleStoredMessageMetadata; + } catch { + return undefined; + } +} + +/** Lift the pre-realignment lifecycle identity into StoredMessage.from once. */ +export function safeParseLegacyLifecycleMessageFrom(raw: string | undefined): MessageFrom | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as { from?: unknown }; + return isMessageFrom(parsed.from) ? parsed.from : undefined; + } catch { + return undefined; + } +} export function safeParseMessageRecall(raw: string | undefined): MessageRecallMarker | undefined { if (!raw) return undefined; @@ -218,16 +274,31 @@ type ExtraCarrierPersistence = ExtraCarrierPersistenceClassification<{ systemKind: 'parsed'; systemInfo: 'parsed'; a2aRouting: 'parsed'; - queueReceipt: 'derived'; - pluginMessage: 'parsed'; custodyOfferV1: 'derived'; evolutionPreparationSubmissionV1: 'parsed'; + pluginMessage: 'parsed'; + routingWarnings: 'parsed'; + cloudBridgeRetry: 'parsed'; }>; function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +function parseRealtimeCompanionCarrier(value: unknown): StoredMessageExtra['realtimeCompanion'] { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Record; + if ( + (candidate.consumer !== 'watch_video' && candidate.consumer !== 'meeting_companion') || + !isNonEmptyString(candidate.invocationId) || + candidate.invocationId.length > 128 || + !/^realtime-companion-[0-9a-f-]+$/.test(candidate.invocationId) + ) { + return undefined; + } + return { consumer: candidate.consumer, invocationId: candidate.invocationId }; +} + function isOptionalLocalReviewHead(value: unknown): boolean { return value === undefined || localReviewGitRevisionSchema.safeParse(value).success; } @@ -284,20 +355,6 @@ function parseProactiveCarrier(value: unknown): StoredMessageExtra['proactive'] return { visitId: candidate.visitId, intentId: candidate.intentId, source: 'private_time' }; } -function parseRealtimeCompanionCarrier(value: unknown): StoredMessageExtra['realtimeCompanion'] { - if (typeof value !== 'object' || value === null) return undefined; - const candidate = value as Record; - if ( - (candidate.consumer !== 'watch_video' && candidate.consumer !== 'meeting_companion') || - !isNonEmptyString(candidate.invocationId) || - candidate.invocationId.length > 128 || - !/^realtime-companion-[0-9a-f-]+$/.test(candidate.invocationId) - ) { - return undefined; - } - return { consumer: candidate.consumer, invocationId: candidate.invocationId }; -} - function parseMeetingArtifactCarrier(value: unknown): StoredMessageExtra['meetingArtifact'] { if (typeof value !== 'object' || value === null) return undefined; const candidate = value as Record; @@ -351,6 +408,7 @@ export function safeParseExtra(raw: string | undefined): StoredMessage['extra'] const result: StoredMessageExtra = {}; let hasField = false; + if (parsed.collectiveOwnerAdmissionV1 !== undefined) { const admission = collectiveOwnerAdmissionV1Schema.safeParse(parsed.collectiveOwnerAdmissionV1); if (admission.success) result.collectiveOwnerAdmissionV1 = admission.data; @@ -409,6 +467,11 @@ export function safeParseExtra(raw: string | undefined): StoredMessage['extra'] hasField = true; } + if (isCloudBridgeRetryV1(parsed.cloudBridgeRetry)) { + result.cloudBridgeRetry = parsed.cloudBridgeRetry; + hasField = true; + } + const proactive = parseProactiveCarrier(parsed.proactive); if (proactive) { result.proactive = proactive; @@ -421,6 +484,23 @@ export function safeParseExtra(raw: string | undefined): StoredMessage['extra'] hasField = true; } + if (Array.isArray(parsed.routingWarnings) && parsed.routingWarnings.length > 0) { + const routingWarnings: CatRoutingError[] = []; + let valid = true; + for (const candidate of parsed.routingWarnings as unknown[]) { + const warning = CatRoutingErrorSchema.safeParse(candidate); + if (!warning.success) { + valid = false; + break; + } + routingWarnings.push(warning.data); + } + if (valid) { + result.routingWarnings = routingWarnings; + hasField = true; + } + } + if (Array.isArray(parsed.dynamicSceneEntries)) { const scenes: AsrPersonMemoryDynamicSceneEntryV1[] = []; let valid = parsed.dynamicSceneEntries.length > 0; @@ -777,12 +857,7 @@ function parsePawFeelSourceCrossPost(value: unknown): PawFeelSourceExtra['crossP return typeof crossPost?.sourceThreadId === 'string' ? { sourceThreadId: crossPost.sourceThreadId } : undefined; } -/** - * F278 reads canonical marker sources without hydrating history-only carriers. - * The source verifier needs only stream invocation grouping and cross-post origin; - * keep this narrow parser aligned to those two consumers rather than decoding rich - * blocks or every optional extra carrier for each global inbox source. - */ +/** Decode only the provenance fields consumed by the Paw Feel source verifier. */ export function safeParsePawFeelSourceExtra(raw: string | undefined): PawFeelSourceExtra | undefined { if (!raw) return undefined; try { diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-unread-summary-projection.ts b/packages/api/src/domains/cats/services/stores/redis/redis-unread-summary-projection.ts index 3eef5e317f..6a91af3d97 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-unread-summary-projection.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-unread-summary-projection.ts @@ -8,7 +8,7 @@ import type { } from '../ports/MessageStore.js'; import { MessageKeys } from '../redis-keys/message-keys.js'; import { isDurableOwnerReadEvidence, isSystemUserMessage, passesManagedHoldViewerBoundary } from '../visibility.js'; -import { safeParseConnectorSource, safeParseExtra, safeParseQueueCustody } from './redis-message-parsers.js'; +import { safeParseConnectorSource, safeParseExtra, safeParseMessageFrom } from './redis-message-parsers.js'; type PipelineResults = Array<[Error | null, unknown]> | null; @@ -33,14 +33,14 @@ function resolveViewerBoundProjectionCursor( // A pruned canonical anchor retains its encoded monotonic position. return cursor; } - const [messageUserId, catIdRaw, source, deliveryStatus, origin, threadId, extra, queueCustody] = fields; + const [messageUserId, catIdRaw, fromRaw, source, deliveryStatus, origin, threadId, extra] = fields; const message = { userId: messageUserId ?? '', catId: (catIdRaw || null) as CatId | null, + from: safeParseMessageFrom(fromRaw), threadId: threadId ?? '', source: safeParseConnectorSource(source ?? undefined), extra: safeParseExtra(extra ?? undefined), - queueCustody: safeParseQueueCustody(queueCustody ?? undefined), ...(deliveryStatus ? { deliveryStatus } : {}), ...(origin ? { origin } : {}), } as StoredMessage; @@ -77,12 +77,12 @@ async function selectViewerBoundProjectionCursors( MessageKeys.detail(parsed.id), 'userId', 'catId', + 'from', 'source', 'deliveryStatus', 'origin', 'threadId', 'extra', - 'queueCustody', ); } if (commandIndex === 0) return selected; @@ -177,6 +177,7 @@ async function projectMessageFields( MessageKeys.detail(messageId), 'userId', 'catId', + 'from', 'source', 'deletedAt', 'mentionsUser', @@ -184,7 +185,6 @@ async function projectMessageFields( 'origin', 'threadId', 'extra', - 'queueCustody', ); } const results = (await pipeline.exec()) as PipelineResults; @@ -198,42 +198,32 @@ async function projectMessageFields( } function isProjectedUnread(fields: Array, userId: string): { unread: boolean; mentioned: boolean } { - const [ - messageUserId, - catIdRaw, - source, - deletedAt, - mentionsUser, - deliveryStatus, - origin, - threadId, - extra, - queueCustody, - ] = fields; + const [messageUserId, catIdRaw, fromRaw, source, deletedAt, mentionsUser, deliveryStatus, origin, threadId, extra] = + fields; const catId = (catIdRaw || null) as CatId | null; + const from = safeParseMessageFrom(fromRaw); const managedHoldMessage = { userId: messageUserId ?? '', catId, + from, threadId: threadId ?? '', source: safeParseConnectorSource(source ?? undefined), extra: safeParseExtra(extra ?? undefined), - queueCustody: safeParseQueueCustody(queueCustody ?? undefined), }; if (!passesManagedHoldViewerBoundary(managedHoldMessage, userId)) { return { unread: false, mentioned: false }; } - const visibleToUser = messageUserId === userId || isSystemUserMessage({ userId: messageUserId ?? '', catId }); + const visibleToUser = messageUserId === userId || isSystemUserMessage({ userId: messageUserId ?? '', catId, from }); const timelinePublished = !deliveryStatus || deliveryStatus === 'delivered' || (deliveryStatus === 'queued' && - catId !== null && - catId !== 'system' && - messageUserId !== 'system' && - messageUserId !== 'scheduler' && + (from ? from.kind === 'agent' : catId !== null && catId !== 'system') && + (!from ? messageUserId !== 'system' && messageUserId !== 'scheduler' : true) && origin !== 'briefing'); return { - unread: visibleToUser && timelinePublished && !deletedAt && (catId !== null || !!source), + unread: + visibleToUser && timelinePublished && !deletedAt && (from ? from.kind !== 'user' : catId !== null || !!source), mentioned: mentionsUser === '1', }; } diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-visibility-lua-scripts.ts b/packages/api/src/domains/cats/services/stores/redis/redis-visibility-lua-scripts.ts index 783a3aa3c1..3ca4eb3fe2 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-visibility-lua-scripts.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-visibility-lua-scripts.ts @@ -34,6 +34,9 @@ export const MAX_BACKFILL_MEMBERS = 50_000; * allocation is deferred to DELIVER_WITH_VISIBILITY_LUA. * * KEYS[1] = hash key (auto-prefixed by ioredis) + * KEYS[2] = ADR-043 Queue row hash (combined admission only) + * KEYS[3] = ADR-043 Queue order list (combined admission only) + * KEYS[4] = ADR-043 Queue message-to-row index (combined admission only) * * ARGV layout (1-indexed): * [1] keyPrefix @@ -48,6 +51,7 @@ export const MAX_BACKFILL_MEMBERS = 50_000; * [10 .. 9+N] mention catIds (N = mentionCount) * [10+N] hashFieldPairCount (string number, M pairs = 2*M values) * [11+N .. 11+N+2*M-1] hash fields as key1, val1, key2, val2, ... + * [tail] queueMaxUserSources, queueRowCount, ...serializedQueueRows * * Returns: * - string (existing msgId) → idempotency replay, concurrent winner @@ -79,6 +83,17 @@ local hsetArgs = {} for i = 0, hfPairCount * 2 - 1 do hsetArgs[i + 1] = ARGV[hfStart + i] end +local queueMaxIdx = hfStart + hfPairCount * 2 +local queueMaxUserSources = tonumber(ARGV[queueMaxIdx] or '-1') +local queueRowCount = tonumber(ARGV[queueMaxIdx + 1] or '0') +local queueRows = {} +local queueEntryIds = {} +for i = 1, queueRowCount do + local raw = ARGV[queueMaxIdx + 1 + i] + local row = cjson.decode(raw) + queueRows[i] = { id = row.id, raw = raw, row = row } + queueEntryIds[i] = row.id +end -- #1210 idempotency: if key points to a live hash, replay (return winner ID). -- If key exists but hash vanished, fall through to reclaim atomically. @@ -87,11 +102,51 @@ if idemKey then local existingId = redis.call('GET', idemKey) if existingId then if redis.call('EXISTS', kp .. 'msg:' .. existingId) == 1 then + if queueRowCount > 0 then return {2, existingId} end return existingId end end end +-- ADR-043 Queue preflight. Every rejection happens before the first message +-- or queue write so Queue-full and identity conflicts cannot create ghosts. +if queueRowCount > 0 then + if #KEYS < 4 or not KEYS[2] or not KEYS[3] or not KEYS[4] then + return redis.error_reply('QUEUE_ADMISSION_KEYS_MISSING') + end + local incomingIds = {} + local incomingUserSources = {} + for i = 1, queueRowCount do + local item = queueRows[i] + local row = item.row + if not row.id or not row.threadId or row.threadId ~= threadId or row.status ~= 'queued' or + not row.payload or row.payload.sourceRecordId ~= msgId or row.payload.messageId ~= msgId then + return redis.error_reply('QUEUE_ENQUEUE_INVALID_ROW') + end + if incomingIds[row.id] then return redis.error_reply('QUEUE_ENQUEUE_DUPLICATE_ID') end + incomingIds[row.id] = true + if redis.call('HEXISTS', KEYS[2], row.id) == 1 then return {-1, ''} end + if row.from and row.from.kind == 'user' then incomingUserSources[row.payload.sourceRecordId] = true end + end + if redis.call('HEXISTS', KEYS[4], msgId) == 1 then return {-1, ''} end + if queueMaxUserSources and queueMaxUserSources >= 0 then + local queuedUserSources = {} + local activeIds = redis.call('LRANGE', KEYS[3], 0, -1) + for i = 1, #activeIds do + local currentRaw = redis.call('HGET', KEYS[2], activeIds[i]) + if not currentRaw then return redis.error_reply('QUEUE_ORDER_ROW_MISSING') end + local row = cjson.decode(currentRaw) + if row.status == 'queued' and row.from and row.from.kind == 'user' then + queuedUserSources[row.payload.sourceRecordId] = true + end + end + for sourceId, _ in pairs(incomingUserSources) do queuedUserSources[sourceId] = true end + local queuedUserCount = 0 + for _, _ in pairs(queuedUserSources) do queuedUserCount = queuedUserCount + 1 end + if queuedUserCount > queueMaxUserSources then return {0, ''} end + end +end + -- 1. Pre-mutation guard: compute visibilitySeq and check exhaustion BEFORE -- any writes. Redis Lua error_reply does NOT rollback prior mutations, so -- all fail-closed checks must precede the first write. (#1200 P1-2 fix) @@ -158,7 +213,16 @@ if idemKey then end end --- 7. TTL management (does NOT apply to visibility index or meta) +-- 7. Queue fan-out commits at the same linearization point as Message. +for i = 1, queueRowCount do + redis.call('HSET', KEYS[2], queueRows[i].id, queueRows[i].raw) + redis.call('RPUSH', KEYS[3], queueRows[i].id) +end +if queueRowCount > 0 then + redis.call('HSET', KEYS[4], msgId, cjson.encode(queueEntryIds)) +end + +-- 8. TTL management (does NOT apply to visibility index, meta, or Queue) if ttlSec > 0 then -- EXPIRE on hash redis.call('EXPIRE', hash, ttlSec) @@ -183,6 +247,7 @@ if ttlSec > 0 then end end +if queueRowCount > 0 then return {1, tostring(seq)} end return seq `; @@ -191,7 +256,6 @@ return seq * * This is the FULL replacement DELIVER_LUA — not a fragment. Integrates: * - CAS guard (queued → delivered only) - * - F254 custody guard (non-terminal custody → no-op) * - Publication-order preservation (real-cat speech / user receipts keep authored timestamp) * - Visibility position guard: already-positioned messages (timeline-published at append) * preserve their immutable canonical position — only allocate when no position exists @@ -217,39 +281,18 @@ if status ~= 'queued' then end --- Pre-CAS fan-out admission is durable pending execution and must never be --- converted into delivered-only visibility by legacy orphan recovery. -local admission = redis.call('HGET', hash, 'queueCustodyAdmission') -if admission and admission ~= '' then - return 0 -end - --- F254: custody guard — non-terminal custody blocks legacy markDelivered -local custody = redis.call('HGET', hash, 'queueCustody') -if custody and custody ~= '' then - local custodyProjection = cjson.decode(custody) - if custodyProjection.status ~= 'terminal' then - return 0 - end -end - local userId = redis.call('HGET', hash, 'userId') local threadId = redis.call('HGET', hash, 'threadId') local timestamp = redis.call('HGET', hash, 'timestamp') local catId = redis.call('HGET', hash, 'catId') local origin = redis.call('HGET', hash, 'origin') -local source = redis.call('HGET', hash, 'source') --- Publication order: already-published real-cat speech and owner-visible --- queued user receipts keep their authored timestamp; private queued work --- enters the timeline at delivery. (Ported from original DELIVER_LUA.) +-- Publication order: already-published real-cat speech keeps its authored +-- timestamp. Undelivered owner work enters History only at actual delivery. local isRealCatSpeech = catId and catId ~= '' and catId ~= 'system' and userId ~= 'system' and userId ~= 'scheduler' and origin ~= 'briefing' -local isQueuedUserReceipt = (not catId or catId == '') and (not source or source == '') - and userId ~= 'system' and userId ~= 'scheduler' and origin ~= 'briefing' - and custody and custody ~= '' local timelineScore = deliveredAt -if isRealCatSpeech or isQueuedUserReceipt then +if isRealCatSpeech then timelineScore = timestamp end @@ -329,14 +372,18 @@ end local threadId = redis.call('HGET', hash, 'threadId') local msgId = redis.call('HGET', hash, 'id') +local lifecycleRaw = redis.call('HGET', hash, 'lifecycle') +local keepVisible = false +if lifecycleRaw and lifecycleRaw ~= '' then + local decoded, lifecycle = pcall(cjson.decode, lifecycleRaw) + keepVisible = decoded and lifecycle and lifecycle.kind == 'response' +end redis.call('HSET', hash, 'deliveryStatus', 'canceled') --- #1269 R8 P1-2: clear custody fields so restart/reconciliation cannot treat --- canceled work as still owned. Parity with CANCEL_LUA in delivery scripts. -redis.call('HDEL', hash, 'queueCustody', 'queueCustodyRevision', 'queueCustodyAdmission') --- #1200: Remove from visibility index if present (backfilled legacy queued) -if threadId and msgId then +-- Canceled response rows remain visible as the member-owned terminal surface. +-- Other queued work is withdrawn from History visibility as before. +if not keepVisible and threadId and msgId then local visKey = kp .. 'msg:visibility:' .. threadId redis.call('ZREM', visKey, msgId) end @@ -371,10 +418,77 @@ local maxMembers = tonumber(ARGV[3]) local metaKey = kp .. 'msg:visibility-meta:' .. threadId local visKey = kp .. 'msg:visibility:' .. threadId --- Guard: already migrated → no-op +-- ADR-043 follow-up: the first single-ledger build terminalized processing +-- response placeholders without publishing them to the visibility index. Run +-- this bounded, one-shot repair even for threads that already completed the +-- original #1200 migration. Repaired responses are appended after the current +-- HWM so no previously issued cursor changes meaning. local migrated = redis.call('HGET', metaKey, 'migrated') +local terminalRepair = redis.call('HGET', metaKey, 'terminalResponseRepair') +if migrated and terminalRepair then return 0 end + if migrated then - return 0 + local count = redis.call('ZCARD', threadKey) + if count > maxMembers then + return redis.error_reply( + 'VISIBILITY_REPAIR_TOO_LARGE: threadId=' .. threadId .. + ' members=' .. tostring(count) .. + ' max=' .. tostring(maxMembers) + ) + end + + local hwmRaw = redis.call('HGET', metaKey, 'hwm') + local hwm = 0 + if hwmRaw ~= false then + hwm = tonumber(hwmRaw) + if hwm == nil or hwm ~= hwm or hwm ~= math.floor(hwm) or hwm < 0 then + return redis.error_reply('VISIBILITY_HWM_INVALID: raw=' .. tostring(hwmRaw) .. ' metaKey=' .. metaKey) + end + end + + local repairIds = {} + local members = redis.call('ZRANGE', threadKey, 0, -1) + for i = 1, #members do + local messageId = members[i] + local messageKey = kp .. 'msg:' .. messageId + local existingSeq = redis.call('HGET', messageKey, 'visibilitySeq') + local lifecycleRaw = redis.call('HGET', messageKey, 'lifecycle') + local lifecycle = nil + if lifecycleRaw and lifecycleRaw ~= '' then + local okLifecycle, decodedLifecycle = pcall(cjson.decode, lifecycleRaw) + if okLifecycle and type(decodedLifecycle) == 'table' then lifecycle = decodedLifecycle end + end + local deliveryStatus = redis.call('HGET', messageKey, 'deliveryStatus') + local publishableDelivery = not deliveryStatus or deliveryStatus == '' or + deliveryStatus == 'delivered' or deliveryStatus == 'canceled' + if (not existingSeq or existingSeq == '') and + not redis.call('HGET', messageKey, 'recall') and + not redis.call('HGET', messageKey, '_tombstone') and + lifecycle and lifecycle.kind == 'response' and lifecycle.status ~= 'processing' and + publishableDelivery then + table.insert(repairIds, messageId) + end + end + + local firstSeq = nil + if #repairIds > 0 then + local timeArr = redis.call('TIME') + local nowMs = tonumber(timeArr[1]) * 1000 + math.floor(tonumber(timeArr[2]) / 1000) + firstSeq = math.max(hwm + 1, nowMs) + local lastSeq = firstSeq + #repairIds - 1 + if lastSeq > 9007199254730991 then + return redis.error_reply('VISIBILITY_SEQ_EXHAUSTED: seq=' .. tostring(lastSeq)) + end + end + + for i = 1, #repairIds do + local seq = firstSeq + i - 1 + redis.call('ZADD', visKey, seq, repairIds[i]) + redis.call('HSET', kp .. 'msg:' .. repairIds[i], 'visibilitySeq', tostring(seq)) + hwm = seq + end + redis.call('HSET', metaKey, 'hwm', tostring(hwm), 'terminalResponseRepair', '1') + return #repairIds end -- #1200 Sol R1 P2: ZCARD first to avoid materializing huge thread in Lua memory @@ -382,7 +496,7 @@ local count = redis.call('ZCARD', threadKey) -- Empty thread → mark migrated, no backfill needed if count == 0 then - redis.call('HSET', metaKey, 'migrated', '1', 'hwm', '0') + redis.call('HSET', metaKey, 'migrated', '1', 'hwm', '0', 'terminalResponseRepair', '1') return 0 end @@ -407,7 +521,7 @@ end -- Set meta atomically: migrated + hwm = last assigned seq local hwm = BASE + count - 1 -redis.call('HSET', metaKey, 'migrated', '1', 'hwm', tostring(hwm)) +redis.call('HSET', metaKey, 'migrated', '1', 'hwm', tostring(hwm), 'terminalResponseRepair', '1') -- Log-friendly return: count of backfilled members return count diff --git a/packages/api/src/domains/cats/services/stores/visibility.ts b/packages/api/src/domains/cats/services/stores/visibility.ts index d133e8fde0..eac4771c92 100644 --- a/packages/api/src/domains/cats/services/stores/visibility.ts +++ b/packages/api/src/domains/cats/services/stores/visibility.ts @@ -3,18 +3,10 @@ * Pure functions for determining whether a message is visible to a given viewer. */ -import { type CatId, isSelectableManagedHoldConnectorSource } from '@cat-cafe/shared'; +import { type CatId, isManagedHoldConnectorSource } from '@cat-cafe/shared'; +import { messageFrom } from './message-from.js'; import type { IMessageStore, StoredMessage, ThreadMessageReadOptions } from './ports/MessageStore.js'; -/** Fields required to reproduce a persisted message's timeline order without hydrating its history payload. */ -type TimelineOrderMessage = Pick< - StoredMessage, - 'userId' | 'catId' | 'timestamp' | 'deliveredAt' | 'timelineOrderAt' | 'deliveryStatus' | 'origin' -> & { - source?: unknown; - queueCustody?: unknown; -}; - /** * System-level userIds whose messages are visible to ALL thread participants * regardless of the per-user filter (scheduler, system, etc.). @@ -27,8 +19,10 @@ export const SYSTEM_USER_IDS: ReadonlySet = new Set(['scheduler', 'syste * Historical writes use `catId: 'system'`; newer display-only badges (for example * persisted ACP errors) use `catId: null`. Both must bypass per-user filtering. */ -export function isSystemUserMessage(msg: Pick): boolean { - return SYSTEM_USER_IDS.has(msg.userId) && (msg.catId === 'system' || msg.catId === null); +export function isSystemUserMessage( + msg: Pick, +): boolean { + return messageFrom(msg).kind === 'system'; } /** @@ -38,23 +32,24 @@ export function isSystemUserMessage(msg: Pick */ export function isTimelinePublished(msg: StoredMessage): boolean { if (!msg.deliveryStatus || msg.deliveryStatus === 'delivered') return true; + if (msg.deliveryStatus === 'canceled' && msg.lifecycle?.kind === 'response') return true; return msg.deliveryStatus === 'queued' && isRealCatSpeech(msg); } /** * A scheduler-authored managed-hold row is user-visible only when its durable - * Queue custody binds the exact viewer. Scheduler authorship is provenance, + * persisted owner binds the exact viewer. Scheduler authorship is provenance, * never access authority. Legacy ownerless records and hidden trigger rows * fail closed. */ type ManagedHoldConnectorVisibilityMessage = Pick< StoredMessage, - 'userId' | 'catId' | 'threadId' | 'source' | 'extra' | 'queueCustody' + 'from' | 'userId' | 'catId' | 'threadId' | 'source' | 'extra' >; /** Classify the protected scheduler namespace before evaluating publication authority. */ export function isManagedHoldConnectorMessage(msg: ManagedHoldConnectorVisibilityMessage): boolean { - return msg.userId === 'scheduler' && msg.catId === null && msg.source?.connector === 'hold-ball'; + return messageFrom(msg).kind === 'system' && msg.source?.connector === 'hold-ball'; } export function isOwnerVisibleManagedHoldConnector( @@ -66,8 +61,21 @@ export function isOwnerVisibleManagedHoldConnector( viewerUserId.length > 0 && isManagedHoldConnectorMessage(msg) && msg.extra?.scheduler?.hiddenTrigger !== true && - msg.queueCustody?.ownerUserId === viewerUserId && - isSelectableManagedHoldConnectorSource(msg.source) && + msg.userId === viewerUserId && + isManagedHoldConnectorSource(msg.source) && + msg.source?.meta?.threadId === msg.threadId + ); +} + +/** Narrow system-message exception shared by full and incremental cat context. */ +export function isAgentReadableManagedHoldMessage(msg: StoredMessage): boolean { + return ( + isDeliveredMessage(msg) && + isManagedHoldConnectorMessage(msg) && + msg.extra?.scheduler?.hiddenTrigger !== true && + typeof msg.userId === 'string' && + !SYSTEM_USER_IDS.has(msg.userId) && + isManagedHoldConnectorSource(msg.source) && msg.source?.meta?.threadId === msg.threadId ); } @@ -103,24 +111,6 @@ export function isDurableOwnerReadEvidence(msg: StoredMessage): boolean { return isTimelinePublished(msg) && !(msg.deliveryStatus === 'queued' && msg.origin === 'stream'); } -/** - * A queued user body that was already exposed to one exact child is durable - * cognition for that target cat. The append-only exposure witness survives - * child/session replacement, while other cats remain unable to read the body. - */ -export function hasDurableQueueBodyExposure(msg: StoredMessage, catId: CatId): boolean { - return ( - msg.deliveryStatus === 'queued' && - msg.catId === null && - (msg.queueCustody?.bodyExposures ?? []).some((exposure) => exposure.targetCatId === catId) - ); -} - -/** Published history plus target-scoped queued bodies the cat has already read. */ -export function isDurablyReadableByCat(msg: StoredMessage, catId: CatId): boolean { - return isTimelinePublished(msg) || hasDurableQueueBodyExposure(msg, catId); -} - /** Resolve the publication predicate for a thread read in one place. */ export function resolveThreadMessageVisibility( options?: ThreadMessageReadOptions, @@ -138,10 +128,7 @@ export function resolveThreadMessageVisibility( return ( isDeliveredMessage(message) || (options?.includeQueuedCatMessages === true && isQueuedCatTimelineMessage(message)) || - (options?.includeQueuedUserMessages === true && - (isQueuedUserTimelineMessage(message) || isQueuedOwnerConnectorTimelineMessage(message))) || - (options?.includeExposedQueuedUserMessagesForCatId !== undefined && - hasDurableQueueBodyExposure(message, options.includeExposedQueuedUserMessagesForCatId)) || + (options?.includeQueuedUserMessages === true && isQueuedOwnerWork(message)) || (options?.includeRecalledUserMessages === true && isOwnerVisibleRecalledUserMessage(message)) ); }; @@ -152,85 +139,47 @@ export function resolveThreadMessageVisibility( * published when authored, even if recipient execution custody ends later. */ export function resolveDeliveryTimelineScore(message: StoredMessage, deliveredAt: number): number { - return isTimelinePublished(message) || - isQueuedUserTimelineMessage(message) || - isQueuedOwnerConnectorTimelineMessage(message) - ? message.timestamp - : deliveredAt; + return isTimelinePublished(message) ? message.timestamp : deliveredAt; } /** Match the Redis timeline score when constructing pagination cursors in memory. */ -export function getTimelineOrderTime(message: TimelineOrderMessage): number { +export function getTimelineOrderTime(message: StoredMessage): number { if (message.timelineOrderAt !== undefined) return message.timelineOrderAt; - if ( - isQueuedCatTimelineMessage(message) || - isQueuedUserTimelineMessage(message) || - isQueuedOwnerConnectorTimelineMessage(message) - ) - return message.timestamp; + if (isQueuedCatTimelineMessage(message)) return message.timestamp; return message.deliveredAt ?? message.timestamp; } function isDeliveredMessage(message: StoredMessage): boolean { - return !message.deliveryStatus || message.deliveryStatus === 'delivered'; -} - -function isRealCatSpeech(message: TimelineOrderMessage): boolean { return ( - message.catId !== null && - message.catId !== 'system' && - message.userId !== 'system' && - message.userId !== 'scheduler' && - message.origin !== 'briefing' + !message.deliveryStatus || + message.deliveryStatus === 'delivered' || + (message.deliveryStatus === 'canceled' && message.lifecycle?.kind === 'response') ); } -function isQueuedCatTimelineMessage(message: TimelineOrderMessage): boolean { - return message.deliveryStatus === 'queued' && isRealCatSpeech(message); +function isRealCatSpeech(message: StoredMessage): boolean { + return messageFrom(message).kind === 'agent' && message.origin !== 'briefing'; } -/** - * Owner-facing timeline publication for durable queued user work. This is kept - * separate from `isTimelinePublished`: callback/context/prompt readers must not - * learn an undelivered body merely because the browser can render its receipt. - */ -function isQueuedUserTimelineMessage(message: TimelineOrderMessage): boolean { - if ( - message.deliveryStatus !== 'queued' || - message.catId !== null || - message.source !== undefined || - message.userId === 'system' || - message.userId === 'scheduler' || - message.origin === 'briefing' - ) { - return false; - } - return message.queueCustody !== undefined; +function isQueuedCatTimelineMessage(message: StoredMessage): boolean { + return message.deliveryStatus === 'queued' && isRealCatSpeech(message); } -/** - * Host-attributed connector ingress is already an owner-visible receipt at - * admission. Keep it browser-only until delivery just like queued user work: - * prompt/context readers must not learn it through `isTimelinePublished`, but - * the owner must see the source bubble before the target cat starts replying. - * System/scheduler connectors remain behind their dedicated visibility gates. - */ -function isQueuedOwnerConnectorTimelineMessage(message: TimelineOrderMessage): boolean { +/** Explicit administrative reads may inspect queued owner work. This predicate + * never publishes the row into the ordinary History timeline. */ +function isQueuedOwnerWork(message: StoredMessage): boolean { + const from = messageFrom(message); return ( message.deliveryStatus === 'queued' && - message.catId === null && - message.source !== undefined && - message.userId !== 'system' && - message.userId !== 'scheduler' && message.origin !== 'briefing' && - message.queueCustody !== undefined + (from.kind === 'user' || from.kind === 'external' || from.kind === 'plugin') ); } function isOwnerVisibleRecalledUserMessage(message: StoredMessage): boolean { return ( message.deliveryStatus === 'canceled' && - message.catId === null && + messageFrom(message).kind === 'user' && message._tombstone === true && message.recall?.exposure === 'seen' ); @@ -300,8 +249,10 @@ export function canQuoteInPublicReply(parent: StoredMessage): boolean { * POST /api/messages replyTo validation, and the get-message route — so no path can * forget the exclusion (the "fetch + gate" invariant in this file's header). */ -export function isInternalNonQuotableParent(msg: Pick): boolean { - return msg.userId === 'system' || msg.origin === 'briefing'; +export function isInternalNonQuotableParent( + msg: Pick, +): boolean { + return messageFrom(msg).kind === 'system' || msg.origin === 'briefing'; } export function isEligibleReplyParent(parent: StoredMessage, opts: ReplyParentEligibilityOptions): boolean { diff --git a/packages/api/src/domains/cats/services/types.ts b/packages/api/src/domains/cats/services/types.ts index 365f16f643..dad76035f4 100644 --- a/packages/api/src/domains/cats/services/types.ts +++ b/packages/api/src/domains/cats/services/types.ts @@ -364,6 +364,12 @@ export interface AgentMessage { /** Exact durable child start time carried only by the typed invocation-created * lifecycle event. Consumers must not recover it by parsing `content`. */ turnExecutionStartedAt?: number; + /** Durable processing response created before provider startup for this exact child. */ + lifecycleResponseMessageId?: string; + /** Raw message frontier observed before the processing response was published. */ + lifecyclePriorFrontierMessageId?: string | null; + /** Exact server-owned working identity; dynamic clients must fail closed without it. */ + activeRun?: import('@cat-cafe/shared').LifecycleActiveRun; /** Typed F167 Phase T proof that this exact child consumed a terminal * coordination wake and correctly produced no reply. */ turnCustodyTerminalWitness?: QueueTerminalConsumptionWitness; @@ -455,12 +461,15 @@ export interface AgentContextCapability { /** F296 B0: concrete provider transport identity, independent of route/origin. */ export type ProviderCarrier = - | { readonly provider: 'claude'; readonly carrier: 'print_sdk' | 'bg_daemon' | 'interactive_pty' | 'api_key' } + | { + readonly provider: 'claude'; + readonly carrier: 'print_sdk' | 'agent_sdk' | 'bg_daemon' | 'interactive_pty' | 'api_key'; + } | { readonly provider: 'codex'; readonly carrier: 'exec_json' | 'app_server' } | { readonly provider: 'gemini'; readonly carrier: 'gemini_cli' | 'antigravity_adapter' } | { readonly provider: 'antigravity'; readonly carrier: 'cdp_bridge' } | { readonly provider: 'kimi'; readonly carrier: 'stream_json' } - | { readonly provider: 'opencode'; readonly carrier: 'run_json' } + | { readonly provider: 'opencode'; readonly carrier: 'run_json' | 'server' } | { readonly provider: 'acp'; readonly carrier: 'acp'; readonly backend: 'opencode' | 'unknown' } | { readonly provider: 'catagent'; readonly carrier: 'direct_api' } | { readonly provider: 'a2a'; readonly carrier: 'remote' } @@ -816,6 +825,49 @@ export interface AgentContextBinding { readonly source: 'service_spawn' | 'invocation_config'; } +/** Provider-native identity for one exact, still-open Agent Client run. */ +export interface AgentClientActiveRunHandle { + readonly provider: 'openai_codex' | 'anthropic' | 'anthropic_acp' | 'opencode' | 'other'; + readonly carrier: 'codex_app_server' | 'claude_agent_sdk' | 'acp' | 'other'; + readonly threadId: string; + readonly turnId: string; +} + +/** Exact input that the lifecycle coordinator has already admitted durably. */ +export interface AgentClientDispatchInput { + readonly text: string; + readonly imagePaths?: readonly string[]; + readonly messageIds: readonly string[]; +} + +export interface AgentClientDispatchOptions { + /** Normal dispatch / Append = false; Steer = true. */ + readonly force: boolean; + /** Server-owned lifecycle invocation fence, never a provider session id. */ + readonly expectedInvocationId: string; +} + +export type AgentClientDispatchResult = + | { readonly accepted: true; readonly handle: AgentClientActiveRunHandle } + | { + readonly accepted: false; + readonly reason: 'active_run_mismatch' | 'active_run_closed' | 'invalid_input' | 'provider_rejected'; + }; + +/** Live-only adapter. Durable Queue/History state must never be stored here. */ +export interface AgentClientActiveRunDispatcher { + readonly invocationId: string; + readonly capabilities: { readonly append: boolean; readonly steer: boolean }; + readonly handle: AgentClientActiveRunHandle; + dispatch(input: AgentClientDispatchInput, options: AgentClientDispatchOptions): Promise; +} + +export interface AgentClientActiveRunDispatchRegistration { + readonly invocationId: string; + /** Return a release hook that removes this exact live handle from its registry. */ + register(dispatcher: AgentClientActiveRunDispatcher): (() => void) | undefined; +} + /** ADR-042 automatic supplement execution: provider + callback layers must enforce this, not prompt prose. */ export type ToolExecutionPolicy = | { @@ -838,6 +890,10 @@ export interface AgentRouteIntent { * Options for invoking an agent */ export interface AgentServiceOptions { + /** Provider-neutral interaction port bound to this invocation. */ + runtimeInteractionPort?: import('../../runtime-interaction/ports/RuntimeInteractionPort.js').RuntimeInteractionPort; + /** F310: source-bound Task relation resolved from canonical Task truth for an F306 question. */ + resolveEntrustedWorkTaskRef?: () => Promise; /** Route-owned intent. Providers may project only explicit behavior intent onto native modes. */ routeIntent?: AgentRouteIntent; /** Session ID to resume (optional) */ @@ -887,10 +943,8 @@ export interface AgentServiceOptions { agentCarrierSessionFactory?: AgentCarrierSessionFactory; /** F254 D2: per-invocation two-phase provider-native freshness controller. */ activeInvocationFreshness?: import('./freshness/FreshnessNoticeBroker.js').ActiveInvocationFreshnessController; - /** F306: provider-neutral, invocation-bound human interaction port. */ - runtimeInteractionPort?: import('../../runtime-interaction/ports/RuntimeInteractionPort.js').RuntimeInteractionPort; - /** F310: source-bound Task relation resolved from canonical Task truth for an F306 question. */ - resolveEntrustedWorkTaskRef?: () => Promise; + /** #1354: expose an exact provider-native active-turn dispatcher after turn acceptance. */ + activeRunDispatch?: AgentClientActiveRunDispatchRegistration; /** F210-H1b: Override AGY --log-file path (test seam for the trajectory progress observer). */ agyLogPathOverride?: string; /** F118: Invocation ID for diagnostic enrichment of __cliTimeout */ diff --git a/packages/api/src/domains/growing/ArtifactReviewReturnDispatcher.ts b/packages/api/src/domains/growing/ArtifactReviewReturnDispatcher.ts index 0b97719ebb..0f54ec4dc6 100644 --- a/packages/api/src/domains/growing/ArtifactReviewReturnDispatcher.ts +++ b/packages/api/src/domains/growing/ArtifactReviewReturnDispatcher.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto'; import type { PersistedQueueDeliveryPort } from '../cats/services/agents/invocation/PersistedQueueDelivery.js'; -import { projectQueueReceipt } from '../cats/services/stores/ports/queued-message-receipt.js'; import { ArtifactReviewError } from '../collaborative-content/artifact-review/errors.js'; import type { ReviewReturnIntent } from '../collaborative-content/artifact-review/return-store.js'; import type { ArtifactReviewService } from '../collaborative-content/artifact-review/service.js'; @@ -82,10 +81,7 @@ export class ArtifactReviewReturnDispatcher { mentions: message.mentions, userId: message.userId, source: message.source, - extra: { - ...message.extra, - ...(message.queueCustody ? { queueReceipt: projectQueueReceipt(message.queueCustody) } : {}), - }, + extra: message.extra, }, ], }); diff --git a/packages/api/src/domains/leaderboard/mention-stats.ts b/packages/api/src/domains/leaderboard/mention-stats.ts index 51c46562e5..31b7bc93c2 100644 --- a/packages/api/src/domains/leaderboard/mention-stats.ts +++ b/packages/api/src/domains/leaderboard/mention-stats.ts @@ -2,13 +2,14 @@ * F075 — Mention stats computation * Pure functions: input messages → output ranked stats */ -import type { MentionStats, RankedCat, StreakCat } from '@cat-cafe/shared'; +import type { MentionStats, MessageFrom, RankedCat, StreakCat } from '@cat-cafe/shared'; export interface MessageLike { id: string; mentions: readonly string[]; timestamp: number; catId: string | null; + from?: MessageFrom; content: string; source?: { connector?: string }; } @@ -106,7 +107,7 @@ export function computeMentionStats( for (const msg of messages) { // Mention-based rankings should reflect co-creator/owner mentions only. // Exclude cat-authored messages AND connector-sourced messages (catId is also null). - if (!msg.catId && !msg.source?.connector) { + if (msg.from ? msg.from.kind === 'user' : !msg.catId && !msg.source?.connector) { for (const catId of msg.mentions) { mentionCount.set(catId, (mentionCount.get(catId) ?? 0) + 1); diff --git a/packages/api/src/domains/leaderboard/silly-stats.ts b/packages/api/src/domains/leaderboard/silly-stats.ts index 9e738485de..76419a8a83 100644 --- a/packages/api/src/domains/leaderboard/silly-stats.ts +++ b/packages/api/src/domains/leaderboard/silly-stats.ts @@ -32,7 +32,7 @@ export function computeSillyStats(messages: MessageLike[], catNames: Record boolean; readonly messageStore: { append(input: { + readonly from: MessageFrom; readonly threadId: string; readonly userId: string; - readonly catId: null; readonly content: string; readonly source: ConnectorSource; readonly mentions: readonly CatId[]; readonly timestamp: number; readonly idempotencyKey: string; + readonly deliveryStatus: 'queued'; }): Promise<{ readonly id: string }> | { readonly id: string }; }; readonly invokeTriggerProvider: { @@ -31,9 +32,6 @@ export interface LimbTranscriptCatDeliveryOptions { } | undefined; }; - readonly socketManager?: { - broadcastToRoom(room: string, event: string, data: unknown): void; - }; } const STACKCHAN_SOURCE: ConnectorSource = { @@ -71,25 +69,15 @@ export class LimbTranscriptCatDelivery implements LimbTranscriptDelivery { }, }; const stored = await this.options.messageStore.append({ + from: { kind: 'external', connectorId: STACKCHAN_SOURCE.connector }, threadId: input.binding.threadId, userId: input.binding.userId, - catId: null, content: input.observation.payload.text, source, mentions: [catId], timestamp, idempotencyKey: `limb:${input.observation.nodeId}:${input.observation.observationId}`, - }); - - this.options.socketManager?.broadcastToRoom(`thread:${input.binding.threadId}`, 'connector_message', { - threadId: input.binding.threadId, - message: { - id: stored.id, - type: 'connector', - content: input.observation.payload.text, - source, - timestamp, - }, + deliveryStatus: 'queued', }); const outcome = await trigger.trigger( diff --git a/packages/api/src/domains/memory/DeferredPersonMemoryDailyTaskSpec.ts b/packages/api/src/domains/memory/DeferredPersonMemoryDailyTaskSpec.ts index 894009afd9..b8ae66a7bd 100644 --- a/packages/api/src/domains/memory/DeferredPersonMemoryDailyTaskSpec.ts +++ b/packages/api/src/domains/memory/DeferredPersonMemoryDailyTaskSpec.ts @@ -193,7 +193,7 @@ export function createDeferredPersonMemoryDailyTaskSpec( const messageId = await context.deliver({ threadId: processingThreadId, content, - userId: 'scheduler', + userId: deps.ownerUserId, extra: { scheduler: { hiddenTrigger: true }, ...(writeOpportunityReentries.length > 0 ? { writeOpportunityReentries } : {}), diff --git a/packages/api/src/domains/memory/InputEntityDetector.ts b/packages/api/src/domains/memory/InputEntityDetector.ts index 5c9378f13b..be9de11972 100644 --- a/packages/api/src/domains/memory/InputEntityDetector.ts +++ b/packages/api/src/domains/memory/InputEntityDetector.ts @@ -105,6 +105,14 @@ function compileAliasMatcher(aliasNorm: string): ((textNorm: string) => boolean) return (textNorm) => pattern.test(textNorm); } +function isRoutingRequestThreadTitle(row: DocAliasRow, aliasNorm: string): boolean { + if (!row.source_path?.startsWith('threads/')) return false; + // A thread title derived from a direct @ routing request is conversation + // control text, not a document/entity mention. Content length is deliberately + // irrelevant: long, intentionally named documents remain discoverable. + return /^@[\p{L}\p{N}_-]+(?:\s|$)/u.test(aliasNorm); +} + // ── Internal row types ────────────────────────────────────────────────── interface EntityAliasRow { @@ -307,6 +315,7 @@ export class InputEntityDetector { const aliasNorm = normalizeEntityAlias(row.alias_norm || row.alias); const matcher = compileAliasMatcher(aliasNorm); if (!matcher?.(textNorm)) continue; + if (isRoutingRequestThreadTitle(row, aliasNorm)) continue; if (contextAnchors.has(row.doc_anchor)) continue; seen.add(row.doc_anchor); diff --git a/packages/api/src/domains/memory/ProactiveMemoryCandidateDetector.ts b/packages/api/src/domains/memory/ProactiveMemoryCandidateDetector.ts index 0b7749181c..e404e6d269 100644 --- a/packages/api/src/domains/memory/ProactiveMemoryCandidateDetector.ts +++ b/packages/api/src/domains/memory/ProactiveMemoryCandidateDetector.ts @@ -1,3 +1,4 @@ +import { messageFrom } from '../cats/services/stores/message-from.js'; import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; import { isDelivered } from '../cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../cats/services/stores/ports/ThreadStore.js'; @@ -96,8 +97,7 @@ export class ProactiveMemoryCandidateDetector { private isEligibleMessageShape(message: StoredMessage, ownerUserId: string): boolean { return ( message.userId === ownerUserId && - message.catId === null && - message.source === undefined && + messageFrom(message).kind === 'user' && isDelivered(message) && !message.deletedAt && !message._tombstone && diff --git a/packages/api/src/domains/memory/ThreadAwareSignalSources.ts b/packages/api/src/domains/memory/ThreadAwareSignalSources.ts index a1e98be711..82599723b7 100644 --- a/packages/api/src/domains/memory/ThreadAwareSignalSources.ts +++ b/packages/api/src/domains/memory/ThreadAwareSignalSources.ts @@ -1,3 +1,4 @@ +import type { MessageFrom } from '@cat-cafe/shared'; import type Database from 'better-sqlite3'; import type { OutputVerifiedSignalSources } from './output-verified-detector.js'; import { SqliteSignalSources } from './SqliteSignalSources.js'; @@ -19,6 +20,7 @@ import { SqliteSignalSources } from './SqliteSignalSources.js'; /** Minimal message shape needed for signal detection (subset of StoredMessage). */ export interface SignalMessage { id: string; + from?: MessageFrom; userId?: string | null; catId?: string | null; /** Plain text content string (StoredMessage.content is a string, not parsed blocks). */ @@ -179,10 +181,8 @@ export class ThreadAwareSignalSources implements OutputVerifiedSignalSources { // Iterate newest→oldest so the latest operator decision takes precedence. for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - // Human (operator) message: catId is null/undefined/empty. Cat messages have catId set. - if (msg.catId) continue; - // Skip connector/system messages (e.g. CI notifications with "CI 通过"). - if (msg.source) continue; + const operatorAuthored = msg.from ? msg.from.kind === 'user' : !msg.catId && !msg.source; + if (!operatorAuthored) continue; const text = extractText(msg); if (!text) continue; // Negation = explicit rejection → return false (latest decision is rejection). @@ -207,10 +207,8 @@ export class ThreadAwareSignalSources implements OutputVerifiedSignalSources { // Iterate newest→oldest so the latest reviewer decision takes precedence. for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - // Cat (reviewer) message: catId is set. Human messages have catId null. - if (!msg.catId) continue; - // Skip connector/system messages — CiCdRouter sends with catId but also source. - if (msg.source) continue; + const agentAuthored = msg.from ? msg.from.kind === 'agent' : !!msg.catId && !msg.source; + if (!agentAuthored) continue; const text = extractText(msg); if (!text) continue; // Negation = explicit rejection → return false (latest decision is rejection). diff --git a/packages/api/src/domains/memory/cue/MemoryCueTrustedConnector.ts b/packages/api/src/domains/memory/cue/MemoryCueTrustedConnector.ts index d1c73a38dd..5f4fef0ec5 100644 --- a/packages/api/src/domains/memory/cue/MemoryCueTrustedConnector.ts +++ b/packages/api/src/domains/memory/cue/MemoryCueTrustedConnector.ts @@ -1,3 +1,4 @@ +import { messageFrom } from '../../cats/services/stores/message-from.js'; import type { IMessageStore } from '../../cats/services/stores/ports/MessageStore.js'; import type { MemoryCueOpportunitySeed } from './MemoryCueInvocationPromptService.js'; import { @@ -15,18 +16,20 @@ export async function readTrustedConnectorMemoryCueSeeds(input: { }): Promise { if (input.entrySource !== 'connector' || !input.messageId) return []; const stored = await Promise.resolve(input.messageStore.getById(input.messageId)); + const from = stored ? messageFrom(stored) : null; if ( !stored || stored.id !== input.messageId || stored.threadId !== input.expectedThreadId || - stored.catId !== null || stored.deletedAt !== undefined || stored._tombstone ) { return []; } if (stored.source?.connector === 'scheduler') { - if (stored.userId !== 'scheduler' || stored.extra?.scheduler?.hiddenTrigger !== true) return []; + if (stored.userId !== 'scheduler' || from?.kind !== 'system' || stored.extra?.scheduler?.hiddenTrigger !== true) { + return []; + } const seed = catOwnedSeedSeedFromTrustedCarrier( stored.extra?.memoryCue?.catOwnedSeed, stored.id, @@ -34,7 +37,14 @@ export async function readTrustedConnectorMemoryCueSeeds(input: { ); return seed ? [seed] : []; } - if (stored.source?.connector !== 'github-ci' || stored.userId !== input.expectedUserId) return []; + if ( + stored.source?.connector !== 'github-ci' || + stored.userId !== input.expectedUserId || + from?.kind !== 'external' || + from.connectorId !== 'github-ci' + ) { + return []; + } const seed = deliveryDecisionSeedFromTrustedCarrier(stored.extra?.memoryCue?.deliveryDecision, stored.id); return seed ? [seed] : []; } diff --git a/packages/api/src/domains/memory/cue/sources/PersonMemoryCueSource.ts b/packages/api/src/domains/memory/cue/sources/PersonMemoryCueSource.ts index b66dec73f9..225944671b 100644 --- a/packages/api/src/domains/memory/cue/sources/PersonMemoryCueSource.ts +++ b/packages/api/src/domains/memory/cue/sources/PersonMemoryCueSource.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { messageFrom } from '../../../cats/services/stores/message-from.js'; import type { IMessageStore } from '../../../cats/services/stores/ports/MessageStore.js'; import { isDelivered } from '../../../cats/services/stores/ports/MessageStore.js'; import type { PersonMemoryRecallService } from '../../people/PersonMemoryRecallService.js'; @@ -27,8 +28,7 @@ function sourceVisibilityFailure( if ( message.userId !== ownerUserId || message.threadId !== threadId || - message.catId !== null || - message.source !== undefined || + messageFrom(message).kind !== 'user' || message.visibility === 'whisper' || !isDelivered(message) ) { diff --git a/packages/api/src/domains/memory/event-backfill.ts b/packages/api/src/domains/memory/event-backfill.ts index fcaaba91c1..117181bb17 100644 --- a/packages/api/src/domains/memory/event-backfill.ts +++ b/packages/api/src/domains/memory/event-backfill.ts @@ -14,7 +14,7 @@ * threads + messages in batches. */ -import type { EventConfidence, EventMemoryRecord } from '@cat-cafe/shared'; +import type { EventConfidence, EventMemoryRecord, MessageFrom } from '@cat-cafe/shared'; import type { IEventMemoryStore } from './EventMemoryStore.js'; import { detectGradedMagicWords } from './magic-word-confidence.js'; @@ -26,6 +26,7 @@ export interface StoredMessageLike { timestamp: number; /** null = user (co-creator) message; a CatId = cat message. */ catId: string | null; + from?: MessageFrom; /** * Message author's userId. Backfill only marks the OWNER's own rows: getByThreadAfter * also returns globally-visible SYSTEM messages (isSystemUserMessage), which must NOT be @@ -70,7 +71,7 @@ export function extractBackfillMessage(m: StoredMessageLike): BackfillMessage { threadId: m.threadId, content: m.content, timestamp: m.timestamp, - authoredByCocreator: m.catId === null, + authoredByCocreator: m.from ? m.from.kind === 'user' : m.catId === null, targetCat: m.extra?.targetCats?.[0] ?? m.mentions?.[0] ?? null, }; } diff --git a/packages/api/src/domains/memory/people/AsrPersonMemoryReentryCarrier.ts b/packages/api/src/domains/memory/people/AsrPersonMemoryReentryCarrier.ts index 2d74457e73..02bc961742 100644 --- a/packages/api/src/domains/memory/people/AsrPersonMemoryReentryCarrier.ts +++ b/packages/api/src/domains/memory/people/AsrPersonMemoryReentryCarrier.ts @@ -4,6 +4,7 @@ import { writeOpportunityPresentationRetryCarrierV1Schema, writeOpportunityReentryCarrierV1Schema, } from '@cat-cafe/shared'; +import { messageFrom } from '../../cats/services/stores/message-from.js'; import type { IMessageStore, StoredMessage } from '../../cats/services/stores/ports/MessageStore.js'; import type { BoundAsrPersonMemoryScene } from './AsrPersonMemoryOpportunityPromptService.js'; import { eligibleOwnerMessage } from './PersonMemorySourceBundleResolver.js'; @@ -57,7 +58,7 @@ export async function bindAsrPersonMemoryReentryFromSchedulerMessage(input: { if ( carriers.length === 0 || input.triggerMessage.userId !== 'scheduler' || - input.triggerMessage.catId !== null || + messageFrom(input.triggerMessage).kind !== 'system' || input.triggerMessage.threadId !== input.threadId || input.triggerMessage.deletedAt !== undefined || input.triggerMessage._tombstone || @@ -140,8 +141,7 @@ export async function bindAsrPersonMemoryPresentationRetryFromSchedulerMessage(i ); if ( !carrier.success || - input.triggerMessage.userId !== 'scheduler' || - input.triggerMessage.catId !== null || + messageFrom(input.triggerMessage).kind !== 'system' || input.triggerMessage.threadId !== input.threadId || input.triggerMessage.deletedAt !== undefined || input.triggerMessage._tombstone || diff --git a/packages/api/src/domains/memory/people/PersonMemorySourceBundleResolver.ts b/packages/api/src/domains/memory/people/PersonMemorySourceBundleResolver.ts index 8d081737ff..3cbb8ddd73 100644 --- a/packages/api/src/domains/memory/people/PersonMemorySourceBundleResolver.ts +++ b/packages/api/src/domains/memory/people/PersonMemorySourceBundleResolver.ts @@ -7,6 +7,7 @@ import { personMemoryResolvedSourceBundleSchema, type ResolvedPersonMemorySource, } from '@cat-cafe/shared'; +import { messageFrom } from '../../cats/services/stores/message-from.js'; import type { IMessageStore, StoredMessage } from '../../cats/services/stores/ports/MessageStore.js'; import { isDelivered } from '../../cats/services/stores/ports/MessageStore.js'; import { canViewMessage } from '../../cats/services/stores/visibility.js'; @@ -78,8 +79,7 @@ export function eligibleOwnerMessage( return Boolean( message && message.userId === auth.ownerUserId && - message.catId === null && - message.source === undefined && + messageFrom(message).kind === 'user' && message.deletedAt === undefined && message._tombstone !== true && isDelivered(message) && diff --git a/packages/api/src/domains/messaging/envelope.ts b/packages/api/src/domains/messaging/envelope.ts index dab33e4525..7c89e3c62c 100644 --- a/packages/api/src/domains/messaging/envelope.ts +++ b/packages/api/src/domains/messaging/envelope.ts @@ -317,9 +317,29 @@ function audienceOf(msg: StoredMessage): CanonicalAudience { } function hostRelayedEpistemic(msg: StoredMessage): EpistemicStatus { + if (msg.from?.kind === 'user') return 'user_intent'; + if (msg.from?.kind === 'agent') return 'inference'; + if (msg.from) return 'observation'; return msg.catId === null ? 'user_intent' : 'inference'; } +function hostRelayedActor(msg: StoredMessage): MessageEnvelope['actor'] { + switch (msg.from?.kind) { + case 'user': + return { kind: 'user', id: msg.from.userId }; + case 'agent': + return { kind: 'cat', id: msg.from.catId }; + case 'external': + return { kind: 'user', id: msg.from.sender?.id ?? msg.from.connectorId }; + case 'plugin': + return { kind: 'plugin', id: msg.from.instanceId }; + case 'system': + return { kind: 'system', id: msg.from.service }; + default: + return msg.catId === null ? { kind: 'user', id: msg.userId } : { kind: 'cat', id: msg.catId }; + } +} + /** * Project a stored message to its canonical envelope. * Returns null for deleted/tombstoned messages and for malformed plugin extras. @@ -361,7 +381,7 @@ export function projectEnvelope(msg: StoredMessage): MessageEnvelope | null { return { ...base, revision: 1, - actor: msg.catId === null ? { kind: 'user', id: msg.userId } : { kind: 'cat', id: msg.catId }, + actor: hostRelayedActor(msg), payload: { provenance: { origin: { kind: 'host' }, epistemicStatus: hostRelayedEpistemic(msg) }, elements: [{ elementId: `el_${msg.id}_0`, kind: 'text', payload: { text: msg.content } }], diff --git a/packages/api/src/domains/messaging/send-service.ts b/packages/api/src/domains/messaging/send-service.ts index 81603a8218..e5b9bd6add 100644 --- a/packages/api/src/domains/messaging/send-service.ts +++ b/packages/api/src/domains/messaging/send-service.ts @@ -147,9 +147,9 @@ export class SendService { } const stored = await this.deps.messageStore.append({ + from: { kind: 'plugin', instanceId: ctx.pluginInstanceId }, threadId: handle.threadId, userId: handle.userId, - catId: null, content: renderElementsText(draft.payload.elements), mentions: [], // v0: plugin sends never trigger @-routing (wake power is K-3a scope) timestamp: Date.now(), diff --git a/packages/api/src/domains/messaging/snapshot-capture.ts b/packages/api/src/domains/messaging/snapshot-capture.ts index f83fdce6c5..8f30772409 100644 --- a/packages/api/src/domains/messaging/snapshot-capture.ts +++ b/packages/api/src/domains/messaging/snapshot-capture.ts @@ -31,7 +31,6 @@ function isSnapshotVisible(msg: StoredMessage): boolean { if (isInternalNonQuotableParent(msg as Parameters[0])) return false; if (msg.extra?.systemKind !== undefined) return false; if (msg.extra?.scheduler?.hiddenTrigger) return false; - if (msg.userId === 'scheduler') return false; return true; } diff --git a/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts b/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts index e018948da7..a88413b9e6 100644 --- a/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts +++ b/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts @@ -13,7 +13,6 @@ import { } from '@cat-cafe/shared'; import type { InvocationQueue } from '../../cats/services/agents/invocation/InvocationQueue.js'; -import { createInitialQueuedMessageCustody } from '../../cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { QueueProcessor } from '../../cats/services/agents/invocation/QueueProcessor.js'; import type { IMessageStore, StoredMessage } from '../../cats/services/stores/ports/MessageStore.js'; @@ -42,8 +41,8 @@ export interface CollectiveIngressDispatcherOptions { readonly threadStore: { get(threadId: string): CollectiveIngressThread | null | Promise; }; - readonly messageStore: Pick; - readonly invocationQueue: Pick; + readonly messageStore: IMessageStore; + readonly invocationQueue: Pick; readonly queueProcessor: Pick; readonly socketManager: { broadcastToRoom(room: string, event: string, data: unknown): void; @@ -162,7 +161,7 @@ export class CollectiveIngressDispatcher { const stored = await this.options.messageStore.appendIdempotent({ threadId, userId: route.localOwnerUserId, - catId: null, + from: collectiveMessageFrom(event), content: event.body, source, mentions: [], @@ -181,45 +180,40 @@ export class CollectiveIngressDispatcher { catId: string, ): Promise { const idempotencyKey = ingressIdempotencyKey(event); - const enqueue = this.options.invocationQueue.enqueue({ - threadId, - userId: route.localOwnerUserId, - ownerAuthProvenance: 'unknown', - executionScope: 'collective-participation', - idempotencyKey, - content: event.body, - source: 'connector', - targetCats: [catId], - intent: 'execute', - senderMeta: collectiveSender(event), - suggestedSkill: 'collective-participation', - }); - if (enqueue.outcome === 'full' || !enqueue.entry) { - throw ingressError('ROUTE_QUEUE_FULL', 'Configured Cat queue is full'); - } - try { - const source = collectiveSource(event); - const stored = await this.options.messageStore.appendIdempotent({ + const source = collectiveSource(event); + const from = collectiveMessageFrom(event); + const stored = await this.options.invocationQueue.appendAndEnqueueDurable( + this.options.messageStore, + { threadId, userId: route.localOwnerUserId, - catId: null, + from, content: event.body, source, mentions: [catId as CatId], timestamp: Date.parse(event.acceptedAt), idempotencyKey, deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(enqueue.entry), extra: { targetCats: [catId] }, - }); - this.options.invocationQueue.backfillMessageId( + }, + { threadId, - route.localOwnerUserId, - enqueue.entry.id, - stored.message.id, - ); + userId: route.localOwnerUserId, + sourceId: idempotencyKey, + kind: 'conversation_input', + from, + ownerAuthProvenance: 'unknown', + idempotencyKey, + content: event.body, + targetCats: [catId], + intent: 'execute', + suggestedSkill: 'collective-participation', + }, + ); + if (stored.outcome === 'full') throw ingressError('ROUTE_QUEUE_FULL', 'Configured Cat queue is full'); + { if (event.workRequest === 'entrust') await this.options.admitStandingWork?.(stored.message, catId as CatId); - if (!stored.idempotent) { + if (!stored.deduped) { this.options.socketManager.emitToUser?.(route.localOwnerUserId, 'messages_queued', { threadId, messageIds: [stored.message.id], @@ -232,11 +226,6 @@ export class CollectiveIngressDispatcher { // Durable Queue custody owns execution after admission. } return { kind: 'thread_message', threadId, messageId: stored.message.id, catId }; - } catch (error) { - if (!enqueue.deduped) { - this.options.invocationQueue.rollbackEnqueue(threadId, route.localOwnerUserId, enqueue.entry.id); - } - throw error; } } @@ -286,6 +275,11 @@ function collectiveSource(event: CollectiveEventEnvelope): ConnectorSource { }; } +function collectiveMessageFrom(event: CollectiveEventEnvelope) { + const sender = collectiveSender(event); + return { kind: 'external' as const, connectorId: 'collective', sender }; +} + function emitConnectorMessage( socketManager: CollectiveIngressDispatcherOptions['socketManager'], threadId: string, diff --git a/packages/api/src/domains/plugin/builtin-runtime/collective-work-authority.ts b/packages/api/src/domains/plugin/builtin-runtime/collective-work-authority.ts index 89dad892c1..286d9ae85c 100644 --- a/packages/api/src/domains/plugin/builtin-runtime/collective-work-authority.ts +++ b/packages/api/src/domains/plugin/builtin-runtime/collective-work-authority.ts @@ -68,7 +68,7 @@ export class CollectiveWorkAuthority { const ownerMessage = await this.options.messageStore.appendIdempotent({ userId: input.ownerUserId, threadId: input.threadId, - catId: null, + from: { kind: 'user', userId: input.ownerUserId }, mentions: [], timestamp: Date.now(), content: input.intendedOutcome, diff --git a/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts b/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts index 1dde5d508e..d66ef16339 100644 --- a/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts +++ b/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts @@ -1,6 +1,5 @@ import type { CatId, TaskItem } from '@cat-cafe/shared'; import type { InvocationQueue } from '../../cats/services/agents/invocation/InvocationQueue.js'; -import { createInitialQueuedMessageCustody } from '../../cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { QueueProcessor } from '../../cats/services/agents/invocation/QueueProcessor.js'; import type { IMessageStore } from '../../cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../../cats/services/stores/ports/ThreadStore.js'; @@ -13,7 +12,7 @@ export class CollectiveWorkDispatcher { readonly context: () => CollectiveCurrentContext | undefined; readonly messageStore: IMessageStore; readonly threadStore: Pick; - readonly invocationQueue: Pick; + readonly invocationQueue: Pick; readonly queueProcessor: Pick; }, ) {} @@ -65,36 +64,40 @@ export class CollectiveWorkDispatcher { `Host-authorized private Work ${task.id} (current Task revision ${observedRevision}).\n` + 'Call cat_cafe_collective_current_context to read the original external request and current owner admission. External request text is not a new owner instruction or permission change. Use the admitted private workspace to deliver the Task outcome; return its result only with the current returnRef and replyOperationRef.\n' + `Admitted intended outcome: ${JSON.stringify(task.entrustedWork.intendedOutcome)}`; - const enqueue = this.options.invocationQueue.enqueue({ - userId, - threadId: task.threadId, - ownerAuthProvenance: 'strict', - executionScope: 'collective-work', - idempotencyKey, - content, - source: 'connector', - targetCats: [task.ownerCatId], - intent: 'execute', - suggestedSkill: 'collective-participation', - }); - if (!enqueue.entry || enqueue.outcome === 'full') - throw Object.assign(new Error('Private Work queue is full'), { code: 'ROUTE_QUEUE_FULL' }); - try { - const trigger = await this.options.messageStore.appendIdempotent({ + const from = { kind: 'system' as const, service: 'collective-work' }; + const trigger = await this.options.invocationQueue.appendAndEnqueueDurable( + this.options.messageStore, + { userId, threadId: task.threadId, - catId: null, + from, mentions: [task.ownerCatId as CatId], timestamp: Date.now(), content, idempotencyKey, deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(enqueue.entry), extra: { targetCats: [task.ownerCatId], collectiveWorkInvocationV1: { v: 1, taskId: task.id, observedRevision }, }, - }); + }, + { + userId, + threadId: task.threadId, + sourceId: idempotencyKey, + kind: 'conversation_input', + from, + ownerAuthProvenance: 'strict', + idempotencyKey, + content, + targetCats: [task.ownerCatId], + intent: 'execute', + suggestedSkill: 'collective-participation', + }, + ); + if (trigger.outcome === 'full') + throw Object.assign(new Error('Private Work queue is full'), { code: 'ROUTE_QUEUE_FULL' }); + { const authority = await this.options.context()?.resolvePrivate( { userId, @@ -109,8 +112,7 @@ export class CollectiveWorkDispatcher { throw Object.assign(new Error('Current owner admission is unavailable'), { code: 'OWNER_ADMISSION_UNAVAILABLE', }); - if (trigger.idempotent) { - if (!enqueue.deduped) this.options.invocationQueue.rollbackEnqueue(task.threadId, userId, enqueue.entry.id); + if (trigger.deduped) { return { taskRef: `task:work:${task.id}`, revision: observedRevision, @@ -118,8 +120,7 @@ export class CollectiveWorkDispatcher { disposition: trigger.message.deliveryStatus ?? 'delivered', }; } - this.options.invocationQueue.backfillMessageId(task.threadId, userId, enqueue.entry.id, trigger.message.id); - // A crash after persistence is recovered by Queue custody, with executionScope intact. + // A crash after persistence is recovered by the canonical Queue ledger. void this.options.queueProcessor.processNext(task.threadId, userId).catch(() => {}); return { taskRef: `task:work:${task.id}`, @@ -127,9 +128,6 @@ export class CollectiveWorkDispatcher { messageId: trigger.message.id, disposition: 'queued' as const, }; - } catch (error) { - if (!enqueue.deduped) this.options.invocationQueue.rollbackEnqueue(task.threadId, userId, enqueue.entry.id); - throw error; } } } diff --git a/packages/api/src/domains/prompt-hooks/resolvers/turn-resolvers-b.ts b/packages/api/src/domains/prompt-hooks/resolvers/turn-resolvers-b.ts index b764e25c98..56c7530b91 100644 --- a/packages/api/src/domains/prompt-hooks/resolvers/turn-resolvers-b.ts +++ b/packages/api/src/domains/prompt-hooks/resolvers/turn-resolvers-b.ts @@ -30,7 +30,7 @@ export class D11Resolver implements HookResolver { } // --------------------------------------------------------------------------- -// D12 — 活跃参与者 (Active Participant) +// D12 — 最近发言者 (Recent Speaker; not execution state) // --------------------------------------------------------------------------- export class D12Resolver implements HookResolver { @@ -38,11 +38,13 @@ export class D12Resolver implements HookResolver { if (input.activeParticipants.length === 0) { return skip('no_active_participants', 'No active participants'); } - const topActive = input.activeParticipants.filter((p) => p.catId !== input.catId).find((p) => p.lastMessageAt > 0); - if (!topActive) { - return skip('no_qualifying_participant', 'No qualifying active participant (other cat with messages)'); + const recentSpeaker = input.activeParticipants + .filter((participant) => participant.catId !== input.catId) + .find((participant) => participant.lastMessageAt > 0); + if (!recentSpeaker) { + return skip('no_recent_speaker', 'No qualifying recent speaker (other cat with messages)'); } - return { status: 'fired', vars: { ACTIVE_LABEL: topActive.label } }; + return { status: 'fired', vars: { RECENT_SPEAKER_LABEL: recentSpeaker.label } }; } } diff --git a/packages/api/src/domains/routing-context/RoutingDispatchPreflightPort.ts b/packages/api/src/domains/routing-context/RoutingDispatchPreflightPort.ts index 14a3862357..c37169d903 100644 --- a/packages/api/src/domains/routing-context/RoutingDispatchPreflightPort.ts +++ b/packages/api/src/domains/routing-context/RoutingDispatchPreflightPort.ts @@ -123,3 +123,10 @@ export function routingDispatchPreflightReceipt(decision: RoutingPreflightDecisi target, }; } + +/** Only a rejection changes the requested delivery and belongs in chat. */ +export function isUserVisibleRoutingPreflightReceipt( + receipt: ReturnType, +): boolean { + return receipt.target.disposition === 'rejected'; +} diff --git a/packages/api/src/domains/runtime-interaction/RuntimeInteractionCardPublisher.ts b/packages/api/src/domains/runtime-interaction/RuntimeInteractionCardPublisher.ts index 785fd05839..af7844c744 100644 --- a/packages/api/src/domains/runtime-interaction/RuntimeInteractionCardPublisher.ts +++ b/packages/api/src/domains/runtime-interaction/RuntimeInteractionCardPublisher.ts @@ -25,8 +25,8 @@ export class MessageRuntimeInteractionCardPublisher implements RuntimeInteractio if (!stored) { try { stored = await this.deps.messageStore.append({ + from: { kind: 'agent', catId: request.owner.catId as CatId }, userId: request.owner.userId, - catId: request.owner.catId as CatId, content: cardContent(request), mentions: [], timestamp: request.createdAt, diff --git a/packages/api/src/domains/signal-intake/AsrPersonMemoryQueueCarrier.ts b/packages/api/src/domains/signal-intake/AsrPersonMemoryQueueCarrier.ts index 25e80e424d..20943398f8 100644 --- a/packages/api/src/domains/signal-intake/AsrPersonMemoryQueueCarrier.ts +++ b/packages/api/src/domains/signal-intake/AsrPersonMemoryQueueCarrier.ts @@ -1,3 +1,4 @@ +import { messageFrom } from '../cats/services/stores/message-from.js'; import type { StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; import type { BoundAsrPersonMemoryScene } from '../memory/people/AsrPersonMemoryOpportunityPromptService.js'; @@ -12,7 +13,7 @@ export function bindAsrPersonMemoryScenesFromQueueMessage( if ( message.userId !== scope.ownerUserId || message.threadId !== scope.threadId || - message.catId !== null || + messageFrom(message).kind !== 'user' || message.deletedAt !== undefined || message._tombstone || message.extra?.meetingArtifact?.trust !== 'untrusted_external' || diff --git a/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts b/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts index bfba4a3190..2daeaf0496 100644 --- a/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts +++ b/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts @@ -7,10 +7,9 @@ import { writeOpportunityPresentationRetryCarrierV1Schema, } from '@cat-cafe/shared'; import type { InvocationQueue } from '../cats/services/agents/invocation/InvocationQueue.js'; -import { createInitialQueuedMessageCustody } from '../cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { QueueProcessor } from '../cats/services/agents/invocation/QueueProcessor.js'; +import { messageFrom } from '../cats/services/stores/message-from.js'; import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; -import { projectQueueReceipt } from '../cats/services/stores/ports/queued-message-receipt.js'; import { buildAsrPersonMemoryDynamicScenes } from './AsrPersonMemorySceneBuilder.js'; import { createMeetingArtifactDescriptor } from './MeetingArtifactResourceService.js'; import type { MeetingArtifactDispatcher, MeetingPresentationRetryReceipt } from './MeetingIntakeActionService.js'; @@ -20,8 +19,8 @@ import { parsePrivateThreadHandle } from './ThreadDestinationAuthority.js'; export interface ThreadMeetingArtifactDispatcherOptions { readonly threadStore: MeetingThreadStore; - readonly messageStore: Pick; - readonly invocationQueue: Pick; + readonly messageStore: IMessageStore; + readonly invocationQueue: Pick; readonly queueProcessor: Pick; readonly socketManager: { emitToUser(userId: string, event: string, data: unknown): void; @@ -201,117 +200,54 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche now: queuedAt, }); const idempotencyKey = meetingArtifactCarrierIdempotencyKey(input.intake.intakeId, input.artifact.sourceRevision); - const enqueue = this.options.invocationQueue.enqueue({ + const from = { kind: 'user' as const, userId: input.intake.ownerId }; + const queueInput = { + from, threadId: input.threadId, userId: input.intake.ownerId, - ownerAuthProvenance: 'strict', + kind: 'conversation_input' as const, + ownerAuthProvenance: 'strict' as const, idempotencyKey, content: input.content, - source: 'connector', targetCats: [catId], intent: 'execute', - }); - if (enqueue.outcome === 'full' || !enqueue.entry) { - throw Object.assign(new Error('meeting destination queue is full'), { code: 'ROUTE_UNAVAILABLE' }); - } - let sourceMessageId = enqueue.entry.messageId ?? null; - let sourceMessage: Awaited> | null = null; - if (!enqueue.deduped || !enqueue.entry.messageId) { - try { - const stored = await this.options.messageStore.append({ - userId: input.intake.ownerId, - catId: null, - content: input.content, - mentions: [catId], - timestamp: queuedAt, - threadId: input.threadId, - idempotencyKey, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(enqueue.entry), - source: { - ...input.source, - meta: { sourceRevision: input.artifact.sourceRevision }, - }, - extra: { - targetCats: [catId], - meetingArtifact: { - intakeId: input.intake.intakeId, - sourceHandle: input.artifact.sourceHandle, - resourceRef: input.artifact.resourceRef, - sourceRevision: input.artifact.sourceRevision, - byteLength: input.artifact.byteLength, - contentType: input.artifact.contentType, - trust: input.artifact.trust, - instructionPolicy: input.artifact.instructionPolicy, - }, - dynamicSceneEntries, - }, - }); - sourceMessage = stored; - sourceMessageId = stored.id; - this.options.invocationQueue.backfillMessageId( - input.threadId, - input.intake.ownerId, - enqueue.entry.id, - stored.id, - ); - } catch (error) { - this.options.invocationQueue.rollbackEnqueue(input.threadId, input.intake.ownerId, enqueue.entry.id); - throw error; - } - } - if (!sourceMessageId) { - throw Object.assign(new Error('meeting destination source receipt is unavailable'), { - code: 'ROUTE_UNAVAILABLE', - }); - } - sourceMessage ??= await this.options.messageStore.getByIdempotencyKey( - input.intake.ownerId, - input.threadId, - idempotencyKey, - ); - if ( - !sourceMessage || - sourceMessage.id !== sourceMessageId || - sourceMessage.userId !== input.intake.ownerId || - sourceMessage.threadId !== input.threadId || - sourceMessage.catId !== null || - (sourceMessage.deliveryStatus !== 'queued' && sourceMessage.deliveryStatus !== 'delivered') || - !sourceMessage.source - ) { - throw Object.assign(new Error('meeting destination source receipt failed admission publication'), { - code: 'ROUTE_UNAVAILABLE', - }); - } - if (sourceMessage.deliveryStatus === 'queued') { - const queueReceipt = sourceMessage.queueCustody ? projectQueueReceipt(sourceMessage.queueCustody) : undefined; - // Admission publication is synchronous and happens before processNext can emit - // spawn_started. The source remains queued (and therefore excluded from prompt - // context); this event only installs its durable owner-visible timeline anchor. - this.options.socketManager.emitToUser(input.intake.ownerId, 'messages_queued', { + }; + const enqueue = await this.options.invocationQueue.appendAndEnqueueDurable( + this.options.messageStore, + { + from, + userId: input.intake.ownerId, + content: input.content, + mentions: [catId], + timestamp: queuedAt, threadId: input.threadId, - messageIds: [sourceMessage.id], - messages: [ - { - id: sourceMessage.id, - content: sourceMessage.content, - catId: sourceMessage.catId, - timestamp: sourceMessage.timestamp, - mentions: sourceMessage.mentions, - userId: sourceMessage.userId, - source: sourceMessage.source, - ...(sourceMessage.contentBlocks ? { contentBlocks: sourceMessage.contentBlocks } : {}), - extra: { - ...(sourceMessage.extra ?? {}), - ...(queueReceipt ? { queueReceipt } : {}), - }, - ...(sourceMessage.origin ? { origin: sourceMessage.origin } : {}), - ...(sourceMessage.replyTo ? { replyTo: sourceMessage.replyTo } : {}), - ...(sourceMessage.mentionsUser ? { mentionsUser: true } : {}), + idempotencyKey, + deliveryStatus: 'queued', + source: { + ...input.source, + meta: { sourceRevision: input.artifact.sourceRevision }, + }, + extra: { + targetCats: [catId], + meetingArtifact: { + intakeId: input.intake.intakeId, + sourceHandle: input.artifact.sourceHandle, + resourceRef: input.artifact.resourceRef, + sourceRevision: input.artifact.sourceRevision, + byteLength: input.artifact.byteLength, + contentType: input.artifact.contentType, + trust: input.artifact.trust, + instructionPolicy: input.artifact.instructionPolicy, }, - ], - }); + dynamicSceneEntries, + }, + }, + queueInput, + ); + if (enqueue.outcome === 'full' || !enqueue.entry) { + throw Object.assign(new Error('meeting destination queue is full'), { code: 'ROUTE_UNAVAILABLE' }); } + const sourceMessageId = enqueue.message.id; let started = false; try { started = (await this.options.queueProcessor.processNext(input.threadId, input.intake.ownerId)).started; @@ -353,7 +289,7 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche if ( !source || source.userId !== input.intake.ownerId || - source.catId !== null || + messageFrom(source).kind !== 'user' || source.threadId !== threadId || source.deletedAt !== undefined || source._tombstone || @@ -399,15 +335,19 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche } const idempotencyKey = `meeting-opportunity-presentation-retry:${input.intake.intakeId}:${input.clientRequestId}`; - const existing = await this.options.messageStore.getByIdempotencyKey('scheduler', threadId, idempotencyKey); + const existing = await this.options.messageStore.getByIdempotencyKey( + input.intake.ownerId, + threadId, + idempotencyKey, + ); if (existing) { const carrier = writeOpportunityPresentationRetryCarrierV1Schema.safeParse( existing.extra?.writeOpportunityPresentationRetry, ); if ( !carrier.success || - existing.userId !== 'scheduler' || - existing.catId !== null || + existing.userId !== input.intake.ownerId || + messageFrom(existing).kind !== 'system' || existing.threadId !== threadId || existing.deletedAt !== undefined || existing._tombstone || @@ -437,52 +377,48 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche code: 'ROUTE_UNAVAILABLE', }); } - const enqueue = this.options.invocationQueue.enqueue({ + const from = { kind: 'system' as const, service: 'meeting-write-opportunity' }; + const queueInput = { + from, threadId, userId: input.intake.ownerId, - ownerAuthProvenance: 'strict', + kind: 'conversation_input' as const, + ownerAuthProvenance: 'strict' as const, idempotencyKey, content, - source: 'connector', targetCats: [catId], intent: 'execute', - sourceCategory: 'scheduled', - }); + sourceCategory: 'scheduled' as const, + }; + const enqueue = await this.options.invocationQueue.appendAndEnqueueDurable( + this.options.messageStore, + { + from, + userId: input.intake.ownerId, + content, + mentions: [catId], + timestamp: queuedAt, + threadId, + idempotencyKey, + deliveryStatus: 'queued', + source: { connector: 'scheduler', label: '定时任务', icon: 'scheduler' }, + extra: { + targetCats: [catId], + scheduler: { hiddenTrigger: true }, + writeOpportunityPresentationRetry: { + v: 1, + sourceMessageRef: { kind: 'message', threadId, messageId: source.id }, + sourceOpportunityId: scene.data.opportunity.opportunityId, + }, + }, + }, + queueInput, + ); if (enqueue.outcome === 'full' || !enqueue.entry) { throw Object.assign(new Error('meeting destination queue is full'), { code: 'ROUTE_UNAVAILABLE' }); } - let triggerMessageId = enqueue.entry.messageId; - if (!enqueue.deduped || !triggerMessageId) { - try { - const stored = await this.options.messageStore.append({ - userId: 'scheduler', - catId: null, - content, - mentions: [catId], - timestamp: queuedAt, - threadId, - idempotencyKey, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(enqueue.entry), - source: { connector: 'scheduler', label: '定时任务', icon: 'scheduler' }, - extra: { - targetCats: [catId], - scheduler: { hiddenTrigger: true }, - writeOpportunityPresentationRetry: { - v: 1, - sourceMessageRef: { kind: 'message', threadId, messageId: source.id }, - sourceOpportunityId: scene.data.opportunity.opportunityId, - }, - }, - }); - triggerMessageId = stored.id; - this.options.invocationQueue.backfillMessageId(threadId, input.intake.ownerId, enqueue.entry.id, stored.id); - } catch (error) { - this.options.invocationQueue.rollbackEnqueue(threadId, input.intake.ownerId, enqueue.entry.id); - throw error; - } - } + const triggerMessageId = enqueue.message?.id; if (!triggerMessageId) { throw Object.assign(new Error('meeting presentation retry message was not persisted'), { code: 'ROUTE_UNAVAILABLE', diff --git a/packages/api/src/domains/signals/services/podcast-generator.ts b/packages/api/src/domains/signals/services/podcast-generator.ts index 6e415cc41f..81584990d5 100644 --- a/packages/api/src/domains/signals/services/podcast-generator.ts +++ b/packages/api/src/domains/signals/services/podcast-generator.ts @@ -1,19 +1,10 @@ +import { randomUUID } from 'node:crypto'; import { unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import type { CatId, StudyArtifact } from '@cat-cafe/shared'; +import type { StudyArtifact } from '@cat-cafe/shared'; import { createModuleLogger } from '../../../infrastructure/logger.js'; -import { - isTerminalDispositionEvent, - PerCatTerminalDispositionCollector, -} from '../../cats/services/agents/invocation/PerCatTerminalDispositionCollector.js'; +import type { InvocationQueue } from '../../cats/services/agents/invocation/InvocationQueue.js'; import type { QueueProcessor } from '../../cats/services/agents/invocation/QueueProcessor.js'; -import { requireInvocationRecordUpdate } from '../../cats/services/agents/invocation/require-invocation-record-update.js'; -import { ClaudeAgentService } from '../../cats/services/agents/providers/ClaudeAgentService.js'; -import type { AgentRouter } from '../../cats/services/agents/routing/AgentRouter.js'; -import { createA2ASlotTrackingBridge } from '../../cats/services/agents/routing/route-helpers.js'; -import type { InvocationTracker } from '../../cats/services/index.js'; -import type { AnyMessageStore } from '../../cats/services/stores/factories/MessageStoreFactory.js'; -import type { IInvocationRecordStore } from '../../cats/services/stores/ports/InvocationRecordStore.js'; import { getVoiceBlockSynthesizer } from '../../cats/services/tts/VoiceBlockSynthesizer.js'; import { StudyMetaService } from './study-meta-service.js'; @@ -32,16 +23,13 @@ export interface PodcastScript { readonly totalDuration: number; } -/** Dependencies for invoking a cat via the existing message pipeline. */ +/** Queue-only dependency boundary for one private system input. */ export interface ThreadInvokeDeps { - readonly messageStore: AnyMessageStore; - readonly router: AgentRouter; - readonly invocationRecordStore: IInvocationRecordStore; - readonly invocationTracker: InvocationTracker; - readonly queueProcessor?: Pick & { - enqueueRaw?: QueueProcessor['enqueueRaw']; - onInvocationComplete?: QueueProcessor['onInvocationComplete']; - }; + readonly invocationQueue: InvocationQueue; + readonly queueProcessor: Pick< + QueueProcessor, + 'registerEntryCompleteHook' | 'unregisterEntryCompleteHook' | 'requestDrain' + >; } export interface PodcastRequest { @@ -53,8 +41,8 @@ export interface PodcastRequest { readonly requestedBy: string; readonly threadContext?: string | undefined; /** AC-P6-1/P6-2: Thread-based generation (reuses existing study thread). */ - readonly threadId?: string; - readonly threadDeps?: ThreadInvokeDeps; + readonly threadId: string; + readonly threadDeps: ThreadInvokeDeps; } /** @@ -141,66 +129,10 @@ function parseScriptResponse(raw: string, mode: PodcastRequest['mode']): Podcast }; } -async function prepareThreadPodcastInvocation( - request: PodcastRequest, - threadId: string, - deps: ThreadInvokeDeps, - prompt: string, - targetCats: CatId[], -) { - const admission = await deps.invocationTracker.acquireExecutionAdmission(threadId, targetCats); - if (!admission) { - throw new Error('Podcast invocation admission rejected: thread is being deleted'); - } - - const primaryCat = targetCats[0] ?? 'opus'; - try { - // Write the user message only after any in-process seal activates the replacement - // session. The admission lease also keeps a new seal/delete out until tracker - // ownership is published below. - const userMsg = await deps.messageStore.append({ - threadId, - catId: null, - content: prompt, - userId: request.requestedBy, - mentions: ['opus' as CatId], - timestamp: Date.now(), - }); - - const createResult = await deps.invocationRecordStore.create({ - threadId, - userId: request.requestedBy, - targetCats, - intent: 'execute', - idempotencyKey: `podcast-${request.articleId}-${Date.now()}`, - actionLeaseCarrier: { kind: 'none' }, - }); - - // Backfill userMessageId so retry endpoint can find the trigger message. - await deps.invocationRecordStore.update(createResult.invocationId, { - userMessageId: userMsg.id, - }); - - const controller = deps.invocationTracker.start( - threadId, - primaryCat, - request.requestedBy, - targetCats, - createResult.invocationId, - ); - if (controller.signal.aborted) { - await deps.invocationRecordStore.update(createResult.invocationId, { status: 'canceled' }); - throw new Error('Podcast invocation admission was lost before tracker publication'); - } - return { userMsg, createResult, controller, primaryCat }; - } finally { - admission.release(); - } -} - /** - * AC-P6: Generate script by posting a prompt into the study thread. - * Reuses the existing message pipeline (same as GitHub/connector triggers). + * AC-P6 / RFC #1356: Generate through the sole Queue admission path. + * The prompt is a private system input: it has execution custody but no History + * source record and no user-visible Queue row. */ export async function generateScriptViaThread( request: PodcastRequest, @@ -208,112 +140,43 @@ export async function generateScriptViaThread( deps: ThreadInvokeDeps, ): Promise { const prompt = buildScriptPrompt(request); - const targetCats: CatId[] = ['opus' as CatId]; - const { userMsg, createResult, controller, primaryCat } = await prepareThreadPodcastInvocation( - request, + const enqueue = await deps.invocationQueue.enqueueDurable({ + from: { kind: 'system', service: 'podcast-generator' }, threadId, - deps, - prompt, - targetCats, - ); - - // ④ Route execution and collect text response - const intent = { intent: 'execute' as const, explicit: false, promptTags: [] as string[] }; - let fullText = ''; - let finalStatus: 'succeeded' | 'failed' = 'failed'; - const terminalDispositions = new PerCatTerminalDispositionCollector({ - targetCatIds: targetCats, - isCanceled: (catId) => deps.invocationTracker.getSlotState?.(threadId, catId) === 'canceled', + userId: request.requestedBy, + kind: 'private_input', + ownerAuthProvenance: 'strict', + content: prompt, + targetCats: ['opus'], + intent: 'execute', + autoExecute: true, + priority: 'normal', + idempotencyKey: `podcast:${request.articleId}:${request.mode}:${randomUUID()}`, }); - - try { - await deps.invocationRecordStore.update(createResult.invocationId, { status: 'running' }); - const enqueueA2A = deps.queueProcessor?.enqueueRaw?.bind(deps.queueProcessor); - - for await (const msg of deps.router.routeExecution( - request.requestedBy, - prompt, - threadId, - userMsg.id, - targetCats, - intent, - { - ownerAuthProvenance: 'unknown', - humanDispositionInvocationOrigin: 'system', - signal: controller.signal, - ...createA2ASlotTrackingBridge(deps.invocationTracker, controller, createResult.invocationId), - ...(enqueueA2A - ? { deferA2AEnqueue: (entry: Parameters[0]) => enqueueA2A(entry) } - : {}), - parentInvocationId: createResult.invocationId, - onPromptMessagesExposed: (input) => deps.queueProcessor?.markPromptMessagesSeen(input) ?? Promise.resolve(), - // F222 P1: System-internal podcast generation is not user-origin - frustrationAutoIssueEligible: false, - // #949 P2-1: No ball-pass expectation in system-internal podcast generation - verdictPassWarningEnabled: false, - }, - )) { - terminalDispositions.observe(msg); - if (isTerminalDispositionEvent(msg) && msg.catId) { - deps.invocationTracker.completeSlot?.(threadId, msg.catId, controller); - } - if (msg.type === 'text' && msg.content) { - fullText += msg.content; - } - } - - await requireInvocationRecordUpdate({ - store: deps.invocationRecordStore, - invocationId: createResult.invocationId, - update: { - status: 'succeeded', - successfulCatIds: terminalDispositions.getSuccessfulCatIds() as CatId[], - }, - writer: 'podcast generator', - }); - finalStatus = 'succeeded'; - } catch (err) { - await deps.invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - error: err instanceof Error ? err.message : String(err), - }); - throw err; - } finally { - deps.invocationTracker.complete(threadId, primaryCat, controller); - await deps.queueProcessor - ?.onInvocationComplete?.( - threadId, - primaryCat, - finalStatus, - createResult.invocationId, - finalStatus === 'succeeded' ? terminalDispositions.getSuccessfulCatIds() : [], - false, - terminalDispositions.getTerminalInvocationIdByCatId(), - [], - terminalDispositions.getTerminalConsumptionByInvocationId(), - ) - .catch((error) => log.warn({ error, threadId }, 'Podcast Queue completion failed')); + if (enqueue.outcome !== 'enqueued' || !enqueue.entry) { + throw new Error('Podcast Queue admission failed'); } - return parseScriptResponse(fullText, request.mode); -} - -async function generateScriptViaLLM(request: PodcastRequest): Promise { - const agent = new ClaudeAgentService({ - model: 'claude-opus-4-5-20250514', - mcpServerPath: '', // No MCP needed for script generation + const entryId = enqueue.entry.id; + const completion = new Promise((resolve, reject) => { + deps.queueProcessor.registerEntryCompleteHook(entryId, (_completedEntryId, status, responseText) => { + if (status === 'succeeded') resolve(responseText); + else reject(new Error(`Podcast Queue execution ${status}`)); + }); }); - const prompt = buildScriptPrompt(request); - let fullText = ''; - - for await (const msg of agent.invoke(prompt)) { - if (msg.type === 'text' && msg.content) { - fullText += msg.content; + try { + await deps.queueProcessor.requestDrain(threadId); + return parseScriptResponse(await completion, request.mode); + } catch (error) { + deps.queueProcessor.unregisterEntryCompleteHook(entryId); + const queued = deps.invocationQueue.getEntrySnapshot(threadId, request.requestedBy, entryId); + if (queued?.status === 'queued') { + const claimed = await deps.invocationQueue.claimQueuedEntryForWithdrawal(threadId, request.requestedBy, entryId); + if (claimed) await deps.invocationQueue.commitClaimedWithdrawal(threadId, entryId); } + throw error; } - - return parseScriptResponse(fullText, request.mode); } const SPEAKER_TO_CAT: Record = { @@ -413,11 +276,7 @@ export async function generatePodcastScript(request: PodcastRequest): Promise { return null; } +export function buildTmuxAgentCarrierExitError(exitCode: number, stderr: string): Error { + const excerpt = excerptSanitizedStderr(stderr, { edge: 'tail', maxLength: 1_000 }); + return new Error(`Codex app-server exited with code ${exitCode}${excerpt ? `: ${excerpt}` : ''}`); +} + class TmuxAgentCarrierSession implements AgentCarrierSession { private input: WriteStream | null = null; private output: ReadStream | null = null; @@ -91,7 +97,7 @@ class TmuxAgentCarrierSession implements AgentCarrierSession { this.context.agentPaneRegistry?.markDone(this.options.invocationId, exitCode); if (exitCode !== null && exitCode !== 0 && !this.options.signal?.aborted) { const stderr = await readFile(this.context.stderrPath, 'utf8').catch(() => ''); - throw new Error(`Codex app-server exited with code ${exitCode}${stderr ? `: ${stderr.slice(-1000)}` : ''}`); + throw buildTmuxAgentCarrierExitError(exitCode, stderr); } } catch (error) { this.context.agentPaneRegistry?.markCrashed( diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 7b90d1f109..9e4a31fe77 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -36,7 +36,6 @@ import { toAllCatConfigs, } from './config/cat-config-loader.js'; import { getCatModel } from './config/cat-models.js'; -import { resolveCodexCarrierTruth } from './config/codex-cli.js'; import { configEventBus } from './config/config-event-bus.js'; import { resolveFrontendBaseUrl, resolveFrontendCorsOrigins } from './config/frontend-origin.js'; import { readMountRules } from './config/mount/mount-rules-store.js'; @@ -65,10 +64,8 @@ import { createDispatchProposalStore } from './domains/approval-hub/stores/facto import { createEntityProposalStore } from './domains/approval-hub/stores/factories/EntityProposalStoreFactory.js'; import { classifyApprovedActionCarrier } from './domains/ball-custody/ActionSuccessorRecoverySweep.js'; import type { ManagedCommandWakeRecoverySweep } from './domains/ball-custody/ManagedCommandWakeRecoverySweep.js'; -import { createManagedCommandWakeQueueAdapter } from './domains/ball-custody/managed-command-wake-queue-adapter.js'; +import { createManagedCommandWakeCarrierAdapter } from './domains/ball-custody/managed-command-wake-carrier-adapter.js'; import { RedisWaitTerminationStore } from './domains/ball-custody/RedisWaitTerminationStore.js'; -import { WaitContinuationRetryCommitter } from './domains/ball-custody/WaitContinuationRetryCommitter.js'; -import { WaitContinuationRetryPreflight } from './domains/ball-custody/WaitContinuationRetryPreflight.js'; import { WaitTerminationService } from './domains/ball-custody/WaitTerminationService.js'; import { agentSessionMutex } from './domains/cats/services/agents/invocation/AgentSessionMutex.js'; // F297 Phase B: Sidebar C10 production source — domain-owned composition shared by @@ -90,12 +87,13 @@ import { selectInvocationBackendKind, } from './domains/cats/services/agents/invocation/InvocationRegistry.js'; import { InvocationTracker } from './domains/cats/services/agents/invocation/InvocationTracker.js'; -import { QueuedMessageCustodyCoordinator } from './domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { InvocationRecordStoreLike, RouterLike, } from './domains/cats/services/agents/invocation/QueueProcessor.js'; import { QueueProcessor } from './domains/cats/services/agents/invocation/QueueProcessor.js'; +import { InMemoryQueueLedgerStore } from './domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js'; +import { RedisQueueLedgerStore } from './domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.js'; import { reconcileZombies } from './domains/cats/services/agents/invocation/reconcileZombies.js'; import { SessionContinuationCoordinator } from './domains/cats/services/agents/invocation/SessionContinuationCoordinator.js'; import { SessionMutex } from './domains/cats/services/agents/invocation/SessionMutex.js'; @@ -107,6 +105,7 @@ import { listenBeforeTurnExecutionRecovery, TurnExecutionStartupReconciler, } from './domains/cats/services/agents/invocation/TurnExecutionStartupReconciler.js'; +import { createThreadExecutionSituationSource } from './domains/cats/services/agents/invocation/thread-execution-situation.js'; import { createZombieTerminalRecovery } from './domains/cats/services/agents/invocation/ZombieTerminalRecovery.js'; import { type AcpPoolRegistry, @@ -136,6 +135,7 @@ import { reconcileFreshnessSupplementsAtStartup } from './domains/cats/services/ import { AgentRouter, AuditEventTypes, + ClaudeSdkAgentService, CodexAgentService, createDraftStore, createInvocationRecordStore, @@ -356,11 +356,18 @@ import { securityHeadersPlugin } from './infrastructure/security-headers.js'; import { sessionAuthPlugin, sessionRoute } from './infrastructure/session-auth.js'; import { SocketManager } from './infrastructure/websocket/index.js'; import { avatarsRoutes } from './routes/avatars.js'; -import { enqueueA2ATargets } from './routes/callback-a2a-trigger.js'; +import { + appendA2ASourceWithLedgerAdmission, + emitA2ARoutingPreflightReceipts, + enqueueA2ATargets, + planA2AFanoutAdmission, + preflightA2ATargets, +} from './routes/callback-a2a-trigger.js'; import { CallbackAuthSystemMessageNotifier } from './routes/callback-auth-system-message.js'; import { cancelManagedWakeIfTaskMatches, commitManagedWakeCancellation, + isManagedWakeRunnerActive, releaseManagedWakeCancellation, reserveManagedWakeCancellation, } from './routes/callback-hold-ball-routes.js'; @@ -850,13 +857,11 @@ async function main(): Promise { ); } await app.register(runtimeInteractionRoutes, { service: runtimeInteractionRuntime.service }); - // Queue owners are initialized beside MessageStore so action-terminal - // convergence can retire their projections at the completion boundary. - const invocationQueue = new InvocationQueue(); - const queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ - messageStore, - readWaitRegistration: (id) => taskStore.getWaitRegistration(id), - }); + + const invocationQueue = new InvocationQueue( + redis ? new RedisQueueLedgerStore(redis) : new InMemoryQueueLedgerStore(), + ); + await invocationQueue.hydrateFromLedger(messageStore); const invocationRecordStore = createInvocationRecordStore(redis); const sessionStore = redis ? new SessionStore(redis) : undefined; // #1200 P2-3: wire cursor canonicalizer for v1→v2 async resolution @@ -1011,14 +1016,13 @@ async function main(): Promise { actionSubjectTruthResolver, ); const projectionRetirement = new actionProjectionRetirementMod.ActionSuccessorProjectionRetirementService({ - queueCustodyCoordinator, invocationQueue, taskStore: { getBySubject: (subjectKey) => taskStore.getBySubject(subjectKey), replaceAutomationStateIfGeneration: (taskId, input) => taskStore.replaceAutomationStateIfGeneration(taskId, input), }, - publishQueue: ({ threadId, userId, receiptMessageIds }) => + publishQueue: ({ threadId, userId }) => emitQueueUpdated( socketManager!, userId, @@ -1026,7 +1030,6 @@ async function main(): Promise { invocationQueue.list(threadId, userId), messageStore, 'action_successor_terminal', - { receiptMessageIds }, ), }); const completionService = new actionCompletionMod.ActionSuccessorCompletionService( @@ -1503,6 +1506,17 @@ async function main(): Promise { globalControlStore, emissionStore, deliver: schedulerDeliver, + cancelQueuedDelivery: async (messageId) => { + const source = await messageStore.getById(messageId); + if (source?.threadId) { + const durable = await invocationQueue.getDurableEntriesForMessages(source.threadId, [messageId]); + if ((durable.get(messageId) ?? []).some((entry) => entry.status === 'queued' || entry.status === 'claimed')) { + return false; + } + } + const canceled = await messageStore.markCanceled(messageId); + return canceled?.deliveryStatus === 'canceled'; + }, notifyLifecycle: schedulerLifecycleToast, fetchContent: schedulerFetchContent, ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), @@ -1969,10 +1983,14 @@ async function main(): Promise { let service: AgentService; // ── F161: Generic ACP transport path (provider-agnostic) ── - // Any clientId with an `acp` config section uses AcpAgentService. - // This check runs BEFORE the clientId switch — ACP is a transport, not a provider. - const acpConfig = getAcpConfig(id, projectRoot); - if (acpConfig) { + // Carrier selection is resolved once by cat-config-loader. The ACP block + // below contains only launch details; its mere presence is not routing truth. + const acpConfig = config.carrier === 'acp' ? getAcpConfig(id, projectRoot) : undefined; + if (config.carrier === 'acp') { + if (!acpConfig) { + app.log.warn(`[api] Cat "${id}" selects carrier=acp but has no ACP config. It will not be routable.`); + continue; + } activeAcpProfileIds.add(id); const acpService = await createAcpServiceForConfig({ projectRoot, @@ -1990,25 +2008,23 @@ async function main(): Promise { switch (config.clientId) { // ── Provider-specific CLI paths (non-ACP) ── case 'anthropic': { - // F198 Phase B Step 3 canary: env-gated carrier selection. - // CAT_CAFE_CLAUDE_CARRIER=bg_daemon → --bg carrier (subscription - // quota, R1 救宪宪). Unset/other → -p (current production default). - const { createClaudeAgentServiceForCanary } = await import( - './domains/cats/services/agents/providers/claude-carrier-factory.js' - ); - service = createClaudeAgentServiceForCanary(catId); + if (config.carrier === 'sdk') { + service = new ClaudeSdkAgentService({ catId }); + } else { + const { createClaudeAgentServiceForCanary } = await import( + './domains/cats/services/agents/providers/claude-carrier-factory.js' + ); + service = createClaudeAgentServiceForCanary(catId); + } break; } case 'openai': { activeCodexProfileIds.add(id); const appServerHostPool = getOrCreateCodexAppServerPool(codexAppServerPoolRegistry, id); - // F254 D2: carrier truth resolved once via the shared helper — - // per-cat cli.carrier > CAT_CAFE_CODEX_CARRIER env > exec_json default. - // Same helper feeds GET /api/cats so Hub display == runtime behavior. service = new CodexAgentService({ catId, appServerHostPool, - carrierMode: resolveCodexCarrierTruth(config.cli?.carrier).effective, + carrierMode: config.carrier === 'app_server' ? 'app_server' : 'exec_json', nativeRealtimeCompanionEnabled, }); break; @@ -2334,11 +2350,9 @@ async function main(): Promise { opts?: { excludeEntryId?: string; parentInvocationId?: string }, ): Array<{ entryId?: string; - source: string; + from: import('@cat-cafe/shared').MessageFrom; content: string; - callerCatId?: string; messageId?: string | null; - mergedMessageIds?: string[]; sourceCategory?: string; }>; } | null = null; @@ -2352,8 +2366,8 @@ async function main(): Promise { }) : undefined; - // F254 Phase C: Freshness state store for carrier tier persistence. - // Shared instance — lightweight (just holds a Redis ref, no state). + // F254 Phase C/D2: keep provider-native freshness bound to the canonical + // Queue projection after the F117 ledger cutover. const freshnessStateStore = redis ? new FreshnessInvocationStateStore(redis) : undefined; const providerNativeFreshnessFactory = redis ? createProviderNativeFreshnessFactory({ @@ -2364,6 +2378,7 @@ async function main(): Promise { getQueue: () => invocationQueueRef, }) : undefined; + // F254 Phase D (AC-D4): Freshness event log for stream output audit trail. const freshnessEventLog = redis ? new FreshnessAttentionEventLog(redis) : undefined; if (freshnessEventLog) { @@ -2413,34 +2428,7 @@ async function main(): Promise { const { TurnCustodyProjectionService } = await import('./domains/ball-custody/TurnCustodyProjectionService.js'); const turnCustodyProjectionService = new TurnCustodyProjectionService({ ...(actionSuccessorLeaseStore ? { actionSuccessorLeaseStore } : {}), - ...(ballCustodyProjectionStore ? { ballCustodyProjectionStore } : {}), - ...(ballCustodyEventLog ? { ballCustodyEventLog } : {}), }); - let a2aDispatchDispositionService: - | import('./domains/ball-custody/A2ADispatchDispositionService.js').A2ADispatchDispositionService - | undefined; - if (ballCustodyIngest && ballCustodyEventLog && ballCustodyProjectionStore) { - const { A2ADispatchDispositionService } = await import('./domains/ball-custody/A2ADispatchDispositionService.js'); - a2aDispatchDispositionService = new A2ADispatchDispositionService({ - registry, - messageStore, - ballCustodyEventLog, - ballCustodyProjectionStore, - ballCustody: ballCustodyIngest, - log: app.log, - ...(ballCustodyProjector - ? { repairProjection: (subjectKey: string) => ballCustodyProjector!.rebuild(subjectKey) } - : {}), - }); - const { CoordinationTerminalRetirement } = await import('./domains/ball-custody/CoordinationTerminalRetirement.js'); - const terminalRetirement = new CoordinationTerminalRetirement({ - messageStore, - service: a2aDispatchDispositionService, - log: app.log, - }); - app.addHook('onReady', async () => terminalRetirement.start()); - app.addHook('onClose', async () => terminalRetirement.stop()); - } const proactiveCandidateRegistryResolver = personMemoryStore ? new ProactiveCandidateRegistryResolver({ entityRegistry: new EntityRegistryStore(memoryServices.store.getDb()), @@ -2493,6 +2481,11 @@ async function main(): Promise { autoDreamStore: autoDream.services.store, }); memoryCueDeps.sourceReader = memoryCueRuntime.sourceReader; + const threadExecutionSituationSource = createThreadExecutionSituationSource({ + messageStore, + listActiveRuns: (threadId) => + invocationTracker.getActiveSlots(threadId).flatMap((slot) => (slot.activeRun ? [slot.activeRun] : [])), + }); let collectiveContext: | import('./domains/plugin/builtin-runtime/collective-current-context.js').CollectiveCurrentContext | undefined; @@ -2501,6 +2494,7 @@ async function main(): Promise { agentRegistry, registry, messageStore, + threadExecutionSituationSource, taskProgressStore, ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(sessionStore ? { sessionStore } : {}), @@ -2543,7 +2537,6 @@ async function main(): Promise { conciergeTriagePlanStore, cloudInvokeBridge, cloudReturnGrantStore, - ...(a2aDispatchDispositionService ? { a2aDispatchDispositionService } : {}), ...(freshnessReinvokeCheck ? { freshnessReinvokeCheck } : {}), turnExecutionStore, ...(freshnessStateStore ? { freshnessStateStore } : {}), @@ -2590,25 +2583,31 @@ async function main(): Promise { }, }, }); - const queueProcessor = new QueueProcessor({ - queue: invocationQueue, - invocationTracker, - invocationRecordStore: invocationRecordStore as unknown as InvocationRecordStoreLike, - router: router as unknown as RouterLike, - socketManager, - messageStore, - queueCustodyCoordinator, - turnExecutionStore, - log: app.log, - threadStore: - threadStore as unknown as import('./domains/cats/services/agents/invocation/QueueProcessor.js').ThreadStoreLike, - sessionContinuationCoordinator, - freshnessEventLog, - freshnessClosureStore, - ...(a2aDispatchDispositionService ? { a2aDispatchDispositionService } : {}), - ...(actionSuccessorLeaseStore ? { actionSuccessorLeaseStore } : {}), - deliveryCursorStore, - }); + const queueProcessor = new QueueProcessor( + { + queue: invocationQueue, + invocationTracker, + invocationRecordStore: invocationRecordStore as unknown as InvocationRecordStoreLike, + router: router as unknown as RouterLike, + socketManager, + messageStore, + turnExecutionStore, + log: app.log, + getPushService: getPushNotificationService, + threadStore: + threadStore as unknown as import('./domains/cats/services/agents/invocation/QueueProcessor.js').ThreadStoreLike, + sessionContinuationCoordinator, + freshnessEventLog, + freshnessClosureStore, + ...(actionSuccessorLeaseStore ? { actionSuccessorLeaseStore } : {}), + deliveryCursorStore, + }, + { + callerDispatchProcessStart: { + processGenerationId: `api:${PROCESS_START_AT}:${process.pid}`, + }, + }, + ); const deliverApprovedActionCarrier = async ( proposal: import('@cat-cafe/shared').DispatchProposal, fence: import('./domains/ball-custody/ActionSuccessorAdmissionService.js').ActionSuccessorFence, @@ -2620,15 +2619,14 @@ async function main(): Promise { if (!actionSocketManager) return { outcome: 'unavailable' }; const targetCatIds = proposal.targetCats as CatId[]; const senderCatId = proposal.senderCatId as CatId; - const storedMsg = await messageStore.append({ + const messageInput = { + from: { kind: 'agent', catId: senderCatId }, userId: proposal.ownerUserId, - catId: senderCatId, content: proposal.content, mentions: targetCatIds, origin: 'callback', timestamp: Date.now(), threadId: proposal.targetThreadId, - deliveryStatus: 'queued', idempotencyKey: `dispatch-action:${proposal.proposalId}:message`, extra: { isExplicitPost: true as const, @@ -2639,9 +2637,74 @@ async function main(): Promise { targetCats: targetCatIds, }, ...(proposal.replyTo ? { replyTo: proposal.replyTo } : {}), + } as const; + const existingMessage = await messageStore.getByIdempotencyKey( + proposal.ownerUserId, + proposal.targetThreadId, + messageInput.idempotencyKey, + ); + const existingEntries = existingMessage + ? ((await invocationQueue.getDurableEntriesForMessages(existingMessage.threadId, [existingMessage.id])).get( + existingMessage.id, + ) ?? []) + : []; + const existingTargets = new Set(existingEntries.flatMap((entry) => entry.targets)); + const freshTargetCatIds = targetCatIds.filter((catId) => !existingTargets.has(catId)); + const routingPreflight = await preflightA2ATargets( + routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}, + { + targetCats: freshTargetCatIds, + content: proposal.content, + userId: proposal.ownerUserId, + }, + ); + const routingPreflightRejected = routingPreflight.acceptedTargetCats.length !== freshTargetCatIds.length; + const planned = planA2AFanoutAdmission( + { invocationQueue }, + { + targetCats: routingPreflightRejected ? [] : routingPreflight.acceptedTargetCats, + requestedTargetCats: targetCatIds, + content: proposal.content, + userId: proposal.ownerUserId, + ownerAuthProvenance, + threadId: proposal.targetThreadId, + createdAt: messageInput.timestamp, + callerCatId: senderCatId, + isCrossThread: proposal.sourceThreadId !== proposal.targetThreadId, + actionSuccessorFence: fence, + }, + ); + const acceptedFresh = new Set(planned.acceptedTargetCats); + const admissionPlan = { + requestedTargetCats: targetCatIds, + acceptedTargetCats: targetCatIds.filter( + (catId) => existingTargets.has(catId) || (!routingPreflightRejected && acceptedFresh.has(catId)), + ), + streakTargetCats: planned.streakTargetCats, + ...(planned.stop ? { stop: planned.stop } : {}), + }; + const atomicAdmission = await appendA2ASourceWithLedgerAdmission({ messageStore, invocationQueue }, messageInput, { + plan: admissionPlan, + ownerAuthProvenance, + actionSuccessorFence: fence, }); - const persistedState = classifyApprovedActionCarrier(proposal, storedMsg); + const storedMsg = atomicAdmission.message; + const classifyPersistedCarrier = async (message: typeof storedMsg) => { + const entries = + (await invocationQueue.getDurableEntriesForMessages(message.threadId, [message.id])).get(message.id) ?? []; + return { state: classifyApprovedActionCarrier(proposal, message, entries, fence), entries }; + }; + const persisted = await classifyPersistedCarrier(storedMsg); + const persistedState = persisted.state; if (persistedState.outcome === 'conflict') { + emitA2ARoutingPreflightReceipts( + { socketManager: actionSocketManager }, + { + decision: routingPreflight.decision, + receiptCatId: senderCatId, + threadId: proposal.targetThreadId, + }, + ); return { outcome: 'terminal_failure', reason: persistedState.reason, @@ -2650,7 +2713,7 @@ async function main(): Promise { } if ( persistedState.outcome === 'admitted' && - (storedMsg.deliveryStatus === 'delivered' || storedMsg.queueCustody?.status === 'terminal') + (storedMsg.deliveryStatus === 'delivered' || persisted.entries.every((entry) => entry.status === 'terminal')) ) { return { outcome: 'enqueued', deliveredMessageId: storedMsg.id }; } @@ -2658,15 +2721,12 @@ async function main(): Promise { try { enqueueResult = await enqueueA2ATargets( { - router: router as unknown as import('./routes/callback-a2a-trigger.js').A2ATriggerDeps['router'], - invocationRecordStore: invocationRecordStore!, socketManager: actionSocketManager, messageStore, ...(invocationTracker ? { invocationTracker } : {}), ...(deliveryCursorStore ? { deliveryCursorStore } : {}), queueProcessor, invocationQueue, - ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), log: app.log, }, @@ -2679,12 +2739,20 @@ async function main(): Promise { triggerMessage: storedMsg, callerCatId: senderCatId, actionSuccessorFence: fence, + preplannedAdmission: admissionPlan, + ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), + ...(atomicAdmission.preAdmittedEntries + ? { + preAdmittedEntries: atomicAdmission.preAdmittedEntries, + preAdmittedReplayed: atomicAdmission.preAdmittedReplayed, + } + : {}), }, ); } catch (error) { const racedMessage = await messageStore.getById(storedMsg.id); if (racedMessage) { - const racedState = classifyApprovedActionCarrier(proposal, racedMessage); + const racedState = (await classifyPersistedCarrier(racedMessage)).state; if (racedState.outcome === 'admitted') { return { outcome: 'enqueued', deliveredMessageId: storedMsg.id }; } @@ -2702,7 +2770,7 @@ async function main(): Promise { if (!targetCatIds.every((catId) => accepted.has(catId))) return { outcome: 'unavailable' }; const admittedMessage = await messageStore.getById(storedMsg.id); if (!admittedMessage) return { outcome: 'unavailable' }; - const admittedState = classifyApprovedActionCarrier(proposal, admittedMessage); + const admittedState = (await classifyPersistedCarrier(admittedMessage)).state; if (admittedState.outcome === 'conflict') { return { outcome: 'terminal_failure', @@ -2750,15 +2818,15 @@ async function main(): Promise { if (recoveryStatus === 'terminal') return { outcome: 'unavailable' as const }; } - const result = invocationQueue.enqueue({ + const result = await invocationQueue.enqueueDurable({ + from: { kind: 'agent', catId: carrier.callerCatId }, threadId: carrier.threadId, userId: carrier.userId, + kind: 'private_input', ownerAuthProvenance: 'unknown', content: carrier.content, - source: 'agent', sourceCategory: 'a2a', targetCats: [carrier.targetCatId], - callerCatId: carrier.callerCatId, intent: 'execute', autoExecute: true, priority: 'urgent', @@ -2766,7 +2834,7 @@ async function main(): Promise { actionSuccessorFence: carrier.fence, }); if (result.outcome !== 'enqueued') return { outcome: 'unavailable' as const }; - await queueProcessor.tryAutoExecute(carrier.threadId); + await queueProcessor.requestDrain(carrier.threadId); const admitted = await invocationRecordStore.getByIdempotencyKey( carrier.threadId, carrier.userId, @@ -2866,23 +2934,22 @@ async function main(): Promise { await reconcileFreshnessClosuresAtStartup({ closureStore: freshnessClosureStore, enqueue: (closure) => - invocationQueue.enqueue({ + invocationQueue.enqueueDurable({ + from: { kind: 'agent', catId: closure.catId }, threadId: closure.threadId, userId: closure.userId, + kind: 'private_input', ownerAuthProvenance: 'unknown', content: `[Freshness Catch Closure ${closure.id}] startup recovery`, - source: 'agent', sourceCategory: 'freshness', targetCats: [closure.catId], - callerCatId: closure.catId, autoExecute: true, priority: 'normal', intent: 'execute', - idempotencyKey: `freshness-closure:${closure.id}`, + sourceId: `freshness-closure:${closure.id}`, freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, }), - executeThread: (threadId) => queueProcessor.tryAutoExecute(threadId), + executeThread: (threadId) => queueProcessor.requestDrain(threadId), onProjection: (projection) => { socketManager?.broadcastAgentMessage( { @@ -2903,8 +2970,13 @@ async function main(): Promise { await reconcileFreshnessSupplementsAtStartup({ closureStore: freshnessClosureStore, messageStore, - enqueue: (supplement) => invocationQueue.enqueue({ ...supplement, ownerAuthProvenance: 'unknown' }), - executeThread: (threadId) => queueProcessor.tryAutoExecute(threadId), + enqueue: (supplement) => + invocationQueue.enqueueDurable({ + ...supplement, + sourceId: supplement.idempotencyKey, + ownerAuthProvenance: 'unknown', + }), + executeThread: (threadId) => queueProcessor.requestDrain(threadId), onProjection: (projection) => { socketManager?.broadcastAgentMessage( { @@ -2968,16 +3040,6 @@ async function main(): Promise { } // Register routes (socketManager injected, no circular import) - const retryAuthorityPreflight = new WaitContinuationRetryPreflight({ - taskStore, - ...(actionSuccessorLeaseStore ? { actionSuccessorLeaseStore } : {}), - }); - const retryAuthorityCommitter = new WaitContinuationRetryCommitter({ - messageStore, - taskStore, - ...(actionSuccessorLeaseStore ? { actionSuccessorLeaseStore } : {}), - ...(redis ? { redis } : {}), - }); const messagesOpts = { projectRoot: resolveActiveProjectRoot(), registry, @@ -2995,12 +3057,10 @@ async function main(): Promise { invocationQueue, ...(freshnessClosureStore ? { freshnessClosureStore } : {}), queueProcessor, - retryAuthorityPreflight, - retryAuthorityCommitter, sessionContinuationCoordinator, ...(f101GameStore ? { gameStore: f101GameStore } : {}), ...(f101SharedDriver ? { autoPlayer: f101SharedDriver } : {}), - holdBallCancelDeps: { dynamicTaskStore, taskRunner: taskRunnerV2 }, + holdBallCancelDeps: { dynamicTaskStore, taskRunner: taskRunnerV2, messageStore, socketManager }, // F192 Phase G AC-G12 / F227 归一: magic word → Event Memory (single truth // source) + a lightweight episode ref for F192's a2 projection. onMagicWordDetected: ( @@ -3085,12 +3145,10 @@ async function main(): Promise { queueProcessor, invocationTracker, resolveCarrierCapability: (catId) => router.freshnessCarrierCapability(catId), + isCatAvailable: (catId) => isCatAvailable(catId), agentSessionMutex, socketManager, messageStore, // F117: for marking queued messages as canceled on withdraw/clear - retryAuthorityPreflight, - retryAuthorityCommitter, - queueCustodyCoordinator, invocationRecordStore, // F194 Phase B: canonical liveness read source draftStore, // F194 Phase B: canonical liveness read source turnExecutionStore, // F194/F254: durable running child closes tracker/draft handoff gaps @@ -3103,18 +3161,12 @@ async function main(): Promise { await app.register(invocationsRoutes, { invocationRecordStore, turnExecutionStore, - messageStore, - socketManager, - router, - invocationTracker, - queueProcessor, }); await app.register(messageActionsRoutes, { messageStore, socketManager, threadStore, invocationQueue, - queueCustodyCoordinator, queueProcessor, indexBuilder: memoryServices.indexBuilder, }); @@ -3129,6 +3181,7 @@ async function main(): Promise { } await app.register(catsRoutes, { resolveContextCapacitySnapshot: (catId) => router.contextCapacitySnapshot(catId), + resolveCarrierCapability: (catId) => router.freshnessCarrierCapability(catId), }); await app.register(routingContextRoutes, { ...(routingContextRuntime ? { runtime: routingContextRuntime } : {}), @@ -3212,6 +3265,7 @@ async function main(): Promise { const { evalHubRoutes } = await import('./routes/eval-hub.js'); const { evalVerdictLifecycleRoutes } = await import('./routes/eval-verdict-lifecycle.js'); + const evalHarnessFeedbackRoot = resolve(repoRoot, 'docs', 'harness-feedback'); const reevalClosureEventLog = redis ? new (await import('./infrastructure/harness-eval/reeval-closure-event-log.js')).RedisReevalClosureEventLog(redis) @@ -3510,7 +3564,7 @@ async function main(): Promise { new FreshnessReplayProviderImpl({ store: freshnessClosureStore, fixtureRoot: resolve(repoRoot, 'docs', 'harness-feedback', 'fixtures', 'f254'), - queueLifecycleSource: messageStore, + queueLifecycleSource: invocationQueue, ...(freshnessEventLog ? { attentionEventLog: freshnessEventLog } : {}), }), ); @@ -4670,39 +4724,6 @@ async function main(): Promise { const waitLifecycleHolder: { current?: import('./domains/github-signals/GitHubWaitLifecycleService.js').GitHubWaitLifecycleService; } = {}; - let managedHoldDispositionService: - | import('./domains/ball-custody/ManagedHoldDispositionService.js').ManagedHoldDispositionService - | undefined; - if (ballCustodyIngest && ballCustodyEventLog && ballCustodyProjectionStore) { - const [{ ManagedHoldReceiptService }, { ManagedHoldDispositionService }] = await Promise.all([ - import('./domains/ball-custody/ManagedHoldReceiptService.js'), - import('./domains/ball-custody/ManagedHoldDispositionService.js'), - ]); - const receiptService = new ManagedHoldReceiptService({ - queue: invocationQueue, - messageStore, - coordinator: queueCustodyCoordinator, - onSettled: ({ threadId, sourceMessageId }) => { - socketManager?.broadcastToRoom(`thread:${threadId}`, 'message_receipt_updated', { - threadId, - messageId: sourceMessageId, - }); - }, - }); - managedHoldDispositionService = new ManagedHoldDispositionService({ - registry, - log: app.log, - dynamicTaskStore, - messageStore, - ballCustodyEventLog, - ballCustodyProjectionStore, - ballCustody: ballCustodyIngest, - receiptService, - ...(ballCustodyProjector - ? { repairProjection: (subjectKey: string) => ballCustodyProjector!.rebuild(subjectKey) } - : {}), - }); - } const meetingArtifactReaderHolder: import('./routes/callback-meeting-artifact-routes.js').MeetingArtifactReaderHolder = {}; const namedCatContentHolder: import('./routes/callback-content-editor-routes.js').NamedCatContentHolder = {}; @@ -4716,6 +4737,7 @@ async function main(): Promise { cloudReturnBindingSigner, cloudReturnGrantStore, messageStore, + threadExecutionSituationSource, socketManager, callbackAuthNotifier, taskStore, @@ -4779,7 +4801,6 @@ async function main(): Promise { invocationQueue, ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), ...(actionSuccessorAdmissionService ? { actionSuccessorAdmissionService } : {}), - queueCustodyCoordinator, indexBuilder: memoryServices.indexBuilder as | { markThreadDirty(threadId: string): void; flushDirtyThreads?(): number | Promise } | undefined, @@ -4805,8 +4826,6 @@ async function main(): Promise { threadStore, taskStore, invocationRecordStore, - ...(managedHoldDispositionService ? { managedHoldDispositionService } : {}), - ...(a2aDispatchDispositionService ? { a2aDispatchDispositionService } : {}), ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), onHoldBallCancelFeedback: (input) => { void import('./domains/cats/services/frustration/FrustrationDetector.js') @@ -5596,8 +5615,8 @@ async function main(): Promise { const targetCatIds = proposal.targetCats as CatId[]; const senderCatId = proposal.senderCatId as CatId; const storedMsg = await messageStore.append({ + from: { kind: 'agent', catId: senderCatId }, userId: proposal.ownerUserId, - catId: senderCatId, content: proposal.content, mentions: targetCatIds, origin: 'callback', @@ -5620,15 +5639,12 @@ async function main(): Promise { try { await enqueueA2ATargets( { - router: router as unknown as import('./routes/callback-a2a-trigger.js').A2ATriggerDeps['router'], - invocationRecordStore: invocationRecordStore!, socketManager, messageStore, ...(invocationTracker ? { invocationTracker } : {}), ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(invocationQueue ? { invocationQueue } : {}), - ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), log: app.log, }, @@ -5720,6 +5736,8 @@ async function main(): Promise { getCatDisplayName: (catId: string) => allCatConfigs[catId]?.displayName ?? catId, getAllCatIds: () => Object.keys(allCatConfigs), isCatAvailable: (catId: string) => isCatAvailable(catId), + resolveConversationFallbackTarget: async (threadId: string) => + (await router.resolveConversationTargetsAtAdmission([], threadId))[0], }); } await app.register(tasksRoutes, { taskStore, socketManager, waitLifecycleHolder }); @@ -6457,10 +6475,8 @@ async function main(): Promise { `[api] F142-B: CommandRegistry loaded (${commandRegistry.getAll().length} commands, ${skillCommandMap.size} skills)`, ); - // Commands route needs opus service for task extraction. + // Commands route needs the configured opus service for task extraction. // Lazy-init: empty catalog (first-run) has no opus entry yet — defer until first use. - // 砚砚 Step-3 P2 (2026-05-14): route through canary factory so this path - // also honors CAT_CAFE_CLAUDE_CARRIER=bg_daemon when canary flips. // 砚砚 Step-3 P1 re-review: invoke() must directly return AsyncIterable // (not Promise), otherwise `for await (... of svc.invoke())` // crashes at runtime. Use sync generator wrapper that defers async setup @@ -6472,10 +6488,15 @@ async function main(): Promise { // before first yield, then delegates. return (async function* opusLazyInvoke() { if (!_opusService) { - const { createClaudeAgentServiceForCanary } = await import( - './domains/cats/services/agents/providers/claude-carrier-factory.js' - ); - _opusService = createClaudeAgentServiceForCanary('opus' as CatId); + const opusConfig = catRegistry.tryGet('opus')?.config; + if (opusConfig?.carrier === 'sdk') { + _opusService = new ClaudeSdkAgentService({ catId: 'opus' as CatId }); + } else { + const { createClaudeAgentServiceForCanary } = await import( + './domains/cats/services/agents/providers/claude-carrier-factory.js' + ); + _opusService = createClaudeAgentServiceForCanary('opus' as CatId); + } } yield* _opusService.invoke(prompt, options); })(); @@ -6497,9 +6518,7 @@ async function main(): Promise { await app.register(signalPodcastRoutes, { messageStore, threadStore, - router, - invocationRecordStore, - invocationTracker, + invocationQueue, queueProcessor, }); @@ -6747,15 +6766,13 @@ async function main(): Promise { messageStore, socketManager: socketManager ?? undefined, invocationQueue, - ...(a2aDispatchDispositionService ? { a2aDispatchDispositionService } : {}), - resumePrestartRetirement: (entries) => queueProcessor.resumeDurablePrestartRetirement(entries), ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), }); const startupRecovery = await reconciler.reconcileOrphans(); registry.markStartupRecoveryComplete(); for (const scope of startupRecovery.queueResumeScopes) { try { - await queueProcessor.processNext(scope.threadId, scope.userId); + await queueProcessor.requestDrain(scope.threadId); } catch (error) { app.log.warn( { error, threadId: scope.threadId, userId: scope.userId }, @@ -6923,24 +6940,12 @@ async function main(): Promise { // F140 Phase 3b: connector invoke trigger (auto-invoke cat after review feedback delivery via polling) const frontendBaseUrl = resolveFrontendBaseUrl(process.env, app.log); const invokeTrigger = new ConnectorInvokeTrigger({ - router, socketManager, - invocationRecordStore, - invocationTracker, invocationQueue, queueProcessor, - queueCustodyCoordinator, messageStore, - actionSuccessorLeaseStore, - threadMetaLookup: async (threadId) => { - const thread = await threadStore.get(threadId); - if (!thread) return undefined; - return { - threadShortId: threadId.slice(0, 15), - threadTitle: thread.title ?? undefined, - deepLinkUrl: buildThreadDeepLink(frontendBaseUrl, threadId), - }; - }, + waitTaskStore: taskStore, + ...(actionSuccessorLeaseStore ? { actionSuccessorLeaseStore } : {}), log: app.log, }); @@ -6949,7 +6954,6 @@ async function main(): Promise { isKnownCat: (catId) => catRegistry.tryGet(catId) !== undefined, messageStore, invokeTriggerProvider: { get: () => invokeTrigger }, - socketManager, }); const { LimbOutboundDeliveryHook } = await import('./domains/limb/LimbOutboundDeliveryHook.js'); const limbOutboundDelivery = new LimbOutboundDeliveryHook({ @@ -6972,12 +6976,11 @@ async function main(): Promise { taskRunner: taskRunnerV2, invocationRecordStore, getInvokeTrigger: () => invokeTrigger, - ...createManagedCommandWakeQueueAdapter({ - dynamicTaskStore, + isCommandRunnerActive: isManagedWakeRunnerActive, + ...createManagedCommandWakeCarrierAdapter({ messageStore, invocationRecordStore, invocationQueue, - queueProcessor, }), }); managedCommandWakeRecovery = recovery; @@ -7483,7 +7486,7 @@ async function main(): Promise { reconciliationDedup, bindingStore: new RedisConnectorThreadBindingStore(redisClient), deliverFn: deliverConnectorMessage, - deliveryDeps: { messageStore, socketManager }, + deliveryDeps: { messageStore }, fetchOpenPRs, fetchOpenIssues, // F168 C0.3: repo-level comment poller wiring @@ -7695,7 +7698,6 @@ async function main(): Promise { publishPrereqCache.set(domainId, ok); return ok; }; - const evalScheduleOpts = { harnessFeedbackRoot: resolve(repoRoot, 'docs', 'harness-feedback'), threadStore, @@ -7804,15 +7806,12 @@ async function main(): Promise { if (!socketManager) return { accepted: false }; const result = await enqueueA2ATargets( { - router: router as unknown as import('./routes/callback-a2a-trigger.js').A2ATriggerDeps['router'], - invocationRecordStore: invocationRecordStore!, socketManager, messageStore, ...(invocationTracker ? { invocationTracker } : {}), ...(deliveryCursorStore ? { deliveryCursorStore } : {}), queueProcessor, invocationQueue, - ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), log: app.log, }, @@ -7828,7 +7827,7 @@ async function main(): Promise { }, ); const accepted = [...result.enqueued, ...(result.coalesced ?? [])]; - return { accepted: !result.fallback && accepted.includes(input.targetCatId) }; + return { accepted: accepted.includes(input.targetCatId) }; }), }) : undefined; @@ -8070,8 +8069,6 @@ async function main(): Promise { function wireGatewayHooks(handle: NonNullable>>): void { handle.outboundHook.setLimbDelivery(limbOutboundDelivery); - invokeTrigger.setOutboundHook(handle.outboundHook); - invokeTrigger.setStreamingHook(handle.streamingHook); queueProcessor.setOutboundHook(handle.outboundHook as Parameters[0]); queueProcessor.setStreamingHook(handle.streamingHook as Parameters[0]); (callbackOpts as { outboundHook?: typeof handle.outboundHook }).outboundHook = handle.outboundHook; diff --git a/packages/api/src/infrastructure/capability-evolution/program-preparation-materialization.ts b/packages/api/src/infrastructure/capability-evolution/program-preparation-materialization.ts index eaa69fe786..537da5688e 100644 --- a/packages/api/src/infrastructure/capability-evolution/program-preparation-materialization.ts +++ b/packages/api/src/infrastructure/capability-evolution/program-preparation-materialization.ts @@ -94,7 +94,7 @@ export async function materializePreparationSubmission(input: MaterializationInp const result = await input.dependencies.messageStore.appendIdempotent({ userId: input.ownerUserId, threadId: origin.threadId, - catId: createCatId(input.submission.authorCatId), + from: { kind: 'agent', catId: createCatId(input.submission.authorCatId) }, content: `准备提交 · ${input.submission.title}\n${input.submission.body.summary}`, mentions: [], timestamp: Date.parse(input.envelope.occurredAt), diff --git a/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts b/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts index e789cd8b81..6da0351532 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts @@ -1,4 +1,5 @@ -import { normalizeCatId, parseCommand } from '@cat-cafe/shared'; +import { type MessageFrom, normalizeCatId, parseCommand } from '@cat-cafe/shared'; +import { messageFrom } from '../../domains/cats/services/stores/message-from.js'; import type { CommandRegistry } from '../commands/CommandRegistry.js'; import type { IConnectorPermissionStore } from './ConnectorPermissionStore.js'; import type { IConnectorThreadBindingStore } from './ConnectorThreadBindingStore.js'; @@ -96,8 +97,10 @@ export interface ConnectorCommandLayerDeps { timestamp: number, limit?: number, ): - | Array<{ catId: string | null; userId?: string; content: string; timestamp: number }> - | Promise>; + | Array<{ from?: MessageFrom; catId: string | null; userId?: string; content: string; timestamp: number }> + | Promise< + Array<{ from?: MessageFrom; catId: string | null; userId?: string; content: string; timestamp: number }> + >; }; } @@ -424,8 +427,7 @@ export class ConnectorCommandLayer { } type Msg = Awaited['getByThreadBefore']>>[number]; - const SYSTEM_UIDS = new Set(['system', 'scheduler']); - const isUserMsg = (m: Msg): boolean => m.catId === null && !SYSTEM_UIDS.has(m.userId ?? ''); + const isUserMsg = (m: Msg): boolean => messageFrom(m as Parameters[0]).kind === 'user'; const splitRounds = (msgs: Msg[]): Msg[][] => { const result: Msg[][] = []; let cur: Msg[] = []; @@ -459,11 +461,14 @@ export class ConnectorCommandLayer { const TOTAL_BUDGET = PLATFORM_BUDGET[connectorId] ?? 2000; const roster = this.deps.catRoster; const resolveSender = (msg: Msg): string => { - if (msg.catId) { - const display = roster?.[msg.catId]?.displayName; - return `🐱 ${display ?? msg.catId}`; + const from = messageFrom(msg as Parameters[0]); + if (from.kind === 'agent') { + const display = roster?.[from.catId]?.displayName; + return `🐱 ${display ?? from.catId}`; } - if (SYSTEM_UIDS.has(msg.userId ?? '')) return '🔔 系统'; + if (from.kind === 'system') return '🔔 系统'; + if (from.kind === 'external') return `🔗 ${from.sender?.name ?? from.sender?.id ?? from.connectorId}`; + if (from.kind === 'plugin') return `🔌 ${from.instanceId}`; return '👤 你'; }; diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index f51597614a..5099bb8542 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -15,7 +15,7 @@ * F088 Multi-Platform Chat Gateway */ -import type { CatId, ConnectorDefinition, ConnectorSource, MessageContent } from '@cat-cafe/shared'; +import type { CatId, ConnectorDefinition, ConnectorSource, MessageContent, MessageFrom } from '@cat-cafe/shared'; import { catRegistry, getConnectorDefinition } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; import { findMonorepoRoot } from '../../utils/monorepo-root.js'; @@ -62,13 +62,15 @@ export interface ConnectorRouterOptions { readonly dedup: InboundMessageDedup; readonly messageStore: { append(input: { + from: MessageFrom; threadId: string; userId: string; - catId: null; content: string; source: ConnectorSource; mentions: CatId[]; timestamp: number; + deliveryStatus?: 'queued'; + contentBlocks?: readonly MessageContent[]; }): Promise<{ id: string }>; }; readonly threadStore: { @@ -120,7 +122,7 @@ export interface ConnectorRouterOptions { contentBlocks?: readonly MessageContent[], policy?: unknown, sender?: { id: string; name?: string }, - ): Promise<'dispatched' | 'enqueued' | 'full'>; + ): Promise<'enqueued' | 'full'>; }; readonly socketManager?: | { @@ -307,19 +309,18 @@ export class ConnectorRouter { const { targetCatId } = parseMentions(fwdText, mentionPatterns, this.getDefaultCatId()); const fwdTimestamp = Date.now(); const fwdStored = await messageStore.append({ + from: { + kind: 'external', + connectorId, + ...(sender ? { sender } : {}), + }, threadId: fwdThreadId, userId: this.opts.defaultUserId, - catId: null, content: fwdText, source: fwdSource, mentions: [targetCatId], timestamp: fwdTimestamp, - }); - emitConnectorMessage(socketManager, fwdThreadId, { - id: fwdStored.id, - content: fwdText, - source: fwdSource, - timestamp: fwdTimestamp, + deliveryStatus: 'queued', }); const triggerOutcome = await invokeTrigger.trigger( fwdThreadId, @@ -360,19 +361,18 @@ export class ConnectorRouter { const askCatId = cmdResult.targetCatId as CatId; const askTimestamp = Date.now(); const askStored = await messageStore.append({ + from: { + kind: 'external', + connectorId, + ...(sender ? { sender } : {}), + }, threadId: askThreadId, userId: this.opts.defaultUserId, - catId: null, content: askText, source: askSource, mentions: [askCatId], timestamp: askTimestamp, - }); - emitConnectorMessage(socketManager, askThreadId, { - id: askStored.id, - content: askText, - source: askSource, - timestamp: askTimestamp, + deliveryStatus: 'queued', }); const triggerOutcome = await invokeTrigger.trigger( askThreadId, @@ -464,25 +464,23 @@ export class ConnectorRouter { const storedTimestamp = Date.now(); const stored = await messageStore.append({ + from: { + kind: 'external', + connectorId, + ...(sender ? { sender } : {}), + }, threadId: binding.threadId, userId: this.opts.defaultUserId, - catId: null, content: resolvedText, source, mentions: [targetCatId], timestamp: storedTimestamp, + deliveryStatus: 'queued', ...(contentBlocks ? { contentBlocks } : {}), }); - // 4. Broadcast to WebSocket - emitConnectorMessage(socketManager, binding.threadId, { - id: stored.id, - content: resolvedText, - source, - timestamp: storedTimestamp, - }); - - // 5. Trigger cat invocation (use parsed targetCatId) + // 4. Bind the hidden source to Queue. QueueProcessor publishes it to + // History only after exact provider admission. await invokeTrigger.trigger( binding.threadId, targetCatId, @@ -620,9 +618,9 @@ export class ConnectorRouter { // Store inbound command const cmdMsg = await messageStore.append({ + from: { kind: 'external', connectorId }, threadId, userId: this.opts.defaultUserId, - catId: null, content: commandText, source: { connector: connectorId, label: def?.displayName ?? connectorId, icon: connectorSourceIcon(def) }, mentions: [], @@ -631,9 +629,9 @@ export class ConnectorRouter { // Store outbound system response const resMsg = await messageStore.append({ + from: { kind: 'system', service: 'connector-command' }, threadId, userId: this.opts.defaultUserId, - catId: null, content: responseText, source: { connector: 'system-command', label: 'Clowder AI', icon: 'settings' }, mentions: [], diff --git a/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts b/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts index 40926d3fd6..43cd18cfa5 100644 --- a/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts +++ b/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts @@ -23,6 +23,8 @@ import { catRegistry, isStaticConnectorId, isValueField, + type MessageContent, + type MessageFrom, } from '@cat-cafe/shared'; import type { RedisClient } from '@cat-cafe/shared/utils'; import type { FastifyBaseLogger } from 'fastify'; @@ -113,13 +115,15 @@ export interface ConnectorGatewayConfig { export interface ConnectorGatewayDeps { readonly messageStore: { append(input: { + from: MessageFrom; threadId: string; userId: string; - catId: null; content: string; source: ConnectorSource; mentions: CatId[]; timestamp: number; + deliveryStatus?: 'queued'; + contentBlocks?: readonly MessageContent[]; }): Promise<{ id: string }>; getById?(id: string): Promise<{ source?: ConnectorSource } | null>; getByThreadBefore?( @@ -189,7 +193,7 @@ export interface ConnectorGatewayDeps { message: string, messageId: string, ...args: unknown[] - ): Promise<'dispatched' | 'enqueued' | 'full'>; + ): Promise<'enqueued' | 'full'>; }; readonly socketManager?: | { @@ -881,7 +885,6 @@ export async function startConnectorGateway( deliveryDeps: { messageStore: deps.messageStore as import('../../domains/cats/services/stores/ports/MessageStore.js').IMessageStore, - socketManager: deps.socketManager, }, // F168 Phase A P1-1b: pass community event services to webhook handler eventLog: ghEventLog, diff --git a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index 735ce40bb9..57ce75576c 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -1,20 +1,4 @@ -/** - * Connector Invoke Trigger - * Programmatically triggers a cat invocation after a connector message is posted. - * - * Phase 3b: Closes the loop — review email → connector message → cat invocation. - * Uses the same AgentRouter pipeline as POST /api/messages but triggered - * by the email watcher instead of an HTTP request. - * - * BACKLOG #97 Phase 3b - */ - -import { - type CatId, - type MessageContent, - type OutputCommitDecision, - type WaitContinuationCarrierV1, -} from '@cat-cafe/shared'; +import type { CatId, MessageContent } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; import { getDefaultCatId } from '../../config/cat-config-loader.js'; import type { ActionSuccessorLeaseStore } from '../../domains/ball-custody/ActionSuccessorLeaseStore.js'; @@ -22,420 +6,76 @@ import { hasManagedCommandWakeActionLeaseRef, resolveManagedCommandWakeActionLeaseAdmission, } from '../../domains/ball-custody/managed-command-wake-action-lease-admission.js'; -import type { TurnCustodyWakeProvenance } from '../../domains/ball-custody/TurnCustodyProjectionService.js'; -import { - resolveQueueTurnCustodyWake, - retargetTurnCustodyWake, -} from '../../domains/ball-custody/turn-custody-wake-provenance.js'; import { + assertCurrentWaitContinuationCarrier, waitContinuationCarrierFromStoredMessage, - waitContinuationCarriersMatch, } from '../../domains/ball-custody/wait-continuation-carrier.js'; -import type { InvocationQueue, QueueEntry } from '../../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { InvocationTracker } from '../../domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - isTerminalDispositionEvent, - PerCatTerminalDispositionCollector, -} from '../../domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.js'; -import { - actionSuccessorCarrierKey, - createInitialQueuedMessageCustody, - type QueuedMessageCustodyCoordinator, -} from '../../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; +import type { InvocationQueue } from '../../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { QueueProcessor } from '../../domains/cats/services/agents/invocation/QueueProcessor.js'; -import { requireInvocationRecordUpdate } from '../../domains/cats/services/agents/invocation/require-invocation-record-update.js'; -import { stampVisibleTurn } from '../../domains/cats/services/agents/invocation/visible-turn.js'; -import type { AgentRouter } from '../../domains/cats/services/agents/routing/AgentRouter.js'; -import { - createA2ASlotTrackingBridge, - type PersistenceContext, -} from '../../domains/cats/services/agents/routing/route-helpers.js'; import { normalizeOwnerAuthProvenance, type OwnerAuthProvenance, } from '../../domains/cats/services/owner-auth-provenance.js'; -import type { - IInvocationRecordStore, - InvocationActionLeaseCarrier, - InvocationRecord, - InvocationStatus, -} from '../../domains/cats/services/stores/ports/InvocationRecordStore.js'; -import { classifyInvocationRecoveryStatus } from '../../domains/cats/services/stores/ports/invocation-state-machine.js'; -import type { IMessageStore, StoredMessage } from '../../domains/cats/services/stores/ports/MessageStore.js'; -import { type AgentMessage, mergeTokenUsage, type TokenUsage } from '../../domains/cats/services/types.js'; -import type { MemoryCueOpportunitySeed } from '../../domains/memory/cue/MemoryCueInvocationPromptService.js'; -import { readTrustedConnectorMemoryCueSeeds } from '../../domains/memory/cue/MemoryCueTrustedConnector.js'; -import { bindAsrPersonMemoryReentryFromSchedulerMessage } from '../../domains/memory/people/AsrPersonMemoryReentryCarrier.js'; +import { messageFrom } from '../../domains/cats/services/stores/message-from.js'; +import type { IMessageStore } from '../../domains/cats/services/stores/ports/MessageStore.js'; +import type { ITaskStore } from '../../domains/cats/services/stores/ports/TaskStore.js'; import type { SocketManager } from '../../infrastructure/websocket/index.js'; import { emitQueueUpdated, enrichQueueEntries } from '../../utils/queue-enrichment.js'; - import type { OutboundDeliveryHook, ThreadMeta } from '../connectors/OutboundDeliveryHook.js'; import type { StreamingOutboundHook } from '../connectors/StreamingOutboundHook.js'; -export type TriggerOutcome = 'dispatched' | 'enqueued' | 'full'; - -type DirectInvocationAdmission = - | { - readonly kind: 'execute'; - readonly invocationId: string; - readonly expectedInitialStatus: Extract; - } - | { readonly kind: 'acknowledged'; readonly invocationId: string; readonly status: 'running' | 'succeeded' }; - -interface ExecutionStartReceipt { - readonly promise: Promise; - readonly accept: () => void; - readonly reject: (reason: unknown) => void; -} - -function createExecutionStartReceipt(): ExecutionStartReceipt { - let settled = false; - let resolvePromise!: () => void; - let rejectPromise!: (reason: unknown) => void; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - return { - promise, - accept: () => { - if (settled) return; - settled = true; - resolvePromise(); - }, - reject: (reason) => { - if (settled) return; - settled = true; - rejectPromise(reason); - }, - }; -} - -function hasExecutionStartReceipt(record: InvocationRecord): boolean { - return ( - typeof record.executionStartedAt === 'number' && - Number.isFinite(record.executionStartedAt) && - record.executionStartedAt > 0 - ); -} - -interface DurableChildExecutionStart { - readonly childInvocationId: string; - readonly startedAt: number; -} - -function parseDurableChildExecutionStart( - message: AgentMessage, - expectedParentInvocationId: string, -): DurableChildExecutionStart | undefined { - if (message.type !== 'system_info' || typeof message.content !== 'string') return undefined; - - let parsed: unknown; - try { - parsed = JSON.parse(message.content); - } catch { - return undefined; - } - if (!parsed || typeof parsed !== 'object') return undefined; - - const candidate = parsed as Record; - if (candidate.type !== 'invocation_created') return undefined; - if (typeof candidate.invocationId !== 'string' || candidate.invocationId.trim().length === 0) return undefined; - if (candidate.parentInvocationId !== expectedParentInvocationId) return undefined; - if (typeof candidate.startedAt !== 'number' || !Number.isFinite(candidate.startedAt) || candidate.startedAt <= 0) { - return undefined; - } - - return { - childInvocationId: candidate.invocationId, - startedAt: candidate.startedAt, - }; -} +export type TriggerOutcome = 'enqueued' | 'full'; export interface ConnectorInvokeTriggerOptions { - readonly router: AgentRouter; readonly socketManager: SocketManager; - readonly invocationRecordStore: IInvocationRecordStore; - readonly invocationTracker: InvocationTracker; readonly invocationQueue: InvocationQueue; readonly queueProcessor?: QueueProcessor; - /** Gate 2: exact queued-source CAS used when recovery must replace an absent carrier. */ - readonly queueCustodyCoordinator?: QueuedMessageCustodyCoordinator; readonly outboundHook?: OutboundDeliveryHook; readonly streamingHook?: StreamingOutboundHook; readonly threadMetaLookup?: (threadId: string) => ThreadMeta | undefined | Promise; - /** Per-cat outbound deliver timeout in ms (default 10000). Prevents hanging deliver from blocking cleanup. */ readonly deliverTimeoutMs?: number; - /** #706: MessageStore for queue enrichment (messagePreview in queue_updated SSE). */ readonly messageStore?: IMessageStore; - /** Canonical owner used to validate a managed-command wake's frozen action generation. */ readonly actionSuccessorLeaseStore?: Pick; + readonly waitTaskStore?: Pick; readonly log: FastifyBaseLogger; } export interface ConnectorTriggerPolicy { - /** F175: urgent entries get priority dequeue, no preemption */ readonly priority?: 'urgent' | 'normal'; - /** optional reason for diagnostics */ readonly reason?: string; - /** F175: origin category for visual grouping */ readonly sourceCategory?: 'ci' | 'review' | 'conflict' | 'scheduled' | 'a2a' | 'issue'; - /** F140 Phase C: hint which Skill to auto-load (not a hard constraint — cat can override) */ readonly suggestedSkill?: string; - /** Event carriers use the existing Queue/F254/F264 custody even when the thread is idle. */ readonly forceQueue?: boolean; - /** Server-private owner proof restored by durable managed-command producers. */ readonly ownerAuthProvenance?: OwnerAuthProvenance; - /** - * Optional queue coalescing key for connector bursts that supersede earlier queued work. - * Later hits reuse the first queued entry: messageIds are merged, but the original content/body stays in place. - * Once that entry is already processing, follow-up feedback gets a fresh queued wake-up. - * Queue metadata may still upgrade, e.g. normal COMMENTED feedback becoming urgent CHANGES_REQUESTED. - */ readonly coalesceKey?: string; } -function isConnectorDeliverable(decision: OutputCommitDecision | undefined): boolean { - return ( - decision === undefined || - decision.kind === 'committed_fresh' || - decision.kind === 'committed_degraded_unknown' || - decision.kind === 'published_with_unseen' - ); -} - -function actionLeaseCarriersMatch(left: InvocationActionLeaseCarrier, right: InvocationActionLeaseCarrier): boolean { - return ( - left.kind === right.kind && - (left.kind === 'none' || - (right.kind === 'action_successor' && left.leaseId === right.leaseId && left.generation === right.generation)) - ); -} - -/** - * Invocation trigger for connector messages. The executor continues in the - * background, but admission does not resolve until router startup has durable - * evidence in the parent InvocationRecord. - * - * Flow: - * 1. Create InvocationRecord (atomic) - * 2. Start InvocationTracker - * 3. Start routeExecution and persist its durable first-event receipt - * 4. Continue routeExecution in background - * 5. Broadcast agent messages to WebSocket room - * 6. Ack cursor boundaries + update status - */ +/** Connector ingress always joins the same durable Queue lifecycle as user and A2A work. */ export class ConnectorInvokeTrigger { - private readonly opts: ConnectorInvokeTriggerOptions; - - constructor(opts: ConnectorInvokeTriggerOptions) { - this.opts = opts; - } - - /** Late-bind outbound hook (set after gateway bootstrap) */ - setOutboundHook(hook: OutboundDeliveryHook): void { - (this.opts as { outboundHook?: OutboundDeliveryHook }).outboundHook = hook; - } - - /** Late-bind streaming hook (set after gateway bootstrap) */ - setStreamingHook(hook: StreamingOutboundHook): void { - (this.opts as { streamingHook?: StreamingOutboundHook }).streamingHook = hook; - } + constructor(private readonly opts: ConnectorInvokeTriggerOptions) {} - /** - * Trigger a cat invocation for a connector message. - * Returns after durable execution-start acknowledgement; the remainder of - * execution continues in the background. - * - * @param threadId Thread where the connector message was posted - * @param catId Target cat to invoke - * @param userId User context for the invocation - * @param message The connector message content (used as invocation trigger) - * @param messageId The stored connector message ID (for InvocationRecord backfill) - */ async trigger( threadId: string, catId: CatId, userId: string, message: string, messageId: string, - contentBlocks?: readonly MessageContent[], + _contentBlocks?: readonly MessageContent[], policy?: ConnectorTriggerPolicy, - sender?: { id: string; name?: string }, + _sender?: { id: string; name?: string }, ): Promise { - const { invocationTracker } = this.opts; - const priority = policy?.priority ?? 'normal'; - const ownerAuthProvenance = normalizeOwnerAuthProvenance(policy?.ownerAuthProvenance); - - if (policy?.forceQueue) { - const outcome = await this.enqueueWhileActive( - threadId, - catId, - userId, - message, - messageId, - sender, - priority, - policy.sourceCategory, - policy.suggestedSkill, - policy.coalesceKey, - true, - ownerAuthProvenance, - ); - if (outcome === 'enqueued') { - const exactEntry = this.opts.invocationQueue.findEntryWithMessageId(threadId, messageId); - if (exactEntry && (await this.isQueueEntryCustodyReady(exactEntry, messageId))) { - await this.opts.queueProcessor?.tryAutoExecute(threadId, { - bypassNonAgentGate: true, - onlyEntryId: exactEntry.id, - }); - } - } - return outcome; - } - - // A cancel-all/force-reset owns the next terminal transition. Late managed - // command or connector wakes remain durable in Queue instead of reviving - // the just-reset slot through direct admission. - if (this.opts.queueProcessor?.isAutoResumeSuppressed?.(threadId, catId)) { - return this.enqueueWhileActive( - threadId, - catId, - userId, - message, - messageId, - sender, - priority, - policy?.sourceCategory, - policy?.suggestedSkill, - policy?.coalesceKey, - false, - ownerAuthProvenance, - ); - } - - // F185 AC-1: thread-level queue/processingSlots gate - if (this.opts.queueProcessor?.isThreadBusy(threadId)) { - return this.enqueueWhileActive( + const messageStore = this.opts.messageStore; + if (!messageStore) throw new Error('Connector Queue admission requires MessageStore'); + const sourceMessage = await messageStore.getById(messageId); + if (!sourceMessage) throw new Error(`Connector source message is unavailable: ${messageId}`); + const waitContinuationCarrier = waitContinuationCarrierFromStoredMessage(sourceMessage); + if (waitContinuationCarrier) { + await assertCurrentWaitContinuationCarrier(this.opts.waitTaskStore, waitContinuationCarrier, { threadId, - catId, userId, - message, - messageId, - sender, - priority, - policy?.sourceCategory, - policy?.suggestedSkill, - policy?.coalesceKey, - false, - ownerAuthProvenance, - ); - } - - // F185 AC-2: atomic thread-level acquire — TOCTOU-safe - const controller = invocationTracker.tryStartThread(threadId, catId, userId, [catId]); - if (!controller) { - return this.enqueueWhileActive( - threadId, catId, - userId, - message, - messageId, - sender, - priority, - policy?.sourceCategory, - policy?.suggestedSkill, - policy?.coalesceKey, - false, - ownerAuthProvenance, - ); - } - - const releaseDirectAdmission = (): void => { - invocationTracker.complete(threadId, catId, controller); - }; - const rejectDirectAdmission = (): void => { - releaseDirectAdmission(); - this.opts.queueProcessor?.onInvocationComplete(threadId, catId, 'failed', undefined, []).catch(() => { - /* best-effort: release any queued work after rejected direct admission */ }); - }; - - let admission: DirectInvocationAdmission; - try { - admission = await this.admitDirectInvocation(threadId, catId, userId, messageId); - } catch (err) { - rejectDirectAdmission(); - this.opts.log.error( - { err, threadId, catId, messageId }, - '[ConnectorInvokeTrigger] Durable invocation admission failed', - ); - throw err; - } - - if (admission.kind === 'acknowledged') { - releaseDirectAdmission(); - return 'dispatched'; } - - // AC-2+3: dispatch with acquired controller and atomically claimed durable record. - // A running status is only the exclusive claim. Do not report `dispatched` - // until the executor persists proof that router execution actually started. - const executionStartReceipt = createExecutionStartReceipt(); - this.executeInBackground( - threadId, - catId, - userId, - message, - messageId, - admission.invocationId, - contentBlocks, - policy?.sourceCategory, - policy?.suggestedSkill, - ownerAuthProvenance, - sender, - controller, - executionStartReceipt, - ).catch((err) => { - executionStartReceipt.reject(err); - this.opts.log.error(`[ConnectorInvokeTrigger] Unhandled: ${err instanceof Error ? err.message : String(err)}`); - }); - await executionStartReceipt.promise; - return 'dispatched'; - } - - private isExactConnectorRecord( - record: InvocationRecord | null, - expected: { - threadId: string; - userId: string; - catId: CatId; - messageId: string; - actionLeaseCarrier: InvocationActionLeaseCarrier; - waitContinuationCarrier?: WaitContinuationCarrierV1; - }, - ): record is InvocationRecord { - return ( - record !== null && - record.threadId === expected.threadId && - record.userId === expected.userId && - record.intent === 'execute' && - record.idempotencyKey === `connector-${expected.messageId}` && - record.targetCats.length === 1 && - record.targetCats[0] === expected.catId && - actionLeaseCarriersMatch(record.actionLeaseCarrier, expected.actionLeaseCarrier) && - waitContinuationCarriersMatch(record.waitContinuationCarrier, expected.waitContinuationCarrier) - ); - } - - private async admitDirectInvocation( - threadId: string, - catId: CatId, - userId: string, - messageId: string, - ): Promise { - const sourceMessage = await this.opts.messageStore?.getById(messageId); - const waitContinuationCarrier = waitContinuationCarrierFromStoredMessage(sourceMessage); const actionLeaseAdmission = hasManagedCommandWakeActionLeaseRef(sourceMessage) ? await resolveManagedCommandWakeActionLeaseAdmission( sourceMessage, @@ -443,222 +83,47 @@ export class ConnectorInvokeTrigger { this.opts.actionSuccessorLeaseStore, ) : ({ actionLeaseCarrier: { kind: 'none' } } as const); - // Admission is not accepted until the idempotent record exists and exactly - // one worker has claimed queued -> running in the shared store. - const createResult = await this.opts.invocationRecordStore.create({ + const coalesceKey = policy?.coalesceKey; + const sourceCategory = policy?.sourceCategory; + const idempotencyKey = actionLeaseAdmission.actionSuccessorFence + ? `action-successor:${actionLeaseAdmission.actionSuccessorFence.leaseId}:${actionLeaseAdmission.actionSuccessorFence.generation}:${catId}` + : coalesceKey + ? `connector:${sourceCategory ?? 'generic'}:${coalesceKey}${ + waitContinuationCarrier + ? `:wait:${waitContinuationCarrier.waitId}:${waitContinuationCarrier.outcomeId}` + : '' + }` + : undefined; + const result = await this.opts.invocationQueue.enqueueExistingMessageDurable(messageStore, messageId, { threadId, userId, - targetCats: [catId], - intent: 'execute', - idempotencyKey: `connector-${messageId}`, - actionLeaseCarrier: actionLeaseAdmission.actionLeaseCarrier, - ...(waitContinuationCarrier ? { waitContinuationCarrier } : {}), - }); - const invocationId = createResult.invocationId; - let expectedInitialStatus: Extract = 'queued'; - - if (createResult.outcome === 'duplicate') { - const existingRecord = await this.opts.invocationRecordStore.get(invocationId); - if ( - !this.isExactConnectorRecord(existingRecord, { - threadId, - userId, - catId, - messageId, - actionLeaseCarrier: actionLeaseAdmission.actionLeaseCarrier, - ...(waitContinuationCarrier ? { waitContinuationCarrier } : {}), - }) - ) { - this.opts.log.warn( - { threadId, catId, messageId, invocationId, status: 'missing' }, - '[ConnectorInvokeTrigger] Duplicate invocation identity was not accepted', - ); - throw new Error( - `Duplicate connector admission rejected: existing invocation ${invocationId} is missing or mismatched`, - ); - } - - const recoveryStatus = classifyInvocationRecoveryStatus(existingRecord.status); - switch (recoveryStatus) { - case 'replayable': - expectedInitialStatus = existingRecord.status as Extract; - this.opts.log.warn( - { threadId, catId, messageId, invocationId, status: existingRecord.status }, - '[ConnectorInvokeTrigger] Recovering replayable duplicate before acknowledging dispatch', - ); - break; - case 'in_flight': - if (!hasExecutionStartReceipt(existingRecord)) { - this.opts.log.warn( - { threadId, catId, messageId, invocationId, status: existingRecord.status }, - '[ConnectorInvokeTrigger] Running duplicate has no execution-start receipt', - ); - throw new Error( - `Duplicate connector admission rejected: existing invocation ${invocationId} is running without execution-start receipt`, - ); - } - this.opts.log.info( - { threadId, catId, messageId, invocationId, status: existingRecord.status }, - '[ConnectorInvokeTrigger] Duplicate invocation already accepted', - ); - return { kind: 'acknowledged', invocationId, status: 'running' }; - case 'completed': - this.opts.log.info( - { threadId, catId, messageId, invocationId, status: existingRecord.status }, - '[ConnectorInvokeTrigger] Duplicate invocation already accepted', - ); - return { - kind: 'acknowledged', - invocationId, - status: existingRecord.status as Extract, - }; - case 'terminal': - this.opts.log.warn( - { threadId, catId, messageId, invocationId, status: existingRecord.status }, - '[ConnectorInvokeTrigger] Duplicate invocation was not accepted', - ); - throw new Error( - `Duplicate connector admission rejected: existing invocation ${invocationId} is ${existingRecord.status}`, - ); - } - } - - const claimedRecord = await this.opts.invocationRecordStore.update(invocationId, { - userMessageId: messageId, - status: 'running', - expectedStatus: expectedInitialStatus, - ...(expectedInitialStatus === 'failed' ? { error: '' } : {}), - }); - if (claimedRecord) return { kind: 'execute', invocationId, expectedInitialStatus }; - - const currentRecord = await this.opts.invocationRecordStore.get(invocationId); - switch (currentRecord?.status) { - case 'running': - if (!hasExecutionStartReceipt(currentRecord)) { - this.opts.log.warn( - { threadId, catId, messageId, invocationId, status: currentRecord.status }, - '[ConnectorInvokeTrigger] Invocation claim winner has not started execution', - ); - throw new Error( - `Duplicate connector admission rejected: existing invocation ${invocationId} is running without execution-start receipt`, - ); - } - this.opts.log.info( - { threadId, catId, messageId, invocationId, status: currentRecord.status }, - '[ConnectorInvokeTrigger] Another worker accepted the invocation', - ); - return { kind: 'acknowledged', invocationId, status: currentRecord.status }; - case 'succeeded': - this.opts.log.info( - { threadId, catId, messageId, invocationId, status: currentRecord.status }, - '[ConnectorInvokeTrigger] Another worker accepted the invocation', - ); - return { kind: 'acknowledged', invocationId, status: currentRecord.status }; - } - - const status = currentRecord ? currentRecord.status : 'missing'; - this.opts.log.warn( - { threadId, catId, messageId, invocationId, status }, - '[ConnectorInvokeTrigger] Invocation claim was not accepted', - ); - throw new Error( - `Connector invocation ${invocationId} could not be claimed from ${expectedInitialStatus} (status: ${status})`, - ); - } - - private async enqueueWhileActive( - threadId: string, - catId: CatId, - userId: string, - message: string, - messageId: string, - sender?: { id: string; name?: string }, - priority: 'urgent' | 'normal' = 'normal', - sourceCategory?: string, - suggestedSkill?: string, - coalesceKey?: string, - autoExecute = false, - requestedOwnerAuthProvenance: OwnerAuthProvenance = 'unknown', - ): Promise<'full' | 'enqueued'> { - const { invocationQueue, socketManager, log } = this.opts; - - const sourceMessage = await this.opts.messageStore?.getById(messageId); - const waitContinuationCarrier = waitContinuationCarrierFromStoredMessage(sourceMessage); - - if (invocationQueue.hasEntryWithMessageId(threadId, messageId)) { - log.info( - { threadId, messageId }, - '[ConnectorInvokeTrigger] Duplicate connector message already queued, skipping', - ); - return 'enqueued'; - } - - const actionLeaseAdmission = hasManagedCommandWakeActionLeaseRef(sourceMessage) - ? await resolveManagedCommandWakeActionLeaseAdmission( - sourceMessage, - { threadId, catId, tenantScope: userId }, - this.opts.actionSuccessorLeaseStore, - ) - : ({ actionLeaseCarrier: { kind: 'none' } } as const); - const actionLeaseQueueKey = actionLeaseAdmission.actionSuccessorFence - ? actionSuccessorCarrierKey(actionLeaseAdmission.actionSuccessorFence, catId) - : undefined; - - // Once Queue custody exists it is canonical, including a legacy missing - // value that must stay unknown. The private producer carrier is used only - // for the first admission of a source message with no custody yet. - const ownerAuthProvenance = sourceMessage?.queueCustody - ? normalizeOwnerAuthProvenance(sourceMessage.queueCustody.ownerAuthProvenance) - : requestedOwnerAuthProvenance; - const result = invocationQueue.enqueue({ - threadId, - userId, - ownerAuthProvenance, + sourceId: idempotencyKey ?? messageId, + kind: 'conversation_input', + from: messageFrom(sourceMessage), + ownerAuthProvenance: normalizeOwnerAuthProvenance(policy?.ownerAuthProvenance), + ...(idempotencyKey ? { idempotencyKey } : {}), content: message, messageId, - ...(actionLeaseQueueKey - ? { - idempotencyKey: actionLeaseQueueKey, - dedupeProcessing: true, - } - : coalesceKey - ? { - idempotencyKey: `connector:${sourceCategory ?? 'generic'}:${coalesceKey}${ - waitContinuationCarrier - ? `:wait:${waitContinuationCarrier.waitId}:${waitContinuationCarrier.outcomeId}` - : '' - }`, - dedupeProcessing: false, - } - : {}), - source: 'connector', targetCats: [catId], intent: 'execute', - priority, - autoExecute, - ...(sourceCategory - ? { sourceCategory: sourceCategory as 'ci' | 'review' | 'conflict' | 'scheduled' | 'a2a' | 'issue' } - : {}), - ...(sender ? { senderMeta: sender } : {}), - ...(suggestedSkill ? { suggestedSkill } : {}), + priority: policy?.priority ?? 'normal', + autoExecute: policy?.forceQueue === true, + ...(sourceCategory ? { sourceCategory } : {}), + ...(policy?.suggestedSkill ? { suggestedSkill: policy.suggestedSkill } : {}), ...(waitContinuationCarrier ? { waitContinuationCarrier } : {}), ...(actionLeaseAdmission.actionSuccessorFence ? { actionSuccessorFence: actionLeaseAdmission.actionSuccessorFence } : {}), }); - if (result.outcome === 'full') { - const fullQueue = await enrichQueueEntries( - invocationQueue.list(threadId, userId), - this.opts.messageStore ?? null, - ); - socketManager.emitToUser(userId, 'queue_full_warning', { + const fullQueue = await enrichQueueEntries(this.opts.invocationQueue.list(threadId, userId), messageStore); + this.opts.socketManager.emitToUser(userId, 'queue_full_warning', { threadId, source: 'connector', - queueSize: invocationQueue.size(threadId, userId), + queueSize: this.opts.invocationQueue.size(threadId, userId), queue: fullQueue, }); - socketManager.broadcastAgentMessage( + this.opts.socketManager.broadcastAgentMessage( { type: 'system_info', catId: getDefaultCatId(), @@ -667,785 +132,18 @@ export class ConnectorInvokeTrigger { }, threadId, ); - log.warn({ threadId, catId, userId }, '[ConnectorInvokeTrigger] Queue full, connector message not enqueued'); + this.opts.log.warn({ threadId, catId, userId }, '[ConnectorInvokeTrigger] Queue full'); return 'full'; } - - if (result.entry) { - // Initial admission stores the exact source atomically; coalesced replays - // still append their distinct message IDs to the canonical carrier. - invocationQueue.backfillMessageId(threadId, userId, result.entry.id, messageId); - const persistedEntry = invocationQueue.getEntrySnapshot(threadId, userId, result.entry.id); - if (persistedEntry && sourceMessage?.deliveryStatus === 'queued' && !sourceMessage.queueCustody) { - const initialized = await this.opts.messageStore?.initializeQueueCustody( - messageId, - createInitialQueuedMessageCustody(persistedEntry), - ); - if (initialized?.kind !== 'initialized' && initialized?.kind !== 'existing') { - throw new Error(`connector Queue custody initialization failed for ${messageId}: ${initialized?.kind}`); - } - } else if ( - persistedEntry && - sourceMessage?.deliveryStatus === 'queued' && - sourceMessage.queueCustody && - sourceMessage.queueCustody.entryId !== persistedEntry.id - ) { - try { - if (!this.opts.queueCustodyCoordinator) { - throw new Error('Queue custody coordinator unavailable for verified replacement'); - } - await this.opts.queueCustodyCoordinator.transferEntryCustody(persistedEntry, { - kind: 'verified', - previousEntryId: sourceMessage.queueCustody.entryId, - replacementEntryId: persistedEntry.id, - sourceMessageId: messageId, - }); - } catch (error) { - if (!result.deduped) invocationQueue.rollbackEnqueue(threadId, userId, persistedEntry.id); - throw error; - } - } - } - await emitQueueUpdated( - socketManager, + this.opts.socketManager, userId, threadId, - invocationQueue.list(threadId, userId), - this.opts.messageStore ?? null, + this.opts.invocationQueue.list(threadId, userId), + messageStore, result.outcome, ); - log.info( - { threadId, catId, outcome: result.outcome }, - '[ConnectorInvokeTrigger] Queued (active invocation running)', - ); - return result.outcome; - } - - /** - * A recovery caller can observe a speculative replacement row while the first - * caller is still committing its durable rebind. Only the caller that sees the - * exact custody owner may publish that row to QueueProcessor. - */ - private async isQueueEntryCustodyReady(entry: QueueEntry, sourceMessageId: string): Promise { - if (!this.opts.messageStore) return true; - - let sourceMessage: StoredMessage | null; - try { - sourceMessage = await this.opts.messageStore.getById(sourceMessageId); - } catch (error) { - this.opts.log.warn( - { err: error, threadId: entry.threadId, queueEntryId: entry.id, sourceMessageId }, - '[ConnectorInvokeTrigger] Queue custody readiness lookup failed; deferring auto-execution', - ); - return false; - } - if (!sourceMessage?.queueCustody) return true; - if (sourceMessage.queueCustody.status === 'terminal') return false; - - const targetCarriers = sourceMessage.queueCustody.carrierByTargetCatId; - return targetCarriers - ? entry.targetCats.every((catId) => targetCarriers[catId]?.entryId === entry.id) - : sourceMessage.queueCustody.entryId === entry.id; - } - - private async executeInBackground( - threadId: string, - catId: CatId, - userId: string, - message: string, - messageId: string, - existingInvocationId?: string, - contentBlocks?: readonly MessageContent[], - sourceCategory?: ConnectorTriggerPolicy['sourceCategory'], - suggestedSkill?: string, - ownerAuthProvenance: OwnerAuthProvenance = 'unknown', - sender?: { id: string; name?: string }, - preAcquiredController?: AbortController, - executionStartReceipt?: ExecutionStartReceipt, - ): Promise { - const { router, socketManager, invocationRecordStore, invocationTracker, invocationQueue, log } = this.opts; - const targetCats: CatId[] = [catId]; - let finalStatus: 'succeeded' | 'failed' | 'canceled' = 'failed'; - const terminalDispositions = new PerCatTerminalDispositionCollector({ - targetCatIds: targetCats, - isCanceled: (completedCatId) => invocationTracker.getSlotState?.(threadId, completedCatId) === 'canceled', - }); - - // R1-P1 fix: move controller before try so finally always releases it (even if create() throws) - const controller = preAcquiredController ?? invocationTracker.start(threadId, catId, userId, targetCats); - - const HEARTBEAT_INTERVAL_MS = 30_000; - let heartbeatInterval: ReturnType | undefined; - let invocationId: string | undefined; - let executionStartRecorded = false; - // R4 fix: hoist above try so catch can await it for correct failure cleanup - // (onStreamEnd → cleanupPlaceholders, per messages.ts cleanupStreamingOnFailure). - let streamStartPromise: Promise | undefined; - - try { - // ① InvocationRecord was created and atomically claimed by trigger() before - // it reported `dispatched`. QueueProcessor owns the separate queued-admission path. - if (!existingInvocationId) { - throw new Error('Connector invocation reached execution without a durable invocation record'); - } - const createResult = { outcome: 'created' as const, invocationId: existingInvocationId }; - - invocationId = createResult.invocationId; - invocationTracker.bindExecutionId?.(threadId, targetCats, controller, invocationId); - - if (controller?.signal.aborted) { - if (controller.signal.reason === 'cancel_all') { - // cancelAll may win after tracker reservation but before durable admission, - // when it can only arm an ID-less slot fence. Bind the exact record identity - // now so this terminal can consume that fence without making it wildcard. - this.opts.queueProcessor?.bindAutoResumeSuppressionExecution(threadId, catId, invocationId); - } - finalStatus = 'canceled'; - await invocationRecordStore.update(invocationId, { status: 'canceled', expectedStatus: 'running' }); - executionStartReceipt?.reject(new Error('Connector invocation canceled before execution started')); - log.warn(`[ConnectorInvokeTrigger] Thread ${threadId} is being deleted, skipping`); - return; - } - - heartbeatInterval = setInterval(() => { - socketManager.broadcastToRoom(`thread:${threadId}`, 'heartbeat', { threadId, timestamp: Date.now() }); - }, HEARTBEAT_INTERVAL_MS); - - // #768: Defer intent_mode broadcast until CLI produces first event. - let intentModeBroadcast = false; - - // ② Run routeExecution and broadcast each agent message - const cursorBoundaries = new Map(); - const persistenceContext: PersistenceContext = { failed: false, errors: [] }; - const collectedUsage = new Map(); - const collectedTextParts: string[] = []; - - // ISSUE-9: Track per-turn content for individual outbound delivery - // Cloud-P1-4 fix: use ordered array (not Map) to preserve A→B→A turn boundaries - const outboundTurns: Array<{ - catId: string; - textParts: string[]; - richBlocks?: PersistenceContext['richBlocks']; - }> = []; - let currentTurnCatId: string | undefined; - - // Phase 4: Start streaming placeholder on external platforms - // Fire-and-forget for the loop, but save the promise so onStreamEnd can await it - // to prevent race (onStreamEnd before onStreamStart finishes registering sessions). - if (this.opts.streamingHook) { - streamStartPromise = this.opts.streamingHook - .onStreamStart(threadId, catId, createResult.invocationId, sender) - .catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.onStreamStart failed'); - }); - } - - // F151: Deliver per-cat turns inside the loop to preserve ordering when - // post_message callbacks from later cats interleave with earlier outboundTurns. - const deliveredTurnIndices = new Set(); - const DELIVER_TIMEOUT_MS = this.opts.deliverTimeoutMs ?? 10_000; - - // Start threadMeta lookup early — resolved lazily when first delivery needs it. - let threadMeta: ThreadMeta | undefined; - let threadMetaPromise: Promise | undefined; - if (this.opts.outboundHook && this.opts.threadMetaLookup) { - const rawResult = this.opts.threadMetaLookup(threadId); - if (rawResult) { - const LOOKUP_TIMEOUT_MS = 2000; - threadMetaPromise = Promise.race([ - Promise.resolve(rawResult).catch((err: unknown) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] threadMetaLookup late rejection'); - return undefined; - }), - new Promise((resolve) => setTimeout(() => resolve(undefined), LOOKUP_TIMEOUT_MS)), - ]); - } - } - - // F140 Phase C: suggestedSkill flows via promptTags → SystemPromptBuilder (hint, not directive) - const promptTags: string[] = suggestedSkill ? [`skill:${suggestedSkill}`] : []; - const intent = { intent: 'execute' as const, explicit: false, promptTags }; - let turnCustodyWake: TurnCustodyWakeProvenance = { - kind: 'legacy', - reason: 'carrier_missing', - ...(sourceCategory ? { sourceCategory } : {}), - }; - if (this.opts.messageStore) { - const durableInvocation = await invocationRecordStore.get(invocationId); - if (!durableInvocation) { - throw new Error(`Connector invocation ${invocationId} disappeared before wake provenance resolution`); - } - turnCustodyWake = await resolveQueueTurnCustodyWake( - { - threadId, - messageId, - source: 'connector', - sourceCategory, - targetCats, - ...(durableInvocation.waitContinuationCarrier - ? { waitContinuationCarrier: durableInvocation.waitContinuationCarrier } - : {}), - }, - this.opts.messageStore, - ); - } - - let memoryCueOpportunitySeeds: MemoryCueOpportunitySeed[] = []; - let asrPersonMemoryScenes: Awaited> = []; - if (this.opts.messageStore) { - try { - memoryCueOpportunitySeeds = await readTrustedConnectorMemoryCueSeeds({ - entrySource: 'connector', - messageId, - expectedThreadId: threadId, - expectedUserId: userId, - expectedTargetCatIds: targetCats, - messageStore: this.opts.messageStore, - }); - } catch (err) { - log.warn({ err, threadId, messageId }, '[F287] direct connector Cue carrier read failed closed'); - } - try { - const triggerMessage = await this.opts.messageStore.getById(messageId); - if (triggerMessage) { - asrPersonMemoryScenes = await bindAsrPersonMemoryReentryFromSchedulerMessage({ - triggerMessage, - ownerUserId: userId, - threadId, - targetCatId: targetCats[0], - messageStore: this.opts.messageStore, - }); - } - } catch (err) { - log.warn({ err, threadId, messageId }, '[F276] direct connector re-entry carrier read failed closed'); - } - } - - for await (const msg of router.routeExecution(userId, message, threadId, messageId, targetCats, intent, { - ownerAuthProvenance, - humanDispositionInvocationOrigin: 'connector', - turnCustodyWake, - turnCustodyWakeForCat: (catId) => retargetTurnCustodyWake(turnCustodyWake, catId), - ...(memoryCueOpportunitySeeds.length > 0 ? { memoryCueOpportunitySeeds } : {}), - ...(asrPersonMemoryScenes.length > 0 ? { asrPersonMemoryScenes } : {}), - ...(contentBlocks ? { contentBlocks } : {}), - ...(controller?.signal ? { signal: controller.signal } : {}), - queueHasQueuedMessages: (tid: string) => invocationQueue.hasQueuedNonAgentForThread(tid), - getQueuedFreshnessMessagesForCat: (tid: string, uid: string, catId: string, parentInvocationId?: string) => - invocationQueue.getQueuedFreshnessMessagesForCat(tid, uid, catId, { parentInvocationId }), - deferA2AEnqueue: (e) => invocationQueue.enqueue({ ...e, ownerAuthProvenance }), - freshnessReinvokeEnqueue: (entry) => { - const { freshnessContext: _freshnessContext, ...queueFields } = entry; - return invocationQueue.enqueue({ ...queueFields, ownerAuthProvenance }); - }, - hasQueuedOrActiveAgentForCat: (tid: string, catId: string) => - invocationQueue.hasActiveOrQueuedAgentForCat(tid, catId), - hasPendingForCat: (tid: string, uid: string, catId: string) => - invocationQueue.hasPendingForCat(tid, catId, { userId: uid }), - ...createA2ASlotTrackingBridge(invocationTracker, controller, createResult.invocationId), - cursorBoundaries, - persistenceContext, - parentInvocationId: createResult.invocationId, - onPromptMessagesExposed: (input) => - this.opts.queueProcessor?.markPromptMessagesSeen(input) ?? Promise.resolve(), - // F222 P1: Connector-triggered execution is not user-origin — suppress frustration detection - frustrationAutoIssueEligible: false, - // #949 P2: Connector-sourced flows have no ball-pass expectation — suppress verdict warning - verdictPassWarningEnabled: false, - })) { - const durableChildStart = parseDurableChildExecutionStart(msg, createResult.invocationId); - if (!executionStartRecorded && durableChildStart) { - // invokeSingleCat yields this exact child identity only after its - // TurnExecutionStore.createRunning() succeeds. Pre-invocation router - // events (degradation/context briefing) are deliberately ignored. - await requireInvocationRecordUpdate({ - store: invocationRecordStore, - invocationId: createResult.invocationId, - update: { - executionStartedAt: durableChildStart.startedAt, - expectedStatus: 'running', - }, - writer: 'connector execution-start receipt', - }); - log.debug( - { - threadId, - invocationId: createResult.invocationId, - childInvocationId: durableChildStart.childInvocationId, - }, - '[ConnectorInvokeTrigger] Durable child execution started', - ); - executionStartRecorded = true; - executionStartReceipt?.accept(); - } - // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. - if (!intentModeBroadcast) { - socketManager.broadcastToRoom(`thread:${threadId}`, 'intent_mode', { - threadId, - mode: 'execute', - targetCats, - invocationId: createResult.invocationId, - }); - intentModeBroadcast = true; - } - // F39 bugfix: stop broadcasting after cancel (drain pipe buffer silently) - if (controller?.signal.aborted) break; - terminalDispositions.observe(msg); - if (isTerminalDispositionEvent(msg) && msg.catId) { - invocationTracker.completeSlot?.(threadId, msg.catId, controller); - } - if (msg.type === 'done' && msg.catId) { - if (msg.metadata?.usage) { - collectedUsage.set(msg.catId, mergeTokenUsage(collectedUsage.get(msg.catId), msg.metadata.usage)); - } - // ISSUE-9: snapshot richBlocks for current turn before next cat overwrites - // Cloud-P1-5 fix: only reuse turn if still open (currentTurnCatId matches) - if (persistenceContext.richBlocks) { - const turn = outboundTurns[outboundTurns.length - 1]; - if (turn && turn.catId === msg.catId && currentTurnCatId === msg.catId) { - turn.richBlocks = [...persistenceContext.richBlocks]; - } else { - // Cat had richBlocks but no text — create a turn - outboundTurns.push({ catId: msg.catId, textParts: [], richBlocks: [...persistenceContext.richBlocks] }); - } - persistenceContext.richBlocks = undefined; - } - // Close current turn — next text message starts a new turn - currentTurnCatId = undefined; - // F151: Deliver completed cat's turns immediately to preserve ordering - // when post_message callbacks from later cats fire during the loop. - if (this.opts.outboundHook) { - if (threadMetaPromise) { - threadMeta = await threadMetaPromise; - threadMetaPromise = undefined; - } - for (let i = 0; i < outboundTurns.length; i++) { - if (deliveredTurnIndices.has(i)) continue; - const turn = outboundTurns[i]; - if (turn.catId !== msg.catId) continue; - if (!isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[turn.catId])) continue; - const turnContent = turn.textParts.join(''); - if (!turnContent && !turn.richBlocks?.length) continue; - try { - await Promise.race([ - this.opts.outboundHook.deliver( - threadId, - turnContent, - turn.catId as CatId, - turn.richBlocks, - threadMeta, - undefined, - messageId, - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS), - ), - ]); - deliveredTurnIndices.add(i); - } catch (err) { - log.error( - { err, threadId, catId: turn.catId }, - '[ConnectorInvokeTrigger] Mid-loop delivery failed, will retry in final phase', - ); - } - } - } - } - // Collect text content for outbound delivery (final-only) - if (msg.type === 'text' && typeof msg.content === 'string') { - collectedTextParts.push(msg.content); - // ISSUE-9: per-turn text collection (new turn on catId change or after done) - if (msg.catId) { - if (msg.catId !== currentTurnCatId) { - outboundTurns.push({ catId: msg.catId, textParts: [] }); - currentTurnCatId = msg.catId; - } - outboundTurns[outboundTurns.length - 1].textParts.push(msg.content); - } - // Phase 4: Stream accumulated text to external platforms - if (this.opts.streamingHook) { - const accumulated = collectedTextParts.join(''); - this.opts.streamingHook.onStreamChunk(threadId, accumulated, createResult.invocationId).catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.onStreamChunk failed'); - }); - } - } - // F194 Phase Z9 (砚砚 R1 P1-2): unified visible turn stamp via helper. - socketManager.broadcastAgentMessage( - { ...msg, ...stampVisibleTurn(createResult.invocationId, msg.invocationId) }, - threadId, - ); - } - - if (!executionStartRecorded) { - throw new Error('Connector router ended before producing durable execution-start evidence'); - } - - // ③ Finalize: abort guard → persistence check → ack + succeeded - // F39 P1 fix (砚砚 R1): abort guard after loop — same pattern as messages.ts. - // When signal aborted and generator ends normally, break exits loop but - // post-loop code would still run ack+succeeded without this guard. - if (controller?.signal.aborted) { - finalStatus = 'canceled'; - await invocationRecordStore.update(createResult.invocationId, { - status: 'canceled', - }); - // Skip ack/succeeded — let finally handle cleanup - } else if (persistenceContext.failed) { - const errorDetail = persistenceContext.errors.map((e) => `${e.catId}: ${e.error}`).join('; '); - await invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - error: `Connector invoke: message delivered but persistence failed: ${errorDetail}`, - }); - } else { - await router.ackCollectedCursors(userId, threadId, cursorBoundaries); - await requireInvocationRecordUpdate({ - store: invocationRecordStore, - invocationId: createResult.invocationId, - update: { - status: 'succeeded', - successfulCatIds: terminalDispositions.getSuccessfulCatIds() as CatId[], - ...(collectedUsage.size > 0 - ? { - usageByCat: Object.fromEntries(collectedUsage), - } - : {}), - }, - writer: 'connector invoke trigger', - }); - finalStatus = 'succeeded'; - - // ④ Outbound delivery: send final text + rich blocks to bound external chats - const deliverableTurns = outboundTurns.filter((turn) => - isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[turn.catId]), - ); - const hasKnownUndeliverableOutput = Object.values(persistenceContext.outputCommitDecisions ?? {}).some( - (decision) => !isConnectorDeliverable(decision), - ); - const finalContent = - outboundTurns.length > 0 - ? deliverableTurns.flatMap((turn) => turn.textParts).join('') - : collectedTextParts.join(''); - - // Phase 4: Finalize streaming — ensure start completed before ending - if (this.opts.streamingHook) { - if (streamStartPromise) { - const STREAM_START_TIMEOUT_MS = 5000; - await Promise.race([ - streamStartPromise, - new Promise((resolve) => setTimeout(resolve, STREAM_START_TIMEOUT_MS)), - ]); - } - const outputDecisionEntries = Object.entries(persistenceContext.outputCommitDecisions ?? {}); - const supersededOutput = outputDecisionEntries.find( - ([, decision]) => decision.kind === 'superseded_positive_stale', - ); - const blockedOutput = outputDecisionEntries.find(([, decision]) => decision.kind === 'blocked_known_closure'); - if (blockedOutput?.[1].kind === 'blocked_known_closure' && this.opts.streamingHook.onClosureBlocked) { - await this.opts.streamingHook - .onClosureBlocked(threadId, blockedOutput[0] as CatId, blockedOutput[1].reason, createResult.invocationId) - .catch((err) => log.warn({ err, threadId }, '[ConnectorInvokeTrigger] blocked projection failed')); - } else if ( - supersededOutput?.[1].kind === 'superseded_positive_stale' && - this.opts.streamingHook.onClosureCatchingUp - ) { - await this.opts.streamingHook - .onClosureCatchingUp(threadId, supersededOutput[0] as CatId, createResult.invocationId) - .catch((err) => log.warn({ err, threadId }, '[ConnectorInvokeTrigger] catch projection failed')); - } else { - await this.opts.streamingHook - .onStreamEnd(threadId, finalContent, createResult.invocationId) - .catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.onStreamEnd failed'); - }); - } - } - - // R1-P1 fix: restore OR condition — richBlocks-only replies must also trigger delivery - const hasContent = finalContent.length > 0 || deliverableTurns.some((turn) => turn.richBlocks?.length); - log.info( - { - threadId, - hasOutboundHook: !!this.opts.outboundHook, - hasContent, - textPartsCount: collectedTextParts.length, - outboundTurnsCount: outboundTurns.length, - finalContentLen: collectedTextParts.join('').length, - }, - '[ConnectorInvokeTrigger] Outbound delivery check', - ); - if (this.opts.outboundHook && hasContent) { - // Resolve threadMeta if not yet done (no mid-loop delivery happened) - if (threadMetaPromise) { - threadMeta = await threadMetaPromise; - threadMetaPromise = undefined; - } - - // ISSUE-9 + Cloud-P1-4: deliver per-turn (ordered, supports A→B→A ping-pong) - // F151: skip turns already delivered mid-loop - const nonEmptyTurns = deliverableTurns.filter( - (turn) => - !deliveredTurnIndices.has(outboundTurns.indexOf(turn)) && - (turn.textParts.length > 0 || (turn.richBlocks && turn.richBlocks.length > 0)), - ); - - let deliveryFailed = false; - // Cloud-R4-P2: keep references to in-flight deliver promises so we can - // schedule late-success cleanup when a delivery times out but later succeeds. - const inflightDeliverPromises: Promise[] = []; - - // BUG-5 (2026-03-25): iLink context_token is reusable — SINGLE_TOKEN_CONNECTORS - // merge logic removed. Each turn now delivers independently for all connectors. - if (nonEmptyTurns.length > 1) { - for (const turn of nonEmptyTurns) { - const turnContent = turn.textParts.join(''); - const deliverPromise = this.opts.outboundHook.deliver( - threadId, - turnContent, - turn.catId as CatId, - turn.richBlocks, - threadMeta, - undefined, - messageId, - ); - inflightDeliverPromises.push(deliverPromise); - try { - await Promise.race([ - deliverPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS), - ), - ]); - } catch (err) { - deliveryFailed = true; - log.error({ err, threadId, catId: turn.catId }, '[ConnectorInvokeTrigger] Outbound delivery error'); - } - } - } else if (nonEmptyTurns.length === 1) { - const turn = nonEmptyTurns[0]; - const richBlocks = persistenceContext.richBlocks ?? turn.richBlocks; - const deliverPromise = this.opts.outboundHook.deliver( - threadId, - finalContent, - turn.catId as CatId, - richBlocks, - threadMeta, - undefined, - messageId, - ); - inflightDeliverPromises.push(deliverPromise); - try { - await Promise.race([ - deliverPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS), - ), - ]); - } catch (err) { - deliveryFailed = true; - log.error({ err, threadId }, '[ConnectorInvokeTrigger] Outbound delivery error'); - } - } else if (deliveredTurnIndices.size === 0) { - // Fallback: no per-turn delivery happened — deliver all content as one - const richBlocks = persistenceContext.richBlocks; - const deliverPromise = this.opts.outboundHook.deliver( - threadId, - finalContent, - catId, - richBlocks, - threadMeta, - undefined, - messageId, - ); - inflightDeliverPromises.push(deliverPromise); - try { - await Promise.race([ - deliverPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS), - ), - ]); - } catch (err) { - deliveryFailed = true; - log.error({ err, threadId }, '[ConnectorInvokeTrigger] Outbound delivery error'); - } - } - - // Cloud-P1-R2: only cleanup placeholders if ALL deliveries succeeded - if (!deliveryFailed && this.opts.streamingHook?.cleanupPlaceholders) { - await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.cleanupPlaceholders failed'); - }); - } else if (deliveryFailed && this.opts.streamingHook?.cleanupPlaceholders) { - const cleanupHook = this.opts.streamingHook; - const scopedInvocationId = createResult.invocationId; - Promise.allSettled(inflightDeliverPromises).then((results) => { - if (results.every((r) => r.status === 'fulfilled')) { - cleanupHook.cleanupPlaceholders(threadId, scopedInvocationId).catch((err) => { - log.warn( - { err, threadId }, - '[ConnectorInvokeTrigger] Placeholder cleanup failed after late-success delivery', - ); - }); - } - }); - } - } else if (!hasKnownUndeliverableOutput) { - // R6+R7 fix: deliver fallback FIRST (with timeout), then cleanup placeholder - // only on success — preserves "thinking" card if delivery fails (Cloud P2). - // Timeout prevents adapter hang from blocking finally (Cloud P1). - // R7: late-success cleanup mirrors normal content-delivery pattern (lines 641-653). - let silentDeliveryOk = !this.opts.outboundHook; // no hook → proceed to cleanup - let silentDeliverPromise: Promise | undefined; - if (this.opts.outboundHook) { - silentDeliverPromise = this.opts.outboundHook.deliver( - threadId, - '处理完成,但未产生回复内容。', - catId, - undefined, - undefined, - undefined, - messageId, - ); - try { - await Promise.race([ - silentDeliverPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS), - ), - ]); - silentDeliveryOk = true; - } catch (deliverErr) { - log.error({ err: deliverErr, threadId }, '[ConnectorInvokeTrigger] Silent-path outbound delivery failed'); - } - } - if (silentDeliveryOk && this.opts.streamingHook?.cleanupPlaceholders) { - await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.cleanupPlaceholders failed (silent)'); - }); - } else if (silentDeliverPromise && this.opts.streamingHook?.cleanupPlaceholders) { - // R7: timeout fired but delivery may still succeed — defer cleanup to late-success - const cleanupHook = this.opts.streamingHook; - const scopedInvocationId = createResult.invocationId; - silentDeliverPromise - .then(() => { - cleanupHook.cleanupPlaceholders(threadId, scopedInvocationId).catch((err) => { - log.warn( - { err, threadId }, - '[ConnectorInvokeTrigger] Silent late-success placeholder cleanup failed', - ); - }); - }) - .catch(() => { - /* delivery truly failed — thinking card stays as fallback UX */ - }); - } - } - } - - log.info( - `[ConnectorInvokeTrigger] Invocation ${createResult.invocationId} completed for ${catId} in thread ${threadId}`, - ); - } catch (err) { - executionStartReceipt?.reject(err); - const errorMsg = err instanceof Error ? err.message : 'Unknown error'; - log.error(`[ConnectorInvokeTrigger] Invocation failed: ${errorMsg}`); - - // Best-effort status update — don't let this throw mask the original error - if (invocationId) { - try { - await invocationRecordStore.update(invocationId, { - status: 'failed', - error: errorMsg, - }); - } catch (statusErr) { - log.warn( - { err: statusErr, invocationId }, - '[ConnectorInvokeTrigger] invocation status update failed (best-effort)', - ); - } - } - - socketManager.broadcastAgentMessage( - { - type: 'error', - catId: getDefaultCatId(), - error: `Connector invoke failed: ${errorMsg}`, - isFinal: true, - timestamp: Date.now(), - }, - threadId, - ); - - // R4 fix (#873): correct failure cleanup — onStreamEnd transitions sessions - // from active → pendingCleanup; cleanupPlaceholders alone is a no-op on active - // sessions. Matches messages.ts cleanupStreamingOnFailure() sequence. - if (this.opts.streamingHook) { - try { - const STREAM_START_TIMEOUT_MS = 5000; - if (streamStartPromise) { - await Promise.race([streamStartPromise, new Promise((r) => setTimeout(r, STREAM_START_TIMEOUT_MS))]); - } - await this.opts.streamingHook.onStreamEnd(threadId, '', invocationId); - await this.opts.streamingHook.cleanupPlaceholders?.(threadId, invocationId); - } catch (cleanupErr) { - log.warn({ err: cleanupErr, threadId }, '[ConnectorInvokeTrigger] Error-path streaming cleanup failed'); - } - } - - // #873: Deliver error message to external IM platform so user sees a reply (not silence) - // R6 fix: timeout prevents adapter hang from blocking finally (Cloud P1). - if (this.opts.outboundHook) { - const ERROR_DELIVER_TIMEOUT_MS = this.opts.deliverTimeoutMs ?? 10_000; - try { - await Promise.race([ - this.opts.outboundHook.deliver( - threadId, - '抱歉,处理消息时遇到问题,请稍后重试。', - catId, - undefined, - undefined, - undefined, - messageId, - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('deliver timeout')), ERROR_DELIVER_TIMEOUT_MS), - ), - ]); - } catch (deliverErr) { - log.error({ err: deliverErr, threadId }, '[ConnectorInvokeTrigger] Error-path outbound delivery failed'); - } - } - } finally { - if (heartbeatInterval) clearInterval(heartbeatInterval); - invocationTracker.complete(threadId, catId, controller); - // F39 P1 fix: Notify queue processor for auto-dequeue chain - this.opts.queueProcessor - ?.onInvocationComplete( - threadId, - catId, - finalStatus, - invocationId, - finalStatus === 'succeeded' ? terminalDispositions.getSuccessfulCatIds() : [], - false, - terminalDispositions.getTerminalInvocationIdByCatId(), - [], - terminalDispositions.getTerminalConsumptionByInvocationId(), - ) - .catch(() => { - /* best-effort, don't crash background task */ - }); - // F151: Signal adapters that this invocation's delivery batch is complete. - // Fires on both success AND failure — failed invocations must close the task - // immediately instead of waiting for TASK_TIMEOUT_MS (P2-1 review fix). - if (this.opts.streamingHook?.notifyDeliveryBatchDone) { - const threadStillBusy = - invocationTracker.has(threadId) || (this.opts.queueProcessor?.isThreadBusy(threadId) ?? false); - this.opts.streamingHook.notifyDeliveryBatchDone(threadId, !threadStillBusy).catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] notifyDeliveryBatchDone failed'); - }); - } - } + void this.opts.queueProcessor?.requestDrain(threadId).catch(() => {}); + return 'enqueued'; } } diff --git a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts index 48da8f71b9..3c3af6f058 100644 --- a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts +++ b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts @@ -710,7 +710,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T undefined, policy, ); - wakeAccepted = outcome === 'dispatched' || outcome === 'enqueued'; + wakeAccepted = outcome === 'enqueued'; if (!wakeAccepted) { opts.log.error( { taskId: task.id, subjectKey, threadId: wake.threadId, catId: wake.catId, outcome }, diff --git a/packages/api/src/infrastructure/email/deliver-connector-message.ts b/packages/api/src/infrastructure/email/deliver-connector-message.ts index 8e87da4b7d..7fbdb7dd88 100644 --- a/packages/api/src/infrastructure/email/deliver-connector-message.ts +++ b/packages/api/src/infrastructure/email/deliver-connector-message.ts @@ -3,9 +3,6 @@ import type { IMessageStore } from '../../domains/cats/services/stores/ports/Mes export interface ConnectorDeliveryDeps { readonly messageStore: IMessageStore; - readonly socketManager?: { - broadcastToRoom: (room: string, event: string, data: unknown) => void; - }; } export interface ConnectorDeliveryInput { @@ -31,27 +28,21 @@ export async function deliverConnectorMessage( input: ConnectorDeliveryInput, ): Promise { const stored = await deps.messageStore.append({ + from: { + kind: 'external', + connectorId: input.source.connector, + ...(input.source.sender ? { sender: input.source.sender } : {}), + }, threadId: input.threadId, userId: input.userId, - catId: null, content: input.content, source: input.source, mentions: [input.catId as CatId], timestamp: Date.now(), + deliveryStatus: 'queued', ...(input.extra ? { extra: input.extra } : {}), ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}), }); - deps.socketManager?.broadcastToRoom(`thread:${input.threadId}`, 'connector_message', { - threadId: input.threadId, - message: { - id: stored.id, - type: 'connector', - content: input.content, - source: input.source, - timestamp: stored.timestamp, - }, - }); - return { messageId: stored.id, content: input.content }; } diff --git a/packages/api/src/infrastructure/harness-eval/c2-void-hold-sample-evidence.ts b/packages/api/src/infrastructure/harness-eval/c2-void-hold-sample-evidence.ts index 2ff9ed4ce1..682f4a0267 100644 --- a/packages/api/src/infrastructure/harness-eval/c2-void-hold-sample-evidence.ts +++ b/packages/api/src/infrastructure/harness-eval/c2-void-hold-sample-evidence.ts @@ -2,7 +2,7 @@ * F192 Phase D — C2 void-hold per-fire sample evidence extractor. * * Verdict 2026-06-10-eval-a2a-c2-void-hold-samples-build: labeled aggregate counters - * (`{agent_id, thread_system_kind, trigger}`) tell us how many C2 void-hold-hint fires + * (`{agent_id, thread_system_kind, trigger}`) tell us how many C2 void-hold signals * happened but not which messages they were. Per-fire samples close that gap: * route-serial emits a `c2.void_hold_fired` span event at the same point as the * counter `add`, carrying raw IDs that RedactingSpanProcessor HMACs into diff --git a/packages/api/src/infrastructure/harness-eval/domain/eval-domain-trigger-dispatch.ts b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-trigger-dispatch.ts index c067116408..3f73fbe904 100644 --- a/packages/api/src/infrastructure/harness-eval/domain/eval-domain-trigger-dispatch.ts +++ b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-trigger-dispatch.ts @@ -231,7 +231,7 @@ class EvalDomainTriggerDispatchRun { windowKey: this.window.windowKey, dedupeKey: this.dedupeKey, }), - userId: 'scheduler', + userId: this.input.defaultUserId ?? 'default-user', idempotencyKey: this.dedupeKey, }); if (!messageId) throw new Error('scheduled eval delivery returned no message id'); diff --git a/packages/api/src/infrastructure/harness-eval/freshness/freshness-replay-provider.ts b/packages/api/src/infrastructure/harness-eval/freshness/freshness-replay-provider.ts index 15e9d56b11..5f7a1a7bfa 100644 --- a/packages/api/src/infrastructure/harness-eval/freshness/freshness-replay-provider.ts +++ b/packages/api/src/infrastructure/harness-eval/freshness/freshness-replay-provider.ts @@ -1,14 +1,12 @@ import type { FreshnessClosureAggregate, FreshnessSupplementAggregate } from '@cat-cafe/shared'; +import type { InvocationQueue, QueueEntry } from '../../../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { FreshnessClosureStore } from '../../../domains/cats/services/freshness/closure/FreshnessClosureStore.js'; import type { FreshnessAttentionEvent, FreshnessAttentionEventLog, FreshnessEventWindowCoverage, } from '../../../domains/cats/services/freshness/FreshnessAttentionEventLog.js'; -import type { - IMessageStore, - QueueCustodyLifecycleRecord, -} from '../../../domains/cats/services/stores/ports/MessageStore.js'; +import type { IMessageStore, StoredMessage } from '../../../domains/cats/services/stores/ports/MessageStore.js'; import { buildFreshnessReplayReport, deriveFreshnessClosureEvalSnapshot } from './freshness-closure-eval-adapter.js'; import { FRESHNESS_AC_E9_FIXTURE_IDS, loadFreshnessReplayFixture } from './freshness-replay-fixtures.js'; import type { @@ -18,7 +16,10 @@ import type { FreshnessReplaySelector, FreshnessReplaySourceStatus, } from './freshness-replay-types.js'; -import { buildFreshnessWindowedSignals } from './freshness-windowed-signal-report.js'; +import { + buildFreshnessWindowedSignals, + type FreshnessQueueLifecycleRecord, +} from './freshness-windowed-signal-report.js'; import { buildProviderNativeFreshnessCoverage } from './provider-native-freshness-coverage.js'; const DEFAULT_AUTOMATIC_ATTEMPT_LIMIT = 5; @@ -32,7 +33,8 @@ export class FreshnessReplayProviderImpl implements FreshnessReplayProvider { private readonly deps: { store: FreshnessClosureStore; fixtureRoot: string; - queueLifecycleSource?: Pick; + queueLifecycleSource?: Pick; + messageLifecycleSource?: Pick; attentionEventLog?: Pick; }, ) {} @@ -55,8 +57,13 @@ export class FreshnessReplayProviderImpl implements FreshnessReplayProvider { typeof this.deps.store.listAllSupplements === 'function' ? this.deps.store.listAllSupplements() : Promise.reject(new Error('supplement_source_unavailable')), - ownerUserId && this.deps.queueLifecycleSource?.listOwnerQueueCustodyLifecycles - ? this.deps.queueLifecycleSource.listOwnerQueueCustodyLifecycles(ownerUserId) + ownerUserId && this.deps.queueLifecycleSource && this.deps.messageLifecycleSource + ? projectQueueLifecycleRecords( + this.deps.queueLifecycleSource, + this.deps.messageLifecycleSource, + ownerUserId, + selector.windowEndMs, + ) : Promise.reject(new Error('owner_scoped_queue_source_unavailable')), ownerUserId && this.deps.attentionEventLog ? this.deps.attentionEventLog.queryWindowBetween(selector.windowStartMs, selector.windowEndMs, ownerUserId, { @@ -73,9 +80,8 @@ export class FreshnessReplayProviderImpl implements FreshnessReplayProvider { (supplement) => (!ownerUserId || supplement.userId === ownerUserId) && (!threadFilter || threadFilter.has(supplement.threadId)), ); - const queueRecords = fulfilledValue(queueResult, []).filter( - (record) => - (!ownerUserId || record.userId === ownerUserId) && (!threadFilter || threadFilter.has(record.threadId)), + const queueRecords = fulfilledValue(queueResult, []).filter( + (record) => !threadFilter || threadFilter.has(record.threadId), ); const attentionWindow = attentionResult.status === 'fulfilled' @@ -139,6 +145,79 @@ export class FreshnessReplayProviderImpl implements FreshnessReplayProvider { } } +function terminalProjection( + response: StoredMessage | undefined, +): Pick< + FreshnessQueueLifecycleRecord, + 'lastUpdatedAt' | 'handledAt' | 'withdrawnAt' | 'failedAt' | 'terminalState' | 'legacyUntimed' +> | null { + const lifecycle = response?.lifecycle; + if (!response || lifecycle?.kind !== 'response' || lifecycle.status === 'processing') return null; + const terminalAt = lifecycle.completedAt ?? response.timestamp; + if (lifecycle.status === 'completed') { + return { lastUpdatedAt: terminalAt, handledAt: terminalAt, terminalState: true, legacyUntimed: false }; + } + if (lifecycle.status === 'canceled' || (lifecycle.status === 'interrupted' && lifecycle.reason === 'preempted')) { + return { lastUpdatedAt: terminalAt, withdrawnAt: terminalAt, terminalState: true, legacyUntimed: false }; + } + return { lastUpdatedAt: terminalAt, failedAt: terminalAt, terminalState: true, legacyUntimed: false }; +} + +function activeQueueRecord(entry: QueueEntry, targetCatId: string): FreshnessQueueLifecycleRecord { + return { + entryId: entry.id, + targetCatId, + threadId: entry.threadId, + ...(entry.payload.messageId ? { messageId: entry.payload.messageId } : {}), + createdAt: entry.enqueuedAt, + lastUpdatedAt: entry.processingStartedAt ?? entry.claimedAt ?? entry.enqueuedAt, + ...(entry.claimedAt === undefined ? {} : { firstSeenAt: entry.claimedAt }), + terminalState: false, + legacyUntimed: false, + }; +} + +async function projectQueueLifecycleRecords( + queue: Pick, + messages: Pick, + ownerUserId: string, + windowEndMs: number, +): Promise { + const [activeEntries, history] = await Promise.all([ + queue.listOwnerDurableEntries(ownerUserId), + messages.listOwnerMessagesInWindow(ownerUserId, 0, windowEndMs), + ]); + const byId = new Map(history.map((message) => [message.id, message])); + const records = new Map(); + for (const message of history) { + if (message.lifecycle?.kind !== 'input') continue; + for (const dispatch of message.lifecycle.dispatchRefs ?? []) { + const key = `${message.lifecycle.orderKey}\u0000${dispatch.targetId}`; + const terminal = terminalProjection(byId.get(dispatch.statusMessageId)); + records.set(key, { + entryId: message.lifecycle.orderKey, + targetCatId: dispatch.targetId, + threadId: message.threadId, + messageId: message.id, + createdAt: message.timestamp, + ...(dispatch.dispatchedAt === undefined ? {} : { firstSeenAt: dispatch.dispatchedAt }), + ...(terminal ?? { + lastUpdatedAt: dispatch.dispatchedAt ?? message.timestamp, + terminalState: false, + legacyUntimed: dispatch.dispatchedAt === undefined, + }), + }); + } + } + for (const entry of activeEntries) { + for (const targetCatId of entry.targets) { + const key = `${entry.id}\u0000${targetCatId}`; + if (!records.has(key)) records.set(key, activeQueueRecord(entry, targetCatId)); + } + } + return [...records.values()]; +} + function fulfilledValue(result: PromiseSettledResult, fallback: T): T { return result.status === 'fulfilled' ? result.value : fallback; } diff --git a/packages/api/src/infrastructure/harness-eval/freshness/freshness-windowed-signal-report.ts b/packages/api/src/infrastructure/harness-eval/freshness/freshness-windowed-signal-report.ts index 9f778f2d70..2881a8f3f1 100644 --- a/packages/api/src/infrastructure/harness-eval/freshness/freshness-windowed-signal-report.ts +++ b/packages/api/src/infrastructure/harness-eval/freshness/freshness-windowed-signal-report.ts @@ -1,9 +1,7 @@ import type { FreshnessSupplementAggregate } from '@cat-cafe/shared'; import type { FreshnessAttentionEvent } from '../../../domains/cats/services/freshness/FreshnessAttentionEventLog.js'; -import type { QueueCustodyLifecycleRecord } from '../../../domains/cats/services/stores/ports/MessageStore.js'; import type { FreshnessAttentionSignalReport, - FreshnessQueueLifecycle, FreshnessQueueLifecycleReport, FreshnessSupplementLifecycleReport, FreshnessWindowedSignals, @@ -14,190 +12,56 @@ interface Window { endMs: number; } -interface QueueAccumulator { +/** Queue/History join projected by the replay provider; no second durable lifecycle is introduced. */ +export interface FreshnessQueueLifecycleRecord { entryId: string; targetCatId: string; - threadIds: Set; - messageIds: Set; - latestRevision: number; + threadId: string; + messageId?: string; createdAt: number; - updatedAt: number; + lastUpdatedAt: number; firstSeenAt?: number; handledAt?: number; withdrawnAt?: number; failedAt?: number; - seenWithoutTime: boolean; - handledWithoutTime: boolean; - withdrawnWithoutTime: boolean; - failedWithoutTime: boolean; + terminalState: boolean; + legacyUntimed: boolean; } function inWindow(timestamp: number | undefined, window: Window): boolean { return timestamp !== undefined && timestamp >= window.startMs && timestamp < window.endMs; } -function earliest(left: number | undefined, right: number | undefined): number | undefined { - if (left === undefined) return right; - if (right === undefined) return left; - return Math.min(left, right); +function lifecycleEndAt(record: Omit): number | undefined { + return ( + record.handledAt ?? + record.withdrawnAt ?? + record.failedAt ?? + (record.terminalState ? record.lastUpdatedAt : undefined) + ); } -function latest(left: number | undefined, right: number | undefined): number | undefined { - if (left === undefined) return right; - if (right === undefined) return left; - return Math.max(left, right); -} - -function currentFailureAt(record: QueueCustodyLifecycleRecord, targetCatId: string): number | undefined { - if (!record.custody.failedByCatIds.some((catId) => catId === targetCatId)) return undefined; - return (record.custody.targetAttempts ?? []) - .filter( - (attempt) => - attempt.targetCatId === targetCatId && - (attempt.state === 'failed' || attempt.state === 'interrupted' || attempt.state === 'cancelled'), - ) - .sort((left, right) => right.sequence - left.sequence || right.updatedAt - left.updatedAt)[0]?.updatedAt; -} - -function terminalAt(lifecycle: FreshnessQueueLifecycle): number | undefined { - return earliest(earliest(lifecycle.handledAt, lifecycle.withdrawnAt), lifecycle.failedAt); -} - -function lifecycleEndAt(lifecycle: FreshnessQueueLifecycle): number | undefined { - return terminalAt(lifecycle) ?? (lifecycle.terminalState ? lifecycle.lastUpdatedAt : undefined); -} - -function intersectsWindow(createdAt: number, terminal: number | undefined, window: Window): boolean { - return createdAt < window.endMs && (terminal === undefined || terminal >= window.startMs); -} - -function queueTargets(record: QueueCustodyLifecycleRecord): string[] { - const custody = record.custody; - return [ - ...new Set([ - ...custody.allTargetCats, - ...custody.seenByCatIds, - ...custody.handledByCatIds, - ...custody.failedByCatIds, - ...(custody.withdrawnByCatIds ?? []), - ...Object.keys(custody.targetOutcomeByCatId ?? {}), - ...Object.keys(custody.withdrawnAtByCatId ?? {}), - ...(custody.bodyExposures ?? []).map((exposure) => exposure.targetCatId), - ...(custody.targetAttempts ?? []).map((attempt) => attempt.targetCatId), - ]), - ]; -} - -function newQueueAccumulator(record: QueueCustodyLifecycleRecord, targetCatId: string): QueueAccumulator { - return { - entryId: record.custody.entryId, - targetCatId, - threadIds: new Set(), - messageIds: new Set(), - latestRevision: -1, - createdAt: record.custody.createdAt, - updatedAt: record.custody.updatedAt, - seenWithoutTime: false, - handledWithoutTime: false, - withdrawnWithoutTime: false, - failedWithoutTime: false, - }; -} - -function resetTerminalProjection(current: QueueAccumulator, revision: number): void { - current.latestRevision = revision; - current.handledAt = undefined; - current.withdrawnAt = undefined; - current.failedAt = undefined; - current.handledWithoutTime = false; - current.withdrawnWithoutTime = false; - current.failedWithoutTime = false; -} - -function absorbCurrentTerminal( - current: QueueAccumulator, - record: QueueCustodyLifecycleRecord, - targetCatId: string, -): void { - const custody = record.custody; - if (custody.handledByCatIds.some((catId) => catId === targetCatId)) { - current.handledAt = earliest(current.handledAt, custody.targetOutcomeByCatId?.[targetCatId]?.handledAt); - current.handledWithoutTime ||= current.handledAt === undefined; - } - if (custody.withdrawnByCatIds?.some((catId) => catId === targetCatId)) { - current.withdrawnAt = earliest(current.withdrawnAt, custody.withdrawnAtByCatId?.[targetCatId]); - current.withdrawnWithoutTime ||= current.withdrawnAt === undefined; - } - if (custody.failedByCatIds.some((catId) => catId === targetCatId)) { - current.failedAt = latest(current.failedAt, currentFailureAt(record, targetCatId)); - current.failedWithoutTime ||= current.failedAt === undefined; - } -} - -function absorbQueueTarget(current: QueueAccumulator, record: QueueCustodyLifecycleRecord, targetCatId: string): void { - const custody = record.custody; - current.threadIds.add(record.threadId); - current.messageIds.add(record.messageId); - current.createdAt = Math.min(current.createdAt, custody.createdAt); - current.updatedAt = Math.max(current.updatedAt, custody.updatedAt); - for (const exposure of custody.bodyExposures ?? []) { - if (exposure.targetCatId === targetCatId) current.firstSeenAt = earliest(current.firstSeenAt, exposure.seenAt); - } - if (custody.revision > current.latestRevision) resetTerminalProjection(current, custody.revision); - if (custody.revision === current.latestRevision) absorbCurrentTerminal(current, record, targetCatId); - current.seenWithoutTime ||= - custody.seenByCatIds.some((catId) => catId === targetCatId) && current.firstSeenAt === undefined; -} - -function queueLifecycle(item: QueueAccumulator, window: Window): FreshnessQueueLifecycle { - const terminalState = - item.handledAt !== undefined || - item.withdrawnAt !== undefined || - item.failedAt !== undefined || - item.handledWithoutTime || - item.withdrawnWithoutTime || - item.failedWithoutTime; - const legacyUntimed = - item.updatedAt >= window.startMs && - ((item.seenWithoutTime && item.firstSeenAt === undefined) || - (item.handledWithoutTime && item.handledAt === undefined) || - (item.withdrawnWithoutTime && item.withdrawnAt === undefined) || - (item.failedWithoutTime && item.failedAt === undefined)); - return { - entryId: item.entryId, - targetCatId: item.targetCatId, - threadIds: [...item.threadIds].sort(), - messageIds: [...item.messageIds].sort(), - createdAt: item.createdAt, - lastUpdatedAt: item.updatedAt, - ...(item.firstSeenAt === undefined ? {} : { firstSeenAt: item.firstSeenAt }), - ...(item.handledAt === undefined ? {} : { handledAt: item.handledAt }), - ...(item.withdrawnAt === undefined ? {} : { withdrawnAt: item.withdrawnAt }), - ...(item.failedAt === undefined ? {} : { failedAt: item.failedAt }), - terminalState, - legacyUntimed, - }; -} - -function mergeQueueLifecycles(records: readonly QueueCustodyLifecycleRecord[], window: Window) { - const merged = new Map(); - for (const record of records) { - for (const targetCatId of queueTargets(record)) { - const key = `${record.custody.entryId}\u0000${targetCatId}`; - const current = merged.get(key) ?? newQueueAccumulator(record, targetCatId); - absorbQueueTarget(current, record, targetCatId); - merged.set(key, current); - } - } - return [...merged.values()] - .map((item) => queueLifecycle(item, window)) - .filter((item) => intersectsWindow(item.createdAt, lifecycleEndAt(item), window)) +function queueLifecycleReport( + records: readonly FreshnessQueueLifecycleRecord[], + window: Window, +): FreshnessQueueLifecycleReport { + const lifecycles = records + .filter((record) => record.createdAt < window.endMs && (lifecycleEndAt(record) ?? Infinity) >= window.startMs) + .map((record) => ({ + entryId: record.entryId, + targetCatId: record.targetCatId, + threadIds: [record.threadId], + messageIds: record.messageId ? [record.messageId] : [], + createdAt: record.createdAt, + lastUpdatedAt: record.lastUpdatedAt, + ...(record.firstSeenAt === undefined ? {} : { firstSeenAt: record.firstSeenAt }), + ...(record.handledAt === undefined ? {} : { handledAt: record.handledAt }), + ...(record.withdrawnAt === undefined ? {} : { withdrawnAt: record.withdrawnAt }), + ...(record.failedAt === undefined ? {} : { failedAt: record.failedAt }), + terminalState: record.terminalState, + legacyUntimed: record.legacyUntimed, + })) .sort((left, right) => left.createdAt - right.createdAt || left.entryId.localeCompare(right.entryId)); -} - -function queueLifecycleReport(records: readonly QueueCustodyLifecycleRecord[], window: Window) { - const lifecycles = mergeQueueLifecycles(records, window); - return { entryTargetCount: lifecycles.length, admittedCount: lifecycles.filter((item) => inWindow(item.createdAt, window)).length, @@ -216,7 +80,7 @@ function queueLifecycleReport(records: readonly QueueCustodyLifecycleRecord[], w ).length, legacyUntimedCount: lifecycles.filter((item) => item.legacyUntimed).length, lifecycles, - } satisfies FreshnessQueueLifecycleReport; + }; } function supplementLifecycleReport( @@ -225,8 +89,9 @@ function supplementLifecycleReport( ): FreshnessSupplementLifecycleReport { const lifecycles = supplements .filter((item) => { - const isTerminal = item.status === 'committed' || item.status === 'declined' || item.status === 'failed'; - return intersectsWindow(item.createdAt, item.terminalAt ?? (isTerminal ? item.updatedAt : undefined), window); + const terminal = item.status === 'committed' || item.status === 'declined' || item.status === 'failed'; + const endedAt = item.terminalAt ?? (terminal ? item.updatedAt : undefined); + return item.createdAt < window.endMs && (endedAt ?? Infinity) >= window.startMs; }) .map((item) => ({ supplementId: item.id, @@ -255,8 +120,8 @@ function supplementLifecycleReport( declinedCount: lifecycles.filter((item) => item.status === 'declined' && inWindow(item.terminalAt, window)).length, failedCount: lifecycles.filter((item) => item.status === 'failed' && inWindow(item.terminalAt, window)).length, unresolvedAtWindowEndCount: lifecycles.filter((item) => { - const isTerminal = item.status === 'committed' || item.status === 'declined' || item.status === 'failed'; - const endedAt = item.terminalAt ?? (isTerminal ? item.lastUpdatedAt : undefined); + const terminal = item.status === 'committed' || item.status === 'declined' || item.status === 'failed'; + const endedAt = item.terminalAt ?? (terminal ? item.lastUpdatedAt : undefined); return item.createdAt < window.endMs && (endedAt ?? Infinity) >= window.endMs; }).length, budgetExhaustedCount: supplements.filter((item) => inWindow(item.budgetExhausted?.observedAt, window)).length, @@ -277,7 +142,7 @@ function attentionSignalReport(events: readonly FreshnessAttentionEvent[]): Fres export function buildFreshnessWindowedSignals(input: { window: Window; - queueRecords: readonly QueueCustodyLifecycleRecord[]; + queueRecords: readonly FreshnessQueueLifecycleRecord[]; supplements: readonly FreshnessSupplementAggregate[]; attentionEvents: readonly FreshnessAttentionEvent[]; }): FreshnessWindowedSignals { diff --git a/packages/api/src/infrastructure/harness-eval/friction/paw-feel-source.ts b/packages/api/src/infrastructure/harness-eval/friction/paw-feel-source.ts index e7d27ac9e0..b321de4fc1 100644 --- a/packages/api/src/infrastructure/harness-eval/friction/paw-feel-source.ts +++ b/packages/api/src/infrastructure/harness-eval/friction/paw-feel-source.ts @@ -10,6 +10,21 @@ import { extractPawFeelMarkers, isStandalonePawFeelMarker, type PawFeelMarker } const DEFAULT_PAGE_SIZE = 200; +function getProjectedTimelineOrderTime(message: PawFeelSourceMessageProjection): number { + if (message.timelineOrderAt !== undefined) return message.timelineOrderAt; + const queuedAgentSpeech = + message.deliveryStatus === 'queued' && + message.catId !== null && + message.source === undefined && + message.sourceParseFailure !== true && + !( + (message.userId === 'system' || message.userId === 'scheduler') && + (message.catId === 'system' || message.catId === null) + ) && + message.origin !== 'briefing'; + return queuedAgentSpeech ? message.timestamp : (message.deliveredAt ?? message.timestamp); +} + export interface PawFeelSourceOptions { pageSize?: number; } @@ -83,7 +98,7 @@ function inspectPawFeelMessageWithMode( markerDigest, sameDigestOrdinal, markerIndex, - occurredAt: new Date(getTimelineOrderTime(message)).toISOString(), + occurredAt: new Date(getProjectedTimelineOrderTime(message)).toISOString(), captureAssessment: 'ambiguous' as const, marker, }; diff --git a/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts b/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts index b0e079bd80..96fc5f9390 100644 --- a/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts +++ b/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts @@ -119,8 +119,8 @@ export async function handleTriggerNow( ].join('\n'); const stored = await deps.messageStore.append({ + from: { kind: 'system', service: 'scheduler' }, userId: 'scheduler', - catId: null, content, mentions: [], timestamp: Date.now(), diff --git a/packages/api/src/infrastructure/harness-eval/paw-feel-disposition/hot-intake.ts b/packages/api/src/infrastructure/harness-eval/paw-feel-disposition/hot-intake.ts index adc7a816d7..5e5fd5f17d 100644 --- a/packages/api/src/infrastructure/harness-eval/paw-feel-disposition/hot-intake.ts +++ b/packages/api/src/infrastructure/harness-eval/paw-feel-disposition/hot-intake.ts @@ -7,14 +7,20 @@ export type PawFeelHotIntakeResult = | { kind: 'cross_post_copy'; discoveredSignals: 0; markerCount: number } | { kind: 'canonical'; discoveredSignals: number }; +function agentCatId(message: StoredMessage): string | null { + if (message.from) return message.from.kind === 'agent' ? message.from.catId : null; + return message.catId || null; +} + export async function capturePawFeelSourceMessage( principal: { kind: 'cat'; id: string }, message: StoredMessage, dispositionService: Pick, ): Promise { - if (!message.catId) throw new Error('typed paw-feel capture requires a cat-authored source message'); - if (message.catId !== principal.id) { - throw new Error(`typed paw-feel capture source cat mismatch: expected ${principal.id}, got ${message.catId}`); + const sourceCatId = agentCatId(message); + if (!sourceCatId) throw new Error('typed paw-feel capture requires a cat-authored source message'); + if (sourceCatId !== principal.id) { + throw new Error(`typed paw-feel capture source cat mismatch: expected ${principal.id}, got ${sourceCatId}`); } const inspection = inspectDeclaredPawFeelMessage(message); if (inspection.kind === 'ignored') return { kind: 'ignored', discoveredSignals: 0 }; @@ -115,13 +121,14 @@ export class PawFeelCaptureIntentSidecar { async capturePersistedMessage(message: StoredMessage): Promise { this.pruneExpired(); const invocationId = message.extra?.stream?.turnInvocationId; - if (!invocationId || message.origin !== 'stream' || !message.catId) return { kind: 'ignored' }; + const sourceCatId = agentCatId(message); + if (!invocationId || message.origin !== 'stream' || !sourceCatId) return { kind: 'ignored' }; const intent = this.intents.get(invocationId); if ( !intent || intent.threadId !== message.threadId || intent.userId !== message.userId || - intent.catId !== message.catId + intent.catId !== sourceCatId ) { return { kind: 'ignored' }; } @@ -171,7 +178,7 @@ export async function captureAppendedPawFeelMessage( ): Promise { const typed = await sidecar.capturePersistedMessage(message); if (typed.kind === 'captured') return typed; - if (!message.catId) return { kind: 'ignored' }; + if (!agentCatId(message)) return { kind: 'ignored' }; const inspection = inspectDeclaredPawFeelMessage(message); if (inspection.kind === 'ignored') return { kind: 'ignored' }; diff --git a/packages/api/src/infrastructure/harness-eval/reeval-case-task-dispatch.ts b/packages/api/src/infrastructure/harness-eval/reeval-case-task-dispatch.ts index 63fa05b630..22114761d9 100644 --- a/packages/api/src/infrastructure/harness-eval/reeval-case-task-dispatch.ts +++ b/packages/api/src/infrastructure/harness-eval/reeval-case-task-dispatch.ts @@ -143,8 +143,8 @@ export class ReevalCaseTaskDispatcher implements ReevalCaseTaskDispatchPort { let message: StoredMessage; try { message = await this.options.messageStore.append({ + from: { kind: 'agent', catId: callerCatId }, userId: task.userId, - catId: callerCatId, content: carrierContent(input), mentions: [task.ownerCatId], origin: 'callback', diff --git a/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts b/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts index 5fc1355c06..407972b8aa 100644 --- a/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts +++ b/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts @@ -1,5 +1,4 @@ import type { IBallCustodyIngest } from '../../domains/ball-custody/BallCustodyIngest.js'; -import { buildHoldExpiredEvent } from '../../domains/ball-custody/ball-custody-events.js'; import { holdStaleWakeSuppressedTotal } from '../telemetry/instruments.js'; import { computeNextCronSlot, countAdditionalDueCronSlots } from './cron-utils.js'; import type { DynamicTaskDef, DynamicTaskStore } from './DynamicTaskStore.js'; @@ -34,6 +33,8 @@ export interface TaskRunnerV2Options { emissionStore?: import('./EmissionStore.js').EmissionStore; /** Phase 4 (AC-H1): deliver message to a thread */ deliver?: (opts: DeliverOpts) => Promise; + /** Cancel a scheduler-owned queued message that failed before Queue admission. */ + cancelQueuedDelivery?: (messageId: string) => Promise; /** Phase 4 (AC-H2): fetch web content with browser-automation routing */ fetchContent?: (url: string, signal?: AbortSignal) => Promise; /** Phase 4b: invoke a cat to handle a scheduled task (fire-and-forget) */ @@ -52,6 +53,8 @@ export interface TaskRunnerV2Options { * invocationTracker/queueProcessor are constructed after the runner. */ isThreadBusy?: (threadId: string) => boolean; + /** Testable backoff for retrying an active hold whose once pipeline failed before durable settlement. */ + retryableHoldFailureDelayMs?: number; } /** Phase 2.5: Compute human-readable subject preview from subjectKind + lastRun (AC-E2) */ @@ -171,6 +174,7 @@ export class TaskRunnerV2 { private globalControlStore: TaskRunnerV2Options['globalControlStore']; private emissionStore: TaskRunnerV2Options['emissionStore']; private deliver: TaskRunnerV2Options['deliver']; + private cancelQueuedDelivery: TaskRunnerV2Options['cancelQueuedDelivery']; private fetchContent: TaskRunnerV2Options['fetchContent']; private invokeTrigger: TaskRunnerV2Options['invokeTrigger']; private ballCustody: TaskRunnerV2Options['ballCustody']; @@ -179,6 +183,7 @@ export class TaskRunnerV2 { /** F167 Phase M: busy checker for pre-fire defer (queueProcessor.isThreadBusy() || invocationTracker.has()) */ private isThreadBusy: TaskRunnerV2Options['isThreadBusy']; private managedCommandWakeRecovery?: (taskId: string) => Promise<'missing' | 'pending' | 'recovered'>; + private readonly retryableHoldFailureDelayMs: number; /** F167 Phase M: per-task consecutive defer counter (reset on fire) */ private deferCounts = new Map(); /** @@ -197,12 +202,14 @@ export class TaskRunnerV2 { this.globalControlStore = opts.globalControlStore; this.emissionStore = opts.emissionStore; this.deliver = opts.deliver; + this.cancelQueuedDelivery = opts.cancelQueuedDelivery; this.fetchContent = opts.fetchContent; this.invokeTrigger = opts.invokeTrigger; this.ballCustody = opts.ballCustody; this.notifyLifecycle = opts.notifyLifecycle; this.dynamicTaskStore = opts.dynamicTaskStore; this.isThreadBusy = opts.isThreadBusy; + this.retryableHoldFailureDelayMs = opts.retryableHoldFailureDelayMs ?? 30_000; } /** Late-bind invokeTrigger (constructed after TaskRunnerV2 in boot sequence) */ @@ -570,12 +577,22 @@ export class TaskRunnerV2 { const entries = this.ledger.query(task.id, 1); const lastOutcome = entries[0]?.outcome; const isGovernanceSkip = lastOutcome === 'SKIP_GLOBAL_PAUSE' || lastOutcome === 'SKIP_TASK_OVERRIDE'; - if (isGovernanceSkip) { - this.logger.info(`[scheduler] ${task.id}: once task governance-skipped, retrying in 30s`); + const currentDef = this.dynamicTaskStore?.getById(task.id); + const isRetryableHoldFailure = + lastOutcome === 'RUN_FAILED' && + !!currentDef && + isHoldBallReminderDef(currentDef) && + currentDef.enabled && + readHoldLifecycleStatus(currentDef) === 'active'; + if (isGovernanceSkip || isRetryableHoldFailure) { + const reason = isGovernanceSkip ? 'governance-skipped' : 'failed before durable wake settlement'; + this.logger.info( + `[scheduler] ${task.id}: once task ${reason}, retrying in ${this.retryableHoldFailureDelayMs}ms`, + ); const retryTimer = setTimeout(() => { if (!this.started || !this.tasks.some((t) => t.id === task.id)) return; this.scheduleOnceTick(task); - }, 30_000); + }, this.retryableHoldFailureDelayMs); if (typeof retryTimer === 'object' && 'unref' in retryTimer) retryTimer.unref(); this.timers.set(task.id, retryTimer); } else { @@ -624,12 +641,28 @@ export class TaskRunnerV2 { error_summary: null, }); - if (def.deliveryThreadId && isHoldBallReminderDef(def)) { + if (def.deliveryThreadId && isHoldBallReminderDef(def) && this.deliver) { const catId = readHoldBallCatId(def); if (catId) { - this.ballCustody - ?.record(buildHoldExpiredEvent({ threadId: def.deliveryThreadId, catId, fireAt, at: Date.now() })) - .catch((err) => this.logger.error(`[scheduler] ${def.id}: failed to record missed hold expiry`, err)); + const triggerUserId = ((def.params as Record).triggerUserId as string) || 'default-user'; + void this.deliver({ + threadId: def.deliveryThreadId, + userId: triggerUserId, + content: `等待已结束:原定 ${fireAtIso} 的唤醒因服务未运行而错过,任务已取消。`, + idempotencyKey: `hold-ball-missed:${def.id}`, + source: { + connector: 'hold-ball', + label: '持球状态', + icon: '🏓', + meta: { + managedHold: true, + phase: 'status', + taskId: def.id, + threadId: def.deliveryThreadId, + catId, + }, + }, + }).catch((err) => this.logger.error(`[scheduler] ${def.id}: failed to persist missed hold status`, err)); } } @@ -756,6 +789,7 @@ export class TaskRunnerV2 { isManualTrigger, schedule, deliver: this.deliver, + cancelQueuedDelivery: this.cancelQueuedDelivery, fetchContent: this.fetchContent, invokeTrigger: this.invokeTrigger, ballCustody: this.ballCustody, diff --git a/packages/api/src/infrastructure/scheduler/delivery.ts b/packages/api/src/infrastructure/scheduler/delivery.ts index 4dfc36501c..842dc40f50 100644 --- a/packages/api/src/infrastructure/scheduler/delivery.ts +++ b/packages/api/src/infrastructure/scheduler/delivery.ts @@ -21,18 +21,21 @@ export const SCHEDULER_SOURCE = { export function createDeliverFn(deps: DeliveryDeps): (opts: DeliverOpts) => Promise { return async (opts: DeliverOpts): Promise => { + const source = opts.source ?? SCHEDULER_SOURCE; const stored = await deps.messageStore.append({ + from: { kind: 'system', service: source.connector }, userId: opts.userId, - catId: null, content: opts.content, mentions: [], origin: 'callback', timestamp: Date.now(), threadId: opts.threadId, - source: SCHEDULER_SOURCE, + source, + ...(opts.deliveryStatus ? { deliveryStatus: opts.deliveryStatus } : {}), ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}), ...(opts.extra ? { extra: opts.extra } : {}), }); + if (opts.deliveryStatus === 'queued') return stored.id; const schedulerExtra = stored.extra?.scheduler ?? opts.extra?.scheduler; deps.socketManager.broadcastToRoom(`thread:${opts.threadId}`, 'connector_message', { threadId: opts.threadId, @@ -40,7 +43,7 @@ export function createDeliverFn(deps: DeliveryDeps): (opts: DeliverOpts) => Prom id: stored.id, type: 'connector', content: typeof stored.content === 'string' ? stored.content : opts.content, - source: SCHEDULER_SOURCE, + source, ...(schedulerExtra ? { extra: { scheduler: schedulerExtra } } : {}), timestamp: stored.timestamp, }, diff --git a/packages/api/src/infrastructure/scheduler/execute-pipeline.ts b/packages/api/src/infrastructure/scheduler/execute-pipeline.ts index 9b050c570b..092e6adf9a 100644 --- a/packages/api/src/infrastructure/scheduler/execute-pipeline.ts +++ b/packages/api/src/infrastructure/scheduler/execute-pipeline.ts @@ -36,6 +36,8 @@ export interface PipelineContext { schedule?: ScheduleRunTiming; /** Phase 4 (AC-H1): deliver message to a thread */ deliver?: (opts: DeliverOpts) => Promise; + /** Cancel a scheduler-owned queued message that failed before Queue admission. */ + cancelQueuedDelivery?: (messageId: string) => Promise; /** Phase 4 (AC-H2): fetch web content with browser-automation routing */ fetchContent?: (url: string, signal?: AbortSignal) => Promise; /** Phase 4b: invoke a cat to handle a scheduled task (fire-and-forget) */ @@ -111,6 +113,7 @@ export async function executeTaskPipeline(ctx: PipelineContext): Promise { isManualTrigger, schedule, deliver, + cancelQueuedDelivery, fetchContent, invokeTrigger, ballCustody, @@ -249,6 +252,12 @@ export async function executeTaskPipeline(ctx: PipelineContext): Promise { return result; } : undefined; + const cancellationAwareCancelQueuedDelivery = cancelQueuedDelivery + ? async (messageId: string): Promise => { + if (!deliveredMessageIds.has(messageId)) executeController.signal.throwIfAborted(); + return cancelQueuedDelivery(messageId); + } + : undefined; const cancellationAwareInvokeTrigger: ScheduleInvokeTrigger | undefined = invokeTrigger ? { trigger(...args: Parameters) { @@ -283,6 +292,7 @@ export async function executeTaskPipeline(ctx: PipelineContext): Promise { context: task.context, schedule, deliver: cancellationAwareDeliver, + cancelQueuedDelivery: cancellationAwareCancelQueuedDelivery, fetchContent: cancellationAwareFetch, invokeTrigger: cancellationAwareInvokeTrigger, ballCustody, diff --git a/packages/api/src/infrastructure/scheduler/templates/reminder.ts b/packages/api/src/infrastructure/scheduler/templates/reminder.ts index 6c9ea5d7f1..a8f8f1f209 100644 --- a/packages/api/src/infrastructure/scheduler/templates/reminder.ts +++ b/packages/api/src/infrastructure/scheduler/templates/reminder.ts @@ -1,5 +1,4 @@ import { SCHEDULER_TRIGGER_PREFIX } from '@cat-cafe/shared'; -import { buildHoldExpiredEvent } from '../../../domains/ball-custody/ball-custody-events.js'; import type { ScheduleRunTiming, TaskSpec_P1 } from '../types.js'; import type { DynamicTaskParams, TaskTemplate } from './types.js'; @@ -29,6 +28,18 @@ function isManagedCommandWake(params: Record): boolean { ); } +function isManagedHoldWake(instanceId: string, params: Record): boolean { + const lifecycle = params.holdLifecycle; + return Boolean( + instanceId.startsWith('hold-ball-') && + typeof lifecycle === 'object' && + lifecycle !== null && + !Array.isArray(lifecycle) && + ((lifecycle as Record).mode === 'timer' || + (lifecycle as Record).mode === 'wake_when'), + ); +} + /** Reminder template — fires on schedule, wakes a cat to handle the reminder in-thread */ export const reminderTemplate: TaskTemplate = { templateId: 'reminder', @@ -48,6 +59,7 @@ export const reminderTemplate: TaskTemplate = { const ownerAuthProvenance = instanceId.startsWith('hold-ball-') ? p.ownerAuthProvenance : undefined; const threadId = p.deliveryThreadId; const managedCommandWake = instanceId.startsWith('hold-ball-') && isManagedCommandWake(p.params); + const managedHoldWake = isManagedHoldWake(instanceId, p.params); // F167 Phase M (codex P1): pre-fire defer activation is hold_ball-specific. // Gate on the `hold-ball-` instanceId prefix — callback-hold-ball-routes mints those // ids, while public /api/schedule/tasks only mints `dyn-*` (schedule.ts:417), so a @@ -81,22 +93,80 @@ export const reminderTemplate: TaskTemplate = { const catId = targetCatId ?? ctx.assignedCatId ?? 'opus'; const content = `${SCHEDULER_TRIGGER_PREFIX} ${formatScheduleTiming(ctx.schedule)}${message}`; - if (instanceId.startsWith('hold-ball-') && p.trigger.type === 'once' && threadId) { - ctx.ballCustody - ?.record(buildHoldExpiredEvent({ threadId: tid, catId, fireAt: p.trigger.fireAt, at: Date.now() })) - .catch(() => {}); - } - // Store trigger message first → real messageId for InvocationRecord + retry const messageId = await ctx.deliver({ threadId: tid, content, - userId: 'scheduler', - ...(ctx.invokeTrigger ? { extra: { scheduler: { hiddenTrigger: true } } } : {}), + // F117 ADR-043 D.4: stored userId is the verified trigger owner (tenant), + // not the author — scheduler authorship is expressed via from: system:scheduler. + // Must equal the triggerUserId passed to invokeTrigger below, otherwise the + // canonical ingress fence (source owner vs queue owner) rejects the enqueue. + userId: triggerUserId, + ...(ctx.invokeTrigger && !managedHoldWake ? { extra: { scheduler: { hiddenTrigger: true } } } : {}), + ...(managedHoldWake + ? { + deliveryStatus: 'queued' as const, + idempotencyKey: `hold-ball-wake:${instanceId}`, + source: { + connector: 'hold-ball', + label: '持球唤醒', + icon: '🏓', + meta: { + managedHold: true, + phase: 'wake', + taskId: instanceId, + threadId: tid, + catId, + }, + }, + } + : {}), }); // Wake a cat to act on the trigger message - if (ctx.invokeTrigger) { + if (managedHoldWake) { + try { + if (!ctx.invokeTrigger) throw new Error('invoke trigger is unavailable'); + const outcome = await ctx.invokeTrigger.trigger( + tid, + catId, + triggerUserId, + content, + messageId, + undefined, + { + sourceCategory: 'scheduled', + priority: 'urgent', + ...(ownerAuthProvenance ? { ownerAuthProvenance } : {}), + }, + ); + if (outcome !== 'enqueued') throw new Error(`queue admission returned ${outcome}`); + } catch (err) { + if (!ctx.cancelQueuedDelivery) + throw new Error('queued delivery cancellation is unavailable', { cause: err }); + const canceled = await ctx.cancelQueuedDelivery(messageId); + if (!canceled) throw new Error('failed to cancel unadmitted managed-hold wake', { cause: err }); + const detail = err instanceof Error ? err.message.slice(0, 500) : String(err).slice(0, 500); + await ctx.deliver({ + threadId: tid, + userId: triggerUserId, + content: `等待已结束:唤醒入队失败(${detail})`, + idempotencyKey: `hold-ball-wake-failed:${instanceId}`, + source: { + connector: 'hold-ball', + label: '持球状态', + icon: '🏓', + meta: { + managedHold: true, + phase: 'status', + taskId: instanceId, + threadId: tid, + catId, + }, + }, + }); + } + } else if (ctx.invokeTrigger) { try { void Promise.resolve( ctx.invokeTrigger.trigger(tid, catId, triggerUserId, content, messageId, undefined, { @@ -105,7 +175,7 @@ export const reminderTemplate: TaskTemplate = { }), ).catch(() => {}); } catch { - // Best-effort: sync trigger throw should not fail the reminder + // Best-effort: sync trigger throw should not fail ordinary reminders. } } }, diff --git a/packages/api/src/infrastructure/scheduler/templates/web-digest.ts b/packages/api/src/infrastructure/scheduler/templates/web-digest.ts index 41cbca7adf..9e66763695 100644 --- a/packages/api/src/infrastructure/scheduler/templates/web-digest.ts +++ b/packages/api/src/infrastructure/scheduler/templates/web-digest.ts @@ -53,7 +53,7 @@ export const webDigestTemplate: TaskTemplate = { const messageId = await ctx.deliver({ threadId: tid, content: triggerContent, - userId: 'scheduler', + userId: triggerUserId, }); try { void Promise.resolve( diff --git a/packages/api/src/infrastructure/scheduler/types.ts b/packages/api/src/infrastructure/scheduler/types.ts index f14d42f3f9..1f1caff481 100644 --- a/packages/api/src/infrastructure/scheduler/types.ts +++ b/packages/api/src/infrastructure/scheduler/types.ts @@ -1,4 +1,9 @@ -import type { SchedulerLifecycleEvent, SchedulerMessageExtra, SchedulerToastPayload } from '@cat-cafe/shared'; +import type { + ConnectorSource, + SchedulerLifecycleEvent, + SchedulerMessageExtra, + SchedulerToastPayload, +} from '@cat-cafe/shared'; import type { IBallCustodyIngest } from '../../domains/ball-custody/BallCustodyIngest.js'; import type { OwnerAuthProvenance } from '../../domains/cats/services/owner-auth-provenance.js'; @@ -86,6 +91,10 @@ export interface DeliverOpts { /** Stable producer identity for retrying one exact persisted scheduler item. */ idempotencyKey?: string; extra?: SchedulerMessageExtra; + /** Queued sources remain off the timeline until Queue admission marks them delivered. */ + deliveryStatus?: 'queued'; + /** Optional canonical source identity for scheduler-backed continuation producers. */ + source?: ConnectorSource; } /** Phase 4: result of fetching web content */ @@ -114,7 +123,7 @@ export interface ScheduleLifecycleNotice { export type ScheduleLifecycleNotifier = (notice: ScheduleLifecycleNotice) => void; -export type ScheduleInvokeTriggerOutcome = 'dispatched' | 'enqueued' | 'full'; +export type ScheduleInvokeTriggerOutcome = 'enqueued' | 'full'; /** Async cat invocation trigger — callers may detach it, but resolution means durable wake acceptance. */ export interface ScheduleInvokeTrigger { @@ -141,6 +150,8 @@ export interface ExecuteContext { schedule?: ScheduleRunTiming; /** Phase 4: deliver message to a thread */ deliver?: (opts: DeliverOpts) => Promise; + /** Cancel a scheduler-owned queued message that failed before Queue admission. */ + cancelQueuedDelivery?: (messageId: string) => Promise; /** Phase 4: fetch web content with browser-automation routing */ fetchContent?: (url: string) => Promise; /** Phase 4b: invoke a cat to handle a scheduled task (fire-and-forget) */ diff --git a/packages/api/src/infrastructure/telemetry/genai-semconv.ts b/packages/api/src/infrastructure/telemetry/genai-semconv.ts index b46b7de18a..89f85e48a3 100644 --- a/packages/api/src/infrastructure/telemetry/genai-semconv.ts +++ b/packages/api/src/infrastructure/telemetry/genai-semconv.ts @@ -93,7 +93,6 @@ export const TARGET_LAYER = 'target.layer'; // --- Route aggregate attributes (set at route completion) --- export const ROUTE_TOTAL_CATS_INVOKED = 'route.total_cats_invoked'; export const ROUTE_TOTAL_TOKENS = 'route.total_tokens'; -export const ROUTE_HAS_A2A_HANDOFF = 'route.has_a2a_handoff'; /** * F153: Caller trace context for cross-route A2A propagation. diff --git a/packages/api/src/infrastructure/telemetry/instruments.ts b/packages/api/src/infrastructure/telemetry/instruments.ts index ed304d3558..a5afdacfbc 100644 --- a/packages/api/src/infrastructure/telemetry/instruments.ts +++ b/packages/api/src/infrastructure/telemetry/instruments.ts @@ -112,6 +112,12 @@ export const routingSignalObservationTotal = lazy(() => }), ); +export const routingSyntaxCorrectionDetected = lazy(() => + meter().createCounter('cat_cafe.a2a.routing_syntax.correction_detected', { + description: 'Agent output requiring private line-start routing syntax correction', + }), +); + export const proactiveMemoryScanTotal = lazy(() => meter().createCounter('cat_cafe.proactive_memory.scan', { description: 'F282 canonical owner-window scans without owner or subject attributes', @@ -254,7 +260,7 @@ export const inlineActionFeedbackWriteFailed = lazy(() => export const inlineActionHintEmitted = lazy(() => meter().createCounter('cat_cafe.a2a.inline_action.hint_emitted', { - description: 'Inline action hint system message sent to user', + description: 'Legacy-named internal inline action correction signal', }), ); @@ -600,13 +606,13 @@ export const routingTerminalReleaseRemedialTotal = lazy(() => export const c2VerdictHintEmitted = lazy(() => meter().createCounter('cat_cafe.a2a.c2.verdict_hint_emitted', { - description: 'C2 exit-check verdict-no-pass hint emitted (split from mixed hint_emitted)', + description: 'Legacy-named internal C2 verdict-without-pass correction signal', }), ); export const c2VoidHoldHintEmitted = lazy(() => meter().createCounter('cat_cafe.a2a.c2.void_hold_hint_emitted', { - description: 'C2 exit-check void-hold hint emitted (split from mixed hint_emitted)', + description: 'Legacy-named internal C2 void-hold correction signal', }), ); @@ -1070,10 +1076,10 @@ export const freshnessQueuedSeen = lazy(() => }), ); -/** Queued handled: a seen queued entry closed via same-invocation successful completion evidence. */ +/** Queued handled: an exact active child durably adopted one pending target from its source entry. */ export const freshnessQueuedHandled = lazy(() => meter().createCounter('cat_cafe.freshness.queued_handled', { - description: 'F254 queued handled closures inferred from queued_seen plus successful invocation evidence', + description: 'F254 queued handled closures committed by exact active-child full-read adoption', }), ); @@ -1366,5 +1372,6 @@ export function warmupCounters(): void { externalCaseUserNudgeRequired.add(0); contextProjectionTransitionTotal.add(0); contextProjectionLedgerOutcomeTotal.add(0); + routingSyntaxCorrectionDetected.add(0); routingSignalObservationTotal.add(0); } diff --git a/packages/api/src/infrastructure/websocket/SocketManager.ts b/packages/api/src/infrastructure/websocket/SocketManager.ts index f7c6f569a0..099d0e1317 100644 --- a/packages/api/src/infrastructure/websocket/SocketManager.ts +++ b/packages/api/src/infrastructure/websocket/SocketManager.ts @@ -24,7 +24,7 @@ const log = createModuleLogger('ws'); interface QueueProcessorLike { canReleaseSlotForUser(threadId: string, catId: string, userId: string): boolean; - clearPause(threadId: string, catId?: string): void; + hasProcessingSlotReservation(threadId: string, catId: string): boolean; releaseSlot(threadId: string, catId: string): void; suppressAutoResume(threadId: string, catId: string, executionIds?: readonly string[]): void; } @@ -52,19 +52,12 @@ function validateRoomJoin(roomInput: unknown, userId: string): RoomJoinAck { export function buildCancelMessages(result: CancelResult): AgentMessage[] { if (!result.cancelled) return []; const catIds = result.catIds.length > 0 ? result.catIds : ['opus']; - const primaryCatId = catIds[0] ?? 'opus'; const now = Date.now(); const messages: AgentMessage[] = []; - // Single system_info to avoid "cancel chorus" - messages.push({ - type: 'system_info', - catId: createCatId(primaryCatId), - content: '⏹ 已取消', - timestamp: now, - }); - - // Per-cat done to ensure each cat's loading state is cleared + // The durable response message owns the visible canceled terminal. This + // broadcast is transport cleanup only, so a second centered system row would + // duplicate the same fact in History. for (const catId of catIds) { messages.push({ type: 'done', @@ -329,8 +322,9 @@ export class SocketManager { for (const msg of buildCancelMessages(scopedResult)) { this.broadcastAgentMessage(msg, data.threadId); } - this.queueProcessor?.clearPause(data.threadId, data.catId); - this.queueProcessor?.releaseSlot(data.threadId, data.catId); + if (!this.queueProcessor?.hasProcessingSlotReservation(data.threadId, data.catId)) { + this.queueProcessor?.releaseSlot(data.threadId, data.catId); + } } // F108 + F086: Also abort multi-mention dispatches for this specific cat this.multiMentionOrchestrator?.abortBySlot?.(data.threadId, data.catId); @@ -364,15 +358,18 @@ export class SocketManager { this.broadcastAgentMessage(msg, data.threadId); } for (const catId of terminalCatIds) { - this.queueProcessor?.clearPause(data.threadId, catId); - this.queueProcessor?.releaseSlot(data.threadId, catId); + if (!this.queueProcessor?.hasProcessingSlotReservation(data.threadId, catId)) { + this.queueProcessor?.releaseSlot(data.threadId, catId); + } // Suppress auto-resume for BOTH paths: // - Queued invocations: executeEntry also sets suppress (belt-and-suspenders) // - Direct invocations (messages.ts): only this external call covers them // because they don't go through executeEntry // Protected by: cancel_all reason, exact canceled execution IDs, 60s TTL const executionId = cancelAllResult.executionIdByCatId?.[catId]; - this.queueProcessor?.suppressAutoResume(data.threadId, catId, executionId ? [executionId] : []); + if (executionId) { + this.queueProcessor?.suppressAutoResume(data.threadId, catId, [executionId]); + } } } // F156 P1-fix: Use per-cat abortBySlot instead of thread-wide abortByThread. diff --git a/packages/api/src/routes/a2a-routing-projection.ts b/packages/api/src/routes/a2a-routing-projection.ts deleted file mode 100644 index 816b451374..0000000000 --- a/packages/api/src/routes/a2a-routing-projection.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * F086/F216: the single place that turns an A2A dispatch decision into the user-visible - * routing pill ("A → B"). - * - * Both dispatch families write through here so the projection can never disagree with the - * scheduling mode that actually ran: - * - serial — route-serial.ts yields `a2a_handoff` events for an ordered worklist; the - * SSE/message routes persist them via `persistA2ARoutingMessage`. - * - parallel — the multi-mention callback route fans out N independent Queue entries and - * calls `emitParallelRoutingPills` for the same projection surface. - * - * Before this module existed the parallel path emitted no pill at all and the serial path - * emitted N identical "→" pills at the same millisecond — so a sequential worklist and a real - * fan-out were indistinguishable to the reader (#1291). - */ - -import type { A2ARoutingProjection, CatId } from '@cat-cafe/shared'; -import { catRegistry } from '@cat-cafe/shared'; -import type { FastifyBaseLogger } from 'fastify'; -import { formatA2AHandoffContent } from '../domains/cats/services/agents/routing/a2a-handoff-label.js'; -import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; -import type { SocketManager } from '../infrastructure/websocket/index.js'; - -export interface A2ARoutingPillInput { - catId?: string; - content?: string; - invocationId?: string; - targetCatId?: string; - /** Structured serial-vs-parallel scheduling mode for this leg. */ - routing?: A2ARoutingProjection; - timestamp: number; -} - -/** - * Persist one routing pill as a system message so a page reload projects the same scheduling - * truth the live socket stream showed. - */ -export async function persistA2ARoutingMessage( - messageStore: IMessageStore, - msg: A2ARoutingPillInput, - threadId: string, - log: { warn: (obj: unknown, msg: string) => void }, -): Promise { - if (!msg.content) return undefined; - try { - const stored = await messageStore.append({ - userId: 'system', - catId: null, - content: msg.content, - mentions: [], - timestamp: msg.timestamp, - threadId, - extra: { - systemKind: 'a2a_routing', - a2aRouting: { - fromCatId: msg.catId, - targetCatId: msg.targetCatId, - invocationId: msg.invocationId, - ...(msg.routing ? { routing: msg.routing } : {}), - }, - }, - }); - return stored.id; - } catch (err) { - log.warn({ err, threadId }, 'Failed to persist a2a_handoff'); - return undefined; - } -} - -/** - * Emit one `parallel` routing pill per fanned-out target. - * - * Fire-and-forget by contract, and the CALLER must honour that by not awaiting this before - * starting work: persistence is awaited per target inside here, so any caller that awaits the - * whole thing in front of `tryAutoExecute` converts a slow store into a scheduling stall - * (砚砚 R2 P1). The pill is a projection, not custody — losing one costs a UI line, nothing more. - * - * `targetCatIds` MUST be the targets that actually obtained custody, never the requested list. - */ -export async function emitParallelRoutingPills(input: { - messageStore?: IMessageStore; - socketManager: Pick; - threadId: string; - fromCatId: CatId; - targetCatIds: readonly CatId[]; - log: FastifyBaseLogger; -}): Promise { - const { messageStore, socketManager, threadId, fromCatId, targetCatIds, log } = input; - if (targetCatIds.length === 0) return; - const fromConfig = catRegistry.tryGet(fromCatId as string)?.config; - const total = targetCatIds.length; - - // 砚砚 R3 P1 — SNAPSHOT THE WHOLE BATCH SYNCHRONOUSLY, BEFORE ANY AWAIT. - // Building each leg inside the persistence loop meant leg N's `Date.now()` was taken only after - // leg N-1's write settled. With a 25ms-slow first write the probe observed - // `pill1.ts < firstStream.ts < pill2.ts`, so the frontend's timestamp-aware insert could rescue - // the first pill but not the second — the fan-out rendered AFTER the output it announces. A - // never-settling first write starved every later sibling's pill entirely. - // One shared timestamp is also the honest value here: a real fan-out genuinely starts together - // (same-ms handoffs are already de-duplicated by a monotonic seq on the client). - const startedAt = Date.now(); - const legs = targetCatIds.map((targetCatId, i) => { - const routing: A2ARoutingProjection = { mode: 'parallel', index: i + 1, total }; - const toConfig = catRegistry.tryGet(targetCatId as string)?.config; - return { - targetCatId, - routing, - content: formatA2AHandoffContent(fromCatId, targetCatId, fromConfig, toConfig, routing), - timestamp: startedAt, - }; - }); - - // Each leg persists + broadcasts independently: one slow or failing sibling must not delay, - // reorder, or suppress the others. - await Promise.all( - legs.map(async ({ targetCatId, routing, content, timestamp }) => { - const pill: A2ARoutingPillInput = { catId: fromCatId, content, targetCatId, routing, timestamp }; - const messageId = messageStore ? await persistA2ARoutingMessage(messageStore, pill, threadId, log) : undefined; - socketManager.broadcastAgentMessage( - { - type: 'a2a_handoff', - catId: fromCatId, - content, - targetCatId, - routing, - timestamp, - ...(messageId ? { messageId } : {}), - }, - threadId, - ); - }), - ); -} diff --git a/packages/api/src/routes/active-execution-routes.ts b/packages/api/src/routes/active-execution-routes.ts index 746608c754..a3148623cb 100644 --- a/packages/api/src/routes/active-execution-routes.ts +++ b/packages/api/src/routes/active-execution-routes.ts @@ -1,5 +1,8 @@ -import { performance } from 'node:perf_hooks'; -import type { ActiveExecutionListResponse, ActiveExecutionProjection } from '@cat-cafe/shared'; +import type { + ActiveExecutionListResponse, + ActiveExecutionProjection, + LifecycleInputCapabilities, +} from '@cat-cafe/shared'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { @@ -21,6 +24,8 @@ export interface LiveExecutionCandidate { /** Runtime principal. Visibility and control authority are separate decisions. */ readonly ownerUserId?: string; readonly controlSource?: 'tracker' | 'process_owner' | 'unavailable'; + /** Exact request-owned input capability, projected from the live dispatcher. */ + readonly inputCapabilities?: LifecycleInputCapabilities; } interface ActiveExecutionTracker { @@ -49,8 +54,6 @@ export interface ActiveExecutionRouteDeps { userId: string, request: FastifyRequest, ) => Promise; - /** False means a durable process-owner source could not be read on this request. */ - readonly isLiveExecutionControlPlaneComplete?: (request: FastifyRequest) => Promise; readonly cancelExactLiveInvocation: (input: { threadId: string; userId: string; @@ -59,8 +62,34 @@ export interface ActiveExecutionRouteDeps { candidate: LiveExecutionCandidate; request: FastifyRequest; }) => - | { cancelled: boolean; controlPlaneUnavailable?: boolean } - | Promise<{ cancelled: boolean; controlPlaneUnavailable?: boolean }>; + | { cancelled: boolean; reconciled?: boolean; controlPlaneUnavailable?: boolean } + | Promise<{ cancelled: boolean; reconciled?: boolean; controlPlaneUnavailable?: boolean }>; + /** + * Reconcile an exact execution that disappeared between projection and Stop. + * This runs only after the process-owner snapshot is complete and no newer + * controllable execution for the same cat exists. + */ + readonly reconcileInactiveLiveInvocation?: (input: { + threadId: string; + userId: string; + catId: string; + executionId: string; + request: FastifyRequest; + }) => + | { + reconciled: boolean; + replacement?: boolean; + controlPlaneUnavailable?: boolean; + error?: string; + code?: string; + } + | Promise<{ + reconciled: boolean; + replacement?: boolean; + controlPlaneUnavailable?: boolean; + error?: string; + code?: string; + }>; } const cancelLiveBodySchema = z.object({ catId: z.string().min(1).max(100) }).strict(); @@ -123,21 +152,12 @@ function projectLiveExecution( thread: Thread, userId: string, candidate: LiveExecutionCandidate, - tracker: ActiveExecutionTracker, ): ActiveExecutionProjection { const canControl = canControlLiveExecution(thread, userId, candidate); - const trackerExecutionId = - tracker.getUserId(thread.id, candidate.catId) === userId - ? tracker.getExecutionId?.(thread.id, candidate.catId) - : undefined; const realExecutionId = candidate.executionId ?? unresolvedExecutionId(thread.id, candidate); const executionId = canControl ? realExecutionId : foreignOccupancyExecutionId(thread.id, candidate.catId, candidate.startedAt); - const controlAvailable = - candidate.controlSource === 'process_owner' || - candidate.controlSource === 'tracker' || - trackerExecutionId === realExecutionId; return { executionId, ...(canControl && candidate.invocationId ? { turnInvocationId: candidate.invocationId } : {}), @@ -146,21 +166,21 @@ function projectLiveExecution( catId: candidate.catId, kind: 'live_invocation', startedAt: candidate.startedAt, - cancelability: - canControl && controlAvailable && candidate.executionId - ? { - state: 'cancelable', - target: { - kind: 'live_invocation', - threadId: thread.id, - catId: candidate.catId, - executionId: realExecutionId, - }, - } - : { - state: 'not_cancelable', - reason: canControl ? 'control_plane_unavailable' : 'foreign_principal', + cancelability: canControl + ? { + state: 'cancelable', + target: { + kind: 'live_invocation', + threadId: thread.id, + catId: candidate.catId, + executionId: realExecutionId, }, + } + : { + state: 'not_cancelable', + reason: 'foreign_principal', + }, + ...(canControl && candidate.inputCapabilities ? { inputCapabilities: candidate.inputCapabilities } : {}), }; } @@ -300,7 +320,7 @@ async function buildActiveExecutionList( })), ); const executions = liveGroups.flatMap(({ thread, candidates }) => - candidates.map((candidate) => projectLiveExecution(thread, userId, candidate, deps.invocationTracker)), + candidates.map((candidate) => projectLiveExecution(thread, userId, candidate)), ); for (const execution of listManagedCommandExecutions(deps.dynamicTaskStore?.getAll() ?? [])) { @@ -330,6 +350,12 @@ function activeProjectionKey(userId: string, projectPath: string): string { export function registerActiveExecutionRoutes(app: FastifyInstance, deps: ActiveExecutionRouteDeps): void { const activeBuilds = new Map>(); + app.get<{ Params: { threadId: string } }>('/api/threads/:threadId/executions/active', async (request, reply) => { + const access = await requireAccessibleThread(request, reply, deps.threadStore, 'read'); + if (!access) return; + return buildActiveExecutionList(access.thread.projectPath, access.userId, request, deps); + }); + app.get<{ Querystring: { projectPath?: string } }>('/api/executions/active', async (request, reply) => { const userId = resolveUserId(request); if (!userId) { @@ -390,35 +416,60 @@ export function registerActiveExecutionRoutes(app: FastifyInstance, deps: Active const { threadId, executionId } = request.params; const { catId } = parsed.data; const liveCandidates = await deps.resolveLiveExecutions(threadId, access.userId, request); - const candidate = liveCandidates.find( + let candidate = liveCandidates.find( (item) => item.catId === catId && item.executionId === executionId && canControlLiveExecution(access.thread, access.userId, item), ); if (!candidate) { - if (deps.isLiveExecutionControlPlaneComplete && !(await deps.isLiveExecutionControlPlaneComplete(request))) { - reply.status(503); - return { - error: '执行控制面暂时不可用,请重试', - code: 'EXECUTION_CONTROL_UNAVAILABLE', - }; + // A foreign principal's execution is occupancy, not a capability. Keep + // its identity masked and never run viewer-scoped cleanup against the + // occupied cat merely because the supplied id is not controllable. + if ( + liveCandidates.some( + (item) => item.catId === catId && !canControlLiveExecution(access.thread, access.userId, item), + ) + ) { + reply.status(409); + return { error: '该执行已结束或无法取消', code: 'EXECUTION_NOT_ACTIVE' }; } const replacement = liveCandidates.find( (item) => item.catId === catId && canControlLiveExecution(access.thread, access.userId, item), ); - reply.status(409); - return { - error: replacement ? '该执行已被更新的回合替代' : '该执行已结束或无法取消', - code: replacement ? 'EXECUTION_REPLACED' : 'EXECUTION_NOT_ACTIVE', - }; + if (replacement?.executionId) { + candidate = replacement; + } else if (deps.reconcileInactiveLiveInvocation) { + const result = await deps.reconcileInactiveLiveInvocation({ + threadId, + userId: access.userId, + catId, + executionId, + request, + }); + if (result.controlPlaneUnavailable) { + reply.status(503); + return { + error: result.error ?? '执行控制面暂时不可用,请重试', + code: result.code ?? 'EXECUTION_CONTROL_UNAVAILABLE', + }; + } + return { + ok: true, + cancelled: false, + reconciled: result.reconciled, + ...(result.replacement ? { replaced: true } : {}), + }; + } else { + return { ok: true, cancelled: false, reconciled: false }; + } } const result = await deps.cancelExactLiveInvocation({ threadId, userId: access.userId, catId, - executionId, + executionId: candidate.executionId ?? executionId, candidate, request, }); @@ -429,6 +480,11 @@ export function registerActiveExecutionRoutes(app: FastifyInstance, deps: Active code: 'EXECUTION_CONTROL_UNAVAILABLE', }; } - return { ok: true, cancelled: result.cancelled }; + return { + ok: true, + cancelled: result.cancelled, + ...(result.reconciled ? { reconciled: true } : {}), + ...(candidate.executionId !== executionId ? { replaced: true } : {}), + }; }); } diff --git a/packages/api/src/routes/backlog.ts b/packages/api/src/routes/backlog.ts index febc3fd4c0..dbec696a99 100644 --- a/packages/api/src/routes/backlog.ts +++ b/packages/api/src/routes/backlog.ts @@ -248,8 +248,8 @@ export const backlogRoutes: FastifyPluginAsync = async (ap let kickoffMessageId = next.kickoffMessageId; if (!kickoffMessageId) { const kickoffMessage = await messageStore.append({ + from: { kind: 'system', service: 'backlog-dispatch' }, userId, - catId: null, threadId, idempotencyKey: `kickoff:${next.id}:${next.dispatchAttemptId}`, content: buildKickoffMessage(next, phase), diff --git a/packages/api/src/routes/callback-a2a-trigger.ts b/packages/api/src/routes/callback-a2a-trigger.ts index 44245849de..da046db7ed 100644 --- a/packages/api/src/routes/callback-a2a-trigger.ts +++ b/packages/api/src/routes/callback-a2a-trigger.ts @@ -1,1232 +1,840 @@ /** * A2A invocation trigger for MCP callback post_message (F27 rewrite). * - * BEFORE F27: callback detected @mentions → spawned independent routeExecution - * → dual-path bug (double-fire + uncontrollable children + infinite recursion) - * - * AFTER F27: callback detected @mentions → pushes targets to parent worklist - * → single path, shared AbortController, shared depth limit - * - * Fallback: if no parent worklist exists (shouldn't happen in practice, - * since callbacks only fire during cat execution), creates a standalone - * invocation as before. + * Callback mentions enter the same InvocationQueue lifecycle as every other + * message. There is no direct routeExecution fallback. */ import type { CatId, RoutingPreflightDecisionV1 } from '@cat-cafe/shared'; -import type { FastifyBaseLogger } from 'fastify'; -import { getDefaultCatId } from '../config/cat-config-loader.js'; import type { ActionSuccessorFence } from '../domains/ball-custody/ActionSuccessorAdmissionService.js'; -import type { IBallCustodyIngest } from '../domains/ball-custody/BallCustodyIngest.js'; -import { buildHandedEvent } from '../domains/ball-custody/ball-custody-events.js'; -import type { TurnCustodyWakeProvenance } from '../domains/ball-custody/TurnCustodyProjectionService.js'; -import { buildA2ADispatchTurnCustodyWake } from '../domains/ball-custody/turn-custody-wake-provenance.js'; import type { InvocationQueue, QueueEntry } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; import { normalizeOwnerAuthProvenance, type OwnerAuthProvenance, } from '../domains/cats/services/agents/invocation/owner-auth-provenance.js'; +import { queueEntryId } from '../domains/cats/services/agents/invocation/queue-ledger/QueueLedger.js'; import { - isTerminalDispositionEvent, - PerCatTerminalDispositionCollector, -} from '../domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.js'; -import { - createCrossThreadQueueEntryFromCustody, - createFanoutQueueCustodyAdmission, - createInitialCrossThreadQueuedMessageCustody, - createInitialFanoutQueuedMessageCustody, - fanoutQueueCarrierIdempotencyKey, - fanoutQueueCustodyAdmissionId, - readCompleteCrossThreadQueueCarrierGroups, - rebindCrossThreadQueueCarrierActionFence, - sameFanoutCustodyIdentity, -} from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; -import { requireInvocationRecordUpdate } from '../domains/cats/services/agents/invocation/require-invocation-record-update.js'; -import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; -import { createA2ASlotTrackingBridge } from '../domains/cats/services/agents/routing/route-helpers.js'; + callerActivityFromMessage, + type DurableA2ALineage, + readDurableA2ALineage, +} from '../domains/cats/services/agents/routing/durable-a2a-lineage.js'; +import type { CallerActivity } from '../domains/cats/services/agents/routing/WorklistRegistry.js'; import { getWorklist, - hasWorklist, peekStreakOnPush, - pushToWorklist, updateStreakOnPush, } from '../domains/cats/services/agents/routing/WorklistRegistry.js'; -import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; -import type { AgentRouter } from '../domains/cats/services/index.js'; -import type { DeliveryCursorStore } from '../domains/cats/services/stores/ports/DeliveryCursorStore.js'; -import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; +import type { CloudDispatchProvenance } from '../domains/cats/services/cloud-bridge/types.js'; import type { + AppendMessageInput, IMessageStore, - QueuedMessageCustody, + LifecycleResponseTerminalPatch, StoredMessage, } from '../domains/cats/services/stores/ports/MessageStore.js'; -import { projectQueueReceipt } from '../domains/cats/services/stores/ports/queued-message-receipt.js'; +import { + commitLifecycleResponseFromAppendInput, + lifecycleResponseTerminalPatchFromAppendInput, + settleLifecycleResponseInputs, +} from '../domains/cats/services/stores/ports/MessageStore.js'; import { inferRoutingContextIntent, + isUserVisibleRoutingPreflightReceipt, preflightRoutingDispatch, type RoutingDispatchPreflightPort, routingDispatchPreflightReceipt, } from '../domains/routing-context/RoutingDispatchPreflightPort.js'; import { wrapWithDispatchSpan } from '../infrastructure/telemetry/dispatch-span.js'; import type { CallerTraceContext } from '../infrastructure/telemetry/genai-semconv.js'; -import type { SocketManager } from '../infrastructure/websocket/index.js'; import { emitQueueUpdated } from '../utils/queue-enrichment.js'; +interface A2ATriggerSocketManager { + emitToUser(userId: string, event: string, data: unknown): void; + broadcastAgentMessage(message: unknown, threadId: string): void; +} + +interface A2ATriggerLogger { + info(obj: unknown, msg?: string): void; + warn(obj: unknown, msg?: string): void; + error(obj: unknown, msg?: string): void; +} + export interface QueueProcessorLike { - onInvocationComplete: QueueProcessor['onInvocationComplete']; - tryAutoExecute?(threadId: string): Promise; - markPromptMessagesSeen?(input: { + requestDrain?(threadId: string): Promise; + registerCallerDispatchInitialTargets?(source: StoredMessage, targetIds: readonly string[]): void; + tryAutoAppendExactEntry?(input: { threadId: string; userId: string; - catId: string; - invocationId: string; - messageIds: readonly string[]; - seenAt: number; - }): Promise; - /** F216 c3 supersede: reuse the force-send abort-resume coordinate system. - * clearPause prevents the aborted invocation's async cleanup from poisoning QueueProcessor state (F39). - * releaseSlot force-frees the per-slot processingSlots mutex so tryAutoExecute sees a free slot. */ - clearPause?(threadId: string, catId?: string): void; + entryId: string; + targetCatId?: string; + }): Promise<{ outcome: 'appended' | 'rejected' }>; + /** F216 c3 supersede: releaseSlot force-frees the per-slot processingSlots + * mutex so the next drain sees a free slot. */ releaseSlot?(threadId: string, catId: string): void; } export interface A2ATriggerDeps { - router: AgentRouter; - invocationRecordStore: IInvocationRecordStore; - socketManager: SocketManager; - invocationTracker?: InvocationTracker; - deliveryCursorStore?: DeliveryCursorStore; + socketManager: A2ATriggerSocketManager; + invocationTracker?: { + has(threadId: string, catId: string): boolean; + cancelInvocation(threadId: string, catIds: string[], userId?: string, reason?: string): unknown; + }; queueProcessor?: QueueProcessorLike; /** #706: MessageStore for queue enrichment (messagePreview in queue_updated SSE). */ messageStore?: IMessageStore; - /** F167 Phase T: persist accepted A2A dispatch custody before the child can execute. */ - ballCustody?: IBallCustodyIngest; /** F293: fresh per-target decision before worklist, queue custody or fallback creation. */ routingDispatchPreflight?: RoutingDispatchPreflightPort; /** F122B: InvocationQueue for agent-sourced entries. - * F-coalesce: + findInFlightAgentEntry / coalesceContentIntoQueuedAgent for same-turn handoff merge. */ + * Same-turn handoffs remain independent scalar ledger rows. */ invocationQueue?: Pick< InvocationQueue, - // F-coalesce: Guard 2 replaced hasQueuedAgentForCat with findInFlightAgentEntry + - // coalesceContentIntoQueuedAgent — the old skip-dedup method is no longer referenced here. - | 'enqueue' + | 'enqueueExistingMessageDurable' + | 'appendAndEnqueueDurable' + | 'terminalizeResponseAndEnqueueDurable' | 'countAgentEntriesForThread' - | 'findInFlightAgentEntry' - | 'coalesceContentIntoQueuedAgent' - | 'backfillMessageId' - | 'commitQueueCustodyAdmission' | 'getEntrySnapshot' | 'list' - | 'rollbackEnqueue' - | 'restoreDurableEntry' - | 'restoreEntrySnapshotIfUnchanged' - // F216 c3: removeProcessed clears the superseded processing entry so it cannot re-run. - | 'removeProcessed' >; - log: FastifyBaseLogger; + log: A2ATriggerLogger; } -function isRecoverableApprovedCarrierSource( - persisted: StoredMessage, - opts: { - targetCats: readonly CatId[]; - content: string; - userId: string; - threadId: string; - triggerMessage: StoredMessage; - callerCatId?: CatId; - actionSuccessorFence?: ActionSuccessorFence; - }, -): boolean { - if ( - !opts.actionSuccessorFence || - !opts.callerCatId || - persisted.deliveryStatus !== undefined || - persisted.queueCustody - ) { - return false; - } - const expectedCrossPost = opts.triggerMessage.extra?.crossPost; - return ( - persisted.id === opts.triggerMessage.id && - persisted.threadId === opts.threadId && - persisted.userId === opts.userId && - persisted.catId === opts.callerCatId && - persisted.content === opts.content && - persisted.replyTo === opts.triggerMessage.replyTo && - persisted.origin === 'callback' && - persisted.extra?.isExplicitPost === true && - persisted.extra.crossPost?.effectClass === 'assign_work' && - persisted.extra.crossPost.effectClass === expectedCrossPost?.effectClass && - typeof persisted.extra.crossPost.sourceThreadId === 'string' && - persisted.extra.crossPost.sourceThreadId.length > 0 && - persisted.extra.crossPost.sourceThreadId === expectedCrossPost?.sourceThreadId && - persisted.extra.crossPost.sourceThreadId !== persisted.threadId && - JSON.stringify(persisted.mentions) === JSON.stringify(opts.targetCats) && - JSON.stringify(persisted.extra.targetCats) === JSON.stringify(opts.targetCats) - ); +export interface A2AFanoutAdmissionPlan { + requestedTargetCats: readonly CatId[]; + acceptedTargetCats: readonly CatId[]; + streakTargetCats: readonly CatId[]; + stop?: + | { reason: 'depth'; catId: CatId; currentDepth: number } + | { reason: 'pingpong'; catId: CatId; pairCount: number }; } -/** - * Enqueue @mentioned cats into the parent's worklist (F27 unified path). - * - * Returns the cats that were actually enqueued. If no parent worklist exists, - * falls back to standalone invocation (legacy path, should be rare). - */ -export async function enqueueA2ATargets( - deps: A2ATriggerDeps, - opts: { - targetCats: CatId[]; - content: string; - userId: string; - /** Inherited unchanged from the authenticated parent invocation, or explicit unknown. */ +export interface AtomicA2ASourceAdmission { + message: StoredMessage; + preAdmittedEntries?: readonly QueueEntry[]; + preAdmittedReplayed?: boolean; +} + +/** Persist public Agent speech and its accepted A2A rows in one storage transaction. */ +export async function appendA2ASourceWithLedgerAdmission( + deps: Pick, + message: AppendMessageInput, + options: { + plan: A2AFanoutAdmissionPlan; ownerAuthProvenance: OwnerAuthProvenance; - threadId: string; - triggerMessage: StoredMessage; - /** The cat that triggered this A2A callback (for worklist caller guard). */ - callerCatId?: CatId; - /** F108: parentInvocationId for concurrent worklist isolation. */ parentInvocationId?: string; - /** F153: caller trace context for cross-route A2A propagation */ callerTraceContext?: CallerTraceContext; - /** F167 Phase S: persistent subject/action/slot generation fence. */ actionSuccessorFence?: ActionSuccessorFence; + cloudDispatchProvenance?: CloudDispatchProvenance; + requiresExactCloudDispatchProvenance?: boolean; }, -): Promise<{ - enqueued: CatId[]; - coalesced?: CatId[]; - fallback: boolean; - routingPreflight?: RoutingPreflightDecisionV1; -}> { - const { log } = deps; - const { threadId, callerCatId } = opts; - const ownerAuthProvenance = normalizeOwnerAuthProvenance(opts.ownerAuthProvenance); - const triggerMessageId = opts.triggerMessage.id; - const { deliveryCursorStore } = deps; - const requestedTargetCats = opts.targetCats; - const routingContextIntent = inferRoutingContextIntent(opts.content); - const routingPreflight = deps.routingDispatchPreflight - ? await preflightRoutingDispatch(deps.routingDispatchPreflight, { - ownerId: opts.userId, - targetCatIds: requestedTargetCats, - ...(routingContextIntent ? { intent: routingContextIntent } : {}), - }) - : undefined; - if (routingPreflight) { - const receiptCatId = callerCatId ?? opts.triggerMessage.catId ?? getDefaultCatId(); - for (const target of routingPreflight.targets) { - if (target.disposition === 'allowed') continue; - deps.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: receiptCatId, - content: JSON.stringify(routingDispatchPreflightReceipt(routingPreflight, target.targetCatId)), - timestamp: Date.now(), - }, - threadId, - ); - } - } - const targetCats = routingPreflight - ? requestedTargetCats.filter( - (catId) => routingPreflight.targets.find((target) => target.targetCatId === catId)?.disposition !== 'rejected', - ) - : requestedTargetCats; - const routingPreflightResult = routingPreflight ? { routingPreflight } : {}; - if (targetCats.length === 0) { - return { enqueued: [], fallback: false, ...routingPreflightResult }; - } - const isCrossThread = - !!opts.triggerMessage.extra?.crossPost?.sourceThreadId && - opts.triggerMessage.extra.crossPost.sourceThreadId !== opts.triggerMessage.threadId; - const requiresDurableQueueCustody = isCrossThread || opts.triggerMessage.deliveryStatus === 'queued'; - let persistedQueueTrigger: StoredMessage | undefined; - if (requiresDurableQueueCustody && deps.invocationQueue) { - if (!deps.messageStore) { - throw new Error('A2A Queue dispatch requires durable message custody'); - } - let persistedTrigger = await deps.messageStore.getById(triggerMessageId); - if (isCrossThread && persistedTrigger && isRecoverableApprovedCarrierSource(persistedTrigger, opts)) { - const prepared = await deps.messageStore.prepareQueueAdmission(triggerMessageId); - if (prepared.kind === 'prepared' || prepared.kind === 'existing') { - persistedTrigger = prepared.message; - } - } - if (!persistedTrigger || persistedTrigger.deliveryStatus !== 'queued') { - throw new Error('A2A Queue dispatch requires one persisted queued source message'); - } - persistedQueueTrigger = persistedTrigger; +): Promise { + if (!deps.messageStore) throw new Error('A2A source admission requires MessageStore'); + if (options.plan.acceptedTargetCats.length === 0) { + return { message: await deps.messageStore.append(message) }; } - // #1200 §8.7: mention-ack cursors must be v2 (visibility-domain). getMentionsFor - // now uses visibility ordering — ack cursors written as raw IDs would mismatch. - // Canonicalize once; reuse in both InvocationQueue and worklist ack paths. - const ackCursor = - deliveryCursorStore && deps.messageStore?.canonicalizeCursor - ? await deps.messageStore.canonicalizeCursor(triggerMessageId, threadId) - : triggerMessageId; - - // F167 Phase E (KD-20): L3 role-gate retired. Role-based handoff permission is - // no longer harness-enforced — cat-config.restrictions flows into sender & target - // prompts (buildTeammateRoster / buildStaticIdentity); cats self-regulate. - const fromCatId = callerCatId ?? opts.triggerMessage.catId ?? getDefaultCatId(); - // F153 Phase I (Maine Coon P1): Lazy-create mention_dispatch span + a2a.dispatch.count counter - // ONLY when a target is about to actually dispatch (passes all guards and reaches a real enqueue - // or fallback invocation). Pre-creating would mint span/counter even when ALL cats are blocked - // by depth limit / dedup / ping-pong streak / empty-conflict fallback — polluting Step Summary - // a2a_dispatch_count with phantom dispatches. - let dispatchTraceContext: CallerTraceContext | undefined; - const ensureDispatchTraceContext = (): CallerTraceContext | undefined => { - if (dispatchTraceContext === undefined && opts.callerTraceContext) { - dispatchTraceContext = wrapWithDispatchSpan(opts.callerTraceContext, targetCats.length, fromCatId); - } - return dispatchTraceContext; + if (!deps.invocationQueue) throw new Error('A2A source admission requires InvocationQueue'); + if (message.from.kind !== 'agent') throw new Error('A2A source admission requires Agent speech'); + const result = await deps.invocationQueue.appendAndEnqueueDurable(deps.messageStore, message, { + from: message.from, + threadId: message.threadId ?? 'default', + userId: message.userId, + kind: 'message_wake', + ownerAuthProvenance: normalizeOwnerAuthProvenance(options.ownerAuthProvenance), + content: message.content, + sourceCategory: 'a2a', + targetCats: [...options.plan.acceptedTargetCats], + intent: 'execute', + autoExecute: true, + a2aParentInvocationId: options.parentInvocationId, + callerTraceContext: options.callerTraceContext + ? wrapWithDispatchSpan(options.callerTraceContext, options.plan.acceptedTargetCats.length, message.from.catId) + : undefined, + ...(options.actionSuccessorFence ? { actionSuccessorFence: options.actionSuccessorFence } : {}), + ...(options.cloudDispatchProvenance ? { cloudDispatchProvenance: options.cloudDispatchProvenance } : {}), + ...(options.requiresExactCloudDispatchProvenance ? { requiresExactCloudDispatchProvenance: true } : {}), + }); + if (result.outcome === 'full') throw new Error('A2A source Queue admission is full'); + return { + message: result.message, + preAdmittedEntries: result.entries, + preAdmittedReplayed: result.deduped, }; +} - // F122B: If InvocationQueue is available, enqueue as agent entry (unified dispatch). - // This replaces both the worklist path and the fallback standalone invocation. - // Guards mirror worklist protections: depth limit, duplicate detection. - if (deps.invocationQueue) { - const MAX_A2A_DEPTH = 10; +interface A2AFanoutAdmissionOptions { + targetCats: readonly CatId[]; + /** Original requested set when routing preflight removed rejected targets. */ + requestedTargetCats?: readonly CatId[]; + content: string; + userId: string; + ownerAuthProvenance: OwnerAuthProvenance; + threadId: string; + createdAt: number; + callerCatId?: CatId; + parentInvocationId?: string; + isCrossThread?: boolean; + actionSuccessorFence?: ActionSuccessorFence; + durableLineage?: DurableA2ALineage; + callerActivity?: CallerActivity; +} - // F167 L1 AC-A4 + Phase D (cloud Codex P1): streak check must cover modern path - // AND only fire when we know the target is actually about to enqueue — otherwise - // a callback that hits depth/dedup would still mutate the counter (reset by - // substantive content, ++ by inertia), weakening the breaker. - // Pre-resolve worklist entry once; updateStreakOnPush is called inside the loop. - const streakCallerCatId = targetCats.length === 1 ? callerCatId : undefined; - const canTrackStreak = streakCallerCatId !== undefined; - const streakEntry = canTrackStreak ? getWorklist(threadId, opts.parentInvocationId) : null; - const streakActivity = { +/** + * Decide the complete A2A fan-out before the source message is published. + * This is deliberately read-only: streak mutation and Queue staging happen + * only after the durable message + admission record has committed. + */ +export function planA2AFanoutAdmission( + deps: Pick, + opts: A2AFanoutAdmissionOptions, +): A2AFanoutAdmissionPlan { + const invocationQueue = deps.invocationQueue; + if (!invocationQueue) throw new Error('A2A dispatch requires InvocationQueue'); + const streakCallerCatId = opts.targetCats.length === 1 ? opts.callerCatId : undefined; + const streakEntry = streakCallerCatId ? getWorklist(opts.threadId, opts.parentInvocationId) : null; + const maxA2ADepth = streakEntry?.maxDepth ?? 10; + const streakActivity = + opts.callerActivity ?? + ({ hadSubstantiveToolCall: false, outputLength: opts.content.length, - } as const; - const findInFlightForTarget = (catId: CatId): QueueEntry | null => - opts.actionSuccessorFence - ? null - : (deps.invocationQueue?.findInFlightAgentEntry?.( - threadId, - catId, - callerCatId, - opts.parentInvocationId, - ownerAuthProvenance, - ) ?? null); + } as const); + const acceptedTargetCats: CatId[] = []; + const streakTargetCats: CatId[] = []; + let stop: A2AFanoutAdmissionPlan['stop']; + const predictedDepth = + opts.durableLineage?.depth ?? streakEntry?.a2aCount ?? invocationQueue.countAgentEntriesForThread(opts.threadId); + const streakState = opts.durableLineage ?? streakEntry; - // The durable admission is the policy decision, not merely the request. - // Decide the complete fan-out before staging any process-local carrier so a - // crash can only reconstruct targets the ordinary enqueue path accepted. - let admittedTargetCats: ReadonlySet | undefined; - const plannedStreakTargets = new Set(); - let plannedStop: - | { reason: 'depth'; catId: CatId; currentDepth: number } - | { reason: 'pingpong'; catId: CatId; pairCount: number } - | undefined; - if (persistedQueueTrigger && !persistedQueueTrigger.queueCustody) { - const existingAdmission = persistedQueueTrigger.queueCustodyAdmission; - if (existingAdmission) { - const admittedRequestTargets = existingAdmission.requestedTargetCats ?? existingAdmission.targetCats; - if (JSON.stringify(admittedRequestTargets) !== JSON.stringify(requestedTargetCats)) { - throw new Error('A2A fan-out Queue custody admission requested-target mismatch'); - } - admittedTargetCats = new Set(existingAdmission.targetCats.filter((catId) => targetCats.includes(catId))); + const firstTargetCatId = opts.targetCats[0]; + if (firstTargetCatId && predictedDepth >= maxA2ADepth) { + stop = { reason: 'depth', catId: firstTargetCatId, currentDepth: predictedDepth }; + } else { + if (streakCallerCatId && streakState && firstTargetCatId) { + const streak = peekStreakOnPush(streakState, streakCallerCatId, firstTargetCatId, streakActivity); + if (streak.wouldBlock) { + stop = { reason: 'pingpong', catId: firstTargetCatId, pairCount: streak.count }; } else { - const acceptedTargetCats: CatId[] = []; - let predictedDepth = deps.invocationQueue.countAgentEntriesForThread(threadId); - for (const catId of targetCats) { - if (predictedDepth >= MAX_A2A_DEPTH) { - plannedStop = { reason: 'depth', catId, currentDepth: predictedDepth }; - break; - } - const inFlight = findInFlightForTarget(catId); - const willCoalesce = inFlight?.status === 'queued'; - if (streakCallerCatId && streakEntry && !willCoalesce) { - const streak = peekStreakOnPush(streakEntry, streakCallerCatId, catId, streakActivity); - if (streak.wouldBlock) { - plannedStop = { reason: 'pingpong', catId, pairCount: streak.count }; - break; - } - plannedStreakTargets.add(catId); - } - acceptedTargetCats.push(catId); - if (!inFlight) predictedDepth += 1; - } - const admission = createFanoutQueueCustodyAdmission(triggerMessageId, { - ownerUserId: opts.userId, - ownerAuthProvenance, - targetCats: acceptedTargetCats, - requestedTargetCats, - intent: 'execute', - ...(callerCatId ? { callerCatId } : {}), - ...(opts.parentInvocationId ? { a2aParentInvocationId: opts.parentInvocationId } : {}), - ...(isCrossThread ? { receiptScope: 'cross_thread_delivery' as const } : {}), - ...(opts.actionSuccessorFence ? { actionSuccessorFence: opts.actionSuccessorFence } : {}), - createdAt: opts.triggerMessage.timestamp, - }); - const messageStore = deps.messageStore; - if (!messageStore) throw new Error('A2A Queue dispatch requires durable message custody'); - const admissionResult = await messageStore.initializeQueueCustodyAdmission(triggerMessageId, admission); - if (admissionResult.kind !== 'initialized' && admissionResult.kind !== 'existing') { - throw new Error(`A2A fan-out Queue custody admission failed: ${admissionResult.kind}`); - } - persistedQueueTrigger = admissionResult.message; - admittedTargetCats = new Set(admissionResult.message.queueCustodyAdmission?.targetCats ?? []); - if (plannedStop?.reason === 'depth') { - log.warn( - { - threadId, - triggerMessageId, - currentDepth: plannedStop.currentDepth, - catId: plannedStop.catId, - }, - '[F122B] A2A callback: depth limit reached, skipping remaining targets', - ); - } else if (plannedStop?.reason === 'pingpong' && streakEntry && streakCallerCatId) { - updateStreakOnPush(streakEntry, streakCallerCatId, plannedStop.catId, streakActivity); - log.info( - { - threadId, - triggerMessageId, - fromCatId, - catId: plannedStop.catId, - pairCount: plannedStop.pairCount, - }, - 'F167 L1: callback A2A (invocationQueue) ping-pong terminated (streak >= 4)', - ); - deps.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: fromCatId, - content: JSON.stringify({ - type: 'a2a_pingpong_terminated', - fromCatId, - targetCatId: plannedStop.catId, - pairCount: plannedStop.pairCount, - }), - timestamp: Date.now(), - }, - threadId, - ); - } + streakTargetCats.push(firstTargetCatId); } } + if (!stop) acceptedTargetCats.push(...opts.targetCats); + } - const enqueued: CatId[] = []; - // F-coalesce: cats whose same-turn handoff was MERGED into an existing queued entry. - // Tracked separately from `enqueued` because callbacks.ts derives body.routed from `enqueued` - // — a coalesce is NOT a new A2A route (routed must stay []), but the caller's intent IS handled - // (no duplicate dispatch, mention cursor still advances). Conflating the two falsely reports - // "已路由" for a merge (the gate-caught regression: callback-a2a-postmsg.test.js). - const coalesced: CatId[] = []; - // One persisted source message owns one canonical fan-out admission. Recovery - // races for that same message must join the same process-local fence instead - // of minting competing tokens for the idempotently deduped Queue carriers. - const queueCustodyAdmissionId = persistedQueueTrigger ? fanoutQueueCustodyAdmissionId(triggerMessageId) : undefined; - const stagedCustodyEntryIds = new Set(); - const acceptedEntryByCatId = new Map(); - const restoredEntryByCatId = new Map(); - const newlyEnqueuedEntryIds: string[] = []; - const coalescedEntryRollbacks = new Map(); - const rollbackDurableAdmissions = (): void => { - for (const entryId of newlyEnqueuedEntryIds) { - deps.invocationQueue?.rollbackEnqueue(threadId, opts.userId, entryId); - } - for (const { before, after } of coalescedEntryRollbacks.values()) { - const restored = deps.invocationQueue?.restoreEntrySnapshotIfUnchanged(after, before) ?? false; - if (!restored) { - log.error( - { threadId, entryId: before.id, triggerMessageId }, - 'durable A2A Queue coalesce rollback lost its exact compare-and-swap owner', - ); - } - } - }; - const queueDiagnostics: Array<{ - catId: CatId; - outcome: string; - entryId?: string; - createdAt?: number; - }> = []; - const persistedTrigger = persistedQueueTrigger; - if (persistedTrigger?.queueCustody?.carrierByTargetCatId && deps.messageStore) { - const existingCustody = persistedTrigger.queueCustody; - const carrierByTargetCatId = existingCustody.carrierByTargetCatId; - if (!carrierByTargetCatId) throw new Error('durable Queue custody carrier projection disappeared'); - const missingEntryIds = new Set( - targetCats.flatMap((catId) => { - const entryId = carrierByTargetCatId[catId]?.entryId; - if (!entryId || deps.invocationQueue?.getEntrySnapshot(threadId, opts.userId, entryId)) return []; - return [entryId]; - }), - ); - if (missingEntryIds.size > 0) { - const carrierMessagesByEntryId = await readCompleteCrossThreadQueueCarrierGroups( - deps.messageStore, - threadId, - opts.userId, - [...missingEntryIds], - ); - for (const entryId of missingEntryIds) { - let carrierMessages = carrierMessagesByEntryId.get(entryId); - if (!carrierMessages) { - throw new Error(`durable Queue carrier group was not enumerated for ${entryId}`); - } - if (!carrierMessages.some((message) => message.id === persistedTrigger.id)) { - throw new Error(`durable Queue carrier group is incomplete for source ${persistedTrigger.id}/${entryId}`); - } - if (opts.actionSuccessorFence) { - carrierMessages = await rebindCrossThreadQueueCarrierActionFence( - deps.messageStore, - carrierMessages, - entryId, - opts.actionSuccessorFence, - ); - } - const restoredProjection = createCrossThreadQueueEntryFromCustody(carrierMessages, entryId); - deps.invocationQueue.restoreDurableEntry(restoredProjection); - } - } - for (const catId of targetCats) { - const entryId = carrierByTargetCatId[catId]?.entryId; - if (!entryId || !existingCustody.pendingTargetCats.includes(catId)) continue; - const snapshot = deps.invocationQueue.getEntrySnapshot(threadId, opts.userId, entryId); - if (!snapshot || !snapshot.targetCats.includes(catId)) { - throw new Error(`restored Queue carrier disappeared or lost target: ${entryId}/${catId}`); - } - restoredEntryByCatId.set(catId, snapshot); - } - } - for (const catId of targetCats) { - if (admittedTargetCats && !admittedTargetCats.has(catId)) continue; - const restoredEntry = restoredEntryByCatId.get(catId); - if (restoredEntry) { - enqueued.push(catId); - acceptedEntryByCatId.set(catId, restoredEntry); - queueDiagnostics.push({ - catId, - outcome: 'restored', - entryId: restoredEntry.id, - createdAt: restoredEntry.createdAt, - }); - continue; - } - // Non-durable calls re-check depth here. Durable calls already persisted - // their complete accepted/rejected partition before carrier staging. - const currentDepth = deps.invocationQueue.countAgentEntriesForThread(threadId); - if (!admittedTargetCats && currentDepth >= MAX_A2A_DEPTH) { - log.warn( - { threadId, triggerMessageId, currentDepth, catId }, - '[F122B] A2A callback: depth limit reached, skipping remaining targets', - ); - break; - } - // Guard 2 (F-coalesce): coalesce a caller's repeated same-turn handoffs to the same cat - // instead of dispatching a duplicate invocation. Replaces the old skip-dedup, which only - // matched 'queued' entries (so a handoff arriving after the first auto-executed into - // 'processing' slipped through and ran as a SECOND independent invocation — the bug: the - // target cat executed the first, possibly-superseded handoff before ever seeing the caller's - // real follow-up intent). - // Action-scoped work has already been single-flighted by its durable lease. - // Do not coalesce it into an unrelated unfenced handoff or supersede current work. - const inFlight = findInFlightForTarget(catId); - if (inFlight) { - if (inFlight.status === 'queued') { - const beforeCoalesce = persistedQueueTrigger - ? deps.invocationQueue.getEntrySnapshot(threadId, inFlight.userId, inFlight.id) - : null; - // Not yet dispatched → merge content in place. The target sees both handoffs as one - // coherent message (parity with user-message collectUserBatch). No duplicate entry. - const merged = - deps.invocationQueue.coalesceContentIntoQueuedAgent?.( - threadId, - inFlight.userId, - inFlight.id, - opts.content, - triggerMessageId, - callerCatId, - opts.parentInvocationId, - ownerAuthProvenance, - catId, - queueCustodyAdmissionId, - ) ?? false; - if (merged) { - if (persistedQueueTrigger) { - const afterCoalesce = deps.invocationQueue.getEntrySnapshot(threadId, inFlight.userId, inFlight.id); - if (!beforeCoalesce || !afterCoalesce) { - throw new Error('coalesced A2A target lost its exact Queue snapshot'); - } - coalescedEntryRollbacks.set(inFlight.id, { before: beforeCoalesce, after: afterCoalesce }); - } - // Merged into an existing queued entry — handled but NOT a new route (see `coalesced` decl). - coalesced.push(catId); - if (queueCustodyAdmissionId) stagedCustodyEntryIds.add(inFlight.id); - acceptedEntryByCatId.set(catId, inFlight); - log.info( - { threadId, triggerMessageId, catId, mergedInto: inFlight.id }, - '[F-coalesce] merged repeated same-turn handoff into queued agent entry', - ); - continue; - } - // Raced to processing between find and merge → fall through to enqueue a follow-up. - } else { - // F216 c3 SUPERSEDE: the first handoff is already processing but the caller sent a - // second same-turn handoff — last-wins semantics. - // - // GUARD: QueueProcessor marks an entry 'processing' (markProcessingById) before - // executeEntry reaches startAll (which registers the tracker slot). In the pre-start - // window (markProcessing → startAll, spans invocationRecordStore.create await), - // tracker.has() returns false and cancelInvocation would return []. If we naively - // releaseSlot + removeProcessed in that window, the old executeEntry (which captured - // the entry reference) keeps running AND the follow-up starts = double-execute. - // - // Solution: only do the full abort-resume sequence when tracker confirms registration. - // Pre-start window → graceful degradation to sequential (follow-up runs after the - // current execution completes via onInvocationComplete → tryAutoExecute). - const trackerRegistered = deps.invocationTracker?.has(threadId, catId) ?? false; - if (trackerRegistered) { - // Safe to abort: tracker has the slot, controller exists. - deps.invocationTracker!.cancelInvocation(threadId, [catId], inFlight.userId, 'preempted'); - // Drop stale pause the aborted invocation's async cleanup will set (F39). - deps.queueProcessor?.clearPause?.(threadId, catId); - // Force-free the per-slot mutex — the async .catch hasn't deleted it yet. - deps.queueProcessor?.releaseSlot?.(threadId, catId); - // Remove the superseded processing entry so it cannot re-run. - deps.invocationQueue?.removeProcessed?.(threadId, inFlight.userId, inFlight.id); - log.info( - { threadId, triggerMessageId, catId, supersededEntry: inFlight.id }, - '[F216-c3] supersede: aborted running handoff, follow-up will restart via tryAutoExecute', - ); - } else { - // Pre-start window: tracker not yet registered (markProcessing → startAll gap). - // Cannot cancel via tracker, but CAN remove the entry as a tombstone signal: - // QueueProcessor.executeEntry checks entry presence after startAll and self-aborts - // if the entry was removed. Do NOT releaseSlot (slot freed by executeEntry's - // finally→.then chain after the self-abort return 'canceled'). - deps.invocationQueue?.removeProcessed?.(threadId, inFlight.userId, inFlight.id); - log.warn( - { threadId, triggerMessageId, catId, supersededEntry: inFlight.id }, - '[F216-c3] supersede tombstone: entry removed for executeEntry guard (pre-start window)', - ); - } - // Fall through to enqueue the follow-up as a queued entry; tryAutoExecute (called after - // enqueue at line ~284) sees the freed slot and auto-starts it. - } - } - // Guard 3 (F167 Phase D cloud Codex P1): streak check fires here — after - // depth + dedup — so a would-be-skipped target never mutates the counter. - // Callback path has no tool_use stream → fail-closed on hadSubstantiveToolCall - // (routing tool ≠ work). outputLength from content still exempts long-form MCP. - if (streakCallerCatId && streakEntry && (!admittedTargetCats || plannedStreakTargets.has(catId))) { - const streak = updateStreakOnPush(streakEntry, streakCallerCatId, catId, streakActivity); - if (!admittedTargetCats && streak.blockPingPong) { - log.info( - { threadId, triggerMessageId, fromCatId, catId, pairCount: streak.count }, - 'F167 L1: callback A2A (invocationQueue) ping-pong terminated (streak >= 4)', - ); - deps.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: fromCatId, - content: JSON.stringify({ - type: 'a2a_pingpong_terminated', - fromCatId, - targetCatId: catId, - pairCount: streak.count, - }), - timestamp: Date.now(), - }, - threadId, - ); - break; - } - // streak.warnPingPong → injected via buildInvocationContext on next turn, no-op here. - } - const carrierIdempotencyKey = opts.actionSuccessorFence - ? `action:${opts.actionSuccessorFence.leaseId}:${opts.actionSuccessorFence.generation}:${catId}` - : persistedQueueTrigger - ? fanoutQueueCarrierIdempotencyKey(triggerMessageId, catId) - : undefined; - const result = deps.invocationQueue.enqueue({ - threadId, + return { + requestedTargetCats: [...(opts.requestedTargetCats ?? opts.targetCats)], + acceptedTargetCats, + streakTargetCats, + ...(stop ? { stop } : {}), + }; +} + +export interface A2ARoutingPreflightPartition { + requestedTargetCats: readonly CatId[]; + acceptedTargetCats: readonly CatId[]; + decision?: RoutingPreflightDecisionV1; +} + +/** + * Resolve the fresh routing partition before any durable Queue admission. + * Warned targets remain eligible; only explicit rejections are removed. + */ +export async function preflightA2ATargets( + deps: Pick, + opts: { targetCats: readonly CatId[]; content: string; userId: string }, +): Promise { + const requestedTargetCats = [...opts.targetCats]; + if (!deps.routingDispatchPreflight) { + return { requestedTargetCats, acceptedTargetCats: requestedTargetCats }; + } + const intent = inferRoutingContextIntent(opts.content); + const decision = await preflightRoutingDispatch(deps.routingDispatchPreflight, { + ownerId: opts.userId, + targetCatIds: requestedTargetCats, + ...(intent ? { intent } : {}), + }); + return { + requestedTargetCats, + acceptedTargetCats: requestedTargetCats.filter( + (catId) => decision.targets.find((target) => target.targetCatId === catId)?.disposition !== 'rejected', + ), + decision, + }; +} + +export function emitA2ARoutingPreflightReceipts( + deps: Pick, + input: { decision?: RoutingPreflightDecisionV1; receiptCatId: CatId; threadId: string }, +): void { + if (!input.decision) return; + for (const target of input.decision.targets) { + // A warned decision is fail-open infrastructure telemetry: the requested + // target is unchanged, so surfacing it as chat content only duplicates the + // per-send preflight and makes a healthy delivery look user-actionable. + const receipt = routingDispatchPreflightReceipt(input.decision, target.targetCatId); + if (!isUserVisibleRoutingPreflightReceipt(receipt)) continue; + deps.socketManager.broadcastAgentMessage( + { + type: 'system_info', + catId: input.receiptCatId, + content: JSON.stringify(receipt), + timestamp: Date.now(), + }, + input.threadId, + ); + } +} + +export async function commitCompletedResponseAndEnqueueA2ATargets( + deps: A2ATriggerDeps, + opts: { + responseMessageId: string; + invocationId: string; + terminal: Pick; + message: AppendMessageInput; + targetCats: CatId[]; + userId: string; + ownerAuthProvenance: OwnerAuthProvenance; + threadId: string; + callerCatId: CatId; + parentInvocationId?: string; + callerTraceContext?: CallerTraceContext; + }, +): Promise { + if (opts.terminal.status !== 'completed') { + throw new Error('completed response A2A wake requires a completed terminal'); + } + if (!deps.messageStore) throw new Error('completed response A2A wake requires MessageStore'); + const causalTriggerMessageId = opts.message.extra?.causal?.triggerMessageId; + const durableLineage = causalTriggerMessageId + ? await readDurableA2ALineage(deps.messageStore, causalTriggerMessageId, opts.callerCatId) + : undefined; + const routingPreflight = await preflightA2ATargets(deps, { + targetCats: opts.targetCats, + content: opts.message.content, + userId: opts.userId, + }); + const admissionOptions: A2AFanoutAdmissionOptions = { + targetCats: routingPreflight.acceptedTargetCats, + requestedTargetCats: routingPreflight.requestedTargetCats, + content: opts.message.content, + userId: opts.userId, + ownerAuthProvenance: normalizeOwnerAuthProvenance(opts.ownerAuthProvenance), + threadId: opts.threadId, + createdAt: opts.terminal.completedAt, + callerCatId: opts.callerCatId, + ...(durableLineage ? { durableLineage } : {}), + callerActivity: callerActivityFromMessage(opts.message), + ...(opts.parentInvocationId ? { parentInvocationId: opts.parentInvocationId } : {}), + }; + const plan = planA2AFanoutAdmission(deps, admissionOptions); + let stored: StoredMessage; + let preAdmittedEntries: readonly QueueEntry[] | undefined; + let preAdmittedReplayed = false; + if (plan.acceptedTargetCats.length > 0) { + if (!deps.invocationQueue) throw new Error('completed response A2A wake requires InvocationQueue'); + const current = await deps.messageStore.getById(opts.responseMessageId); + if (!current) throw new Error(`lifecycle response not found: ${opts.responseMessageId}`); + const terminalPatch = lifecycleResponseTerminalPatchFromAppendInput( + current, + opts.invocationId, + opts.terminal, + opts.message, + ); + const dispatchTraceContext = opts.callerTraceContext + ? wrapWithDispatchSpan(opts.callerTraceContext, plan.acceptedTargetCats.length, opts.callerCatId) + : undefined; + const admission = await deps.invocationQueue.terminalizeResponseAndEnqueueDurable( + deps.messageStore, + opts.responseMessageId, + terminalPatch, + { + from: { kind: 'agent', catId: opts.callerCatId }, + threadId: opts.threadId, userId: opts.userId, - ownerAuthProvenance, - content: opts.content, - source: 'agent', + kind: 'message_wake', + ownerAuthProvenance: normalizeOwnerAuthProvenance(opts.ownerAuthProvenance), + content: opts.message.content, + messageId: opts.responseMessageId, + sourceId: opts.responseMessageId, sourceCategory: 'a2a', - targetCats: [catId], + targetCats: [...plan.acceptedTargetCats], intent: 'execute', autoExecute: true, - queueCustodyAdmissionId, - callerCatId: fromCatId, a2aParentInvocationId: opts.parentInvocationId, - callerTraceContext: ensureDispatchTraceContext(), - a2aTriggerMessageId: triggerMessageId, - ...(carrierIdempotencyKey ? { idempotencyKey: carrierIdempotencyKey } : {}), - ...(opts.actionSuccessorFence ? { actionSuccessorFence: opts.actionSuccessorFence } : {}), - }); - queueDiagnostics.push({ - catId, - outcome: result.outcome, - entryId: result.entry?.id, - createdAt: result.entry?.createdAt, - }); - if (result.outcome === 'enqueued') { - enqueued.push(catId); - if (result.entry) { - acceptedEntryByCatId.set(catId, result.entry); - if (!result.deduped) newlyEnqueuedEntryIds.push(result.entry.id); - if (queueCustodyAdmissionId) stagedCustodyEntryIds.add(result.entry.id); - deps.invocationQueue.backfillMessageId(threadId, opts.userId, result.entry.id, triggerMessageId); - } - } - } - // Best-effort auto-ack mentions (same as worklist path). - // F-coalesce: ack covers BOTH enqueued AND coalesced targets — a coalesced mention WAS handled - // (merged into an existing queued entry), so its cursor must advance too, otherwise the - // merged-away mention lingers as a phantom pending backlog. - const handled = [...enqueued, ...coalesced]; - if (persistedQueueTrigger && targetCats.length > 0) { - const messageStore = deps.messageStore; - if (!messageStore) throw new Error('A2A Queue dispatch requires durable message custody'); - const acceptedEntries = handled - .map((catId) => acceptedEntryByCatId.get(catId)) - .filter((entry): entry is NonNullable => !!entry); - if (acceptedEntries.length !== handled.length) { - rollbackDurableAdmissions(); - throw new Error('accepted A2A target is missing its exact Queue carrier'); - } - let expectedCustody: QueuedMessageCustody; - let initialized: Awaited>; - try { - const custodyOptions = { - requestedTargetCats, - createdAt: opts.triggerMessage.timestamp, - }; - expectedCustody = isCrossThread - ? createInitialCrossThreadQueuedMessageCustody(triggerMessageId, acceptedEntries, custodyOptions) - : createInitialFanoutQueuedMessageCustody(triggerMessageId, acceptedEntries, custodyOptions); - initialized = await messageStore.initializeQueueCustody(triggerMessageId, expectedCustody); - } catch (error) { - rollbackDurableAdmissions(); - throw error; - } - if (initialized.kind === 'not_found' || initialized.kind === 'not_queued') { - rollbackDurableAdmissions(); - throw new Error(`A2A fan-out Queue custody initialization failed: ${initialized.kind}`); - } - if ( - initialized.message.deliveryStatus !== 'queued' || - (initialized.message.queueCustody?.status === 'terminal' && expectedCustody.status !== 'terminal') || - !sameFanoutCustodyIdentity(initialized.message.queueCustody, expectedCustody) - ) { - rollbackDurableAdmissions(); - throw new Error('A2A fan-out Queue custody identity mismatch'); - } - if ( - queueCustodyAdmissionId && - stagedCustodyEntryIds.size > 0 && - !deps.invocationQueue.commitQueueCustodyAdmission(threadId, opts.userId, queueCustodyAdmissionId, [ - ...stagedCustodyEntryIds, - ]) - ) { - rollbackDurableAdmissions(); - throw new Error('A2A fan-out Queue custody admission changed before commit'); - } - if (isCrossThread) { - const queuedMessage = initialized.message; - const queueReceipt = queuedMessage.queueCustody ? projectQueueReceipt(queuedMessage.queueCustody) : undefined; - deps.socketManager.emitToUser(opts.userId, 'messages_queued', { - threadId, - messageIds: [queuedMessage.id], - messages: [ - { - id: queuedMessage.id, - content: queuedMessage.content, - catId: queuedMessage.catId, - timestamp: queuedMessage.timestamp, - mentions: queuedMessage.mentions, - userId: queuedMessage.userId, - ...(queuedMessage.contentBlocks ? { contentBlocks: queuedMessage.contentBlocks } : {}), - extra: { - ...(queuedMessage.extra ?? {}), - ...(queueReceipt ? { queueReceipt } : {}), - }, - ...(queuedMessage.origin ? { origin: queuedMessage.origin } : {}), - ...(queuedMessage.replyTo ? { replyTo: queuedMessage.replyTo } : {}), - ...(queuedMessage.mentionsUser ? { mentionsUser: true } : {}), - }, - ], - }); - } - } - // Phase T: single-recipient queue acceptance is the machine-confirmed handoff boundary. - // Persist it before auto-execution can start so route-serial cannot close the parent against - // a stale holder. A thread ball has one holder: multi-recipient forks must stay on the existing - // receiver-boundary path rather than pre-writing several mutually-overwriting holders. - // BallCustodyIngest is idempotent by sourceEventId, so retries/coalesces safely converge. - if (deps.ballCustody && handled.length === 1) { - const handedAt = Date.now(); - for (const catId of handled) { - try { - await deps.ballCustody.record( - buildHandedEvent({ - threadId, - messageId: triggerMessageId, - fromCatId, - toCatId: catId, - at: handedAt, - }), - ); - } catch (err) { - // BallCustodyIngest is a best-effort shadow projection, while InvocationQueue acceptance - // is the live delivery decision. Once the queue owns this child, a projection write gap - // must remain observable without escaping into MessageDeliveryService's fail-open - // broadcast recovery (which would execute both the parent broadcast and the queued child). - log.warn( - { err, threadId, triggerMessageId, fromCatId, toCatId: catId }, - '[F167 Phase T] accepted A2A queue handoff custody write failed (best-effort)', - ); - } - } - } - if (deliveryCursorStore && handled.length > 0) { - const ackTargets = handled.filter((catId) => opts.triggerMessage.mentions.includes(catId)); - await Promise.allSettled( - ackTargets.map((catId) => deliveryCursorStore.ackMentionCursor(opts.userId, catId, threadId, ackCursor)), - ); - } - // queue_updated emits on BOTH a new entry (enqueued) AND a coalesce (云端 codex R4 P2). - // A coalesce mutates entry.content in place — and the web client's QueueEntryRow renders - // entry.content, replacing QueuePanel state from each queue_updated event. Without emitting on - // coalesce, the user keeps seeing the STALE pre-merge handoff until some later unrelated queue - // event fires, even though the backend will execute the merged content. (My earlier "no visible - // delta" reasoning was wrong: content IS a rendered field. 46 R3 and I both missed the frontend - // render dependency; cloud codex caught it.) Gate on `handled` (enqueued ∪ coalesced). - if (handled.length > 0) { - // F216 AC-D7: use semantically accurate action — 'coalesced' when content was merged - // into an existing entry (no new entry created), 'enqueued' when a new entry was added. - const action = enqueued.length > 0 ? 'enqueued' : 'coalesced'; - await emitQueueUpdated( - deps.socketManager, - opts.userId, - threadId, - deps.invocationQueue.list(threadId, opts.userId), - deps.messageStore ?? null, - action, - ); - } - log.info( - { - threadId, - triggerMessageId, - callerCatId, - targetCats, - queueDiagnostics, - enqueued, + callerTraceContext: dispatchTraceContext, + a2aTriggerMessageId: opts.responseMessageId, }, - '[DIAG/a2a] enqueueA2ATargets queue scan', ); - // Trigger auto-execute for entries whose target slot is free - await deps.queueProcessor?.tryAutoExecute?.(threadId); - log.info( - { threadId, triggerMessageId, enqueued, coalesced, targetCats }, - enqueued.length > 0 - ? '[F122B] A2A callback: enqueued to InvocationQueue' - : '[F122B] A2A callback: no new InvocationQueue entries enqueued', + if (admission.outcome === 'full') throw new Error('completed response A2A Queue admission is full'); + stored = admission.message; + preAdmittedEntries = admission.entries; + preAdmittedReplayed = admission.deduped; + await settleLifecycleResponseInputs(deps.messageStore, stored, opts.responseMessageId); + } else { + stored = await commitLifecycleResponseFromAppendInput( + deps.messageStore, + opts.responseMessageId, + opts.invocationId, + opts.terminal, + opts.message, ); - return { enqueued, coalesced, fallback: false, ...routingPreflightResult }; } - // Legacy path: F27 worklist + standalone fallback (when invocationQueue dep not wired) - // F27: Try to push to parent worklist first - if (hasWorklist(threadId)) { - // F167 Phase D: fail-closed callerActivity — callback has no tool_use stream, - // outputLength from content exempts long-form discussion. - const pushResult = pushToWorklist(threadId, targetCats, callerCatId, opts.parentInvocationId, triggerMessageId, { - hadSubstantiveToolCall: false, - outputLength: opts.content.length, + if (plan.acceptedTargetCats.length === 0) { + emitA2ARoutingPreflightReceipts(deps, { + decision: routingPreflight.decision, + receiptCatId: opts.callerCatId, + threadId: opts.threadId, }); - const enqueued = pushResult.added; - if (enqueued.length > 0) { - // F153 Phase I (Maine Coon round-2 P2): legacy worklist callback dispatch must also - // mint the mention_dispatch span + a2a.dispatch.count counter. Use the lazy helper for - // its side-effects; the returned trace context is unused here because route-serial - // (which consumes the worklist) doesn't accept a callerTraceContext at worklist-push - // time. Empty added / blocked branches still skip this (lazy = idempotent on first call). - ensureDispatchTraceContext(); - if (deliveryCursorStore) { - // F27 + #77: Best-effort auto-ack to prevent surprise backlog when cats later - // call pending-mentions. This intentionally advances the mention-ack cursor - // using the current trigger message ID (cursor semantics, not a per-message receipt). - // - // Best-effort: ack failure should NOT fail /post-message, since the message has - // already been stored/broadcast; failing would cause retries/duplicates and amplify noise. - const ackTargets = enqueued.filter((catId) => opts.triggerMessage.mentions.includes(catId)); - const results = await Promise.allSettled( - ackTargets.map((catId) => deliveryCursorStore.ackMentionCursor(opts.userId, catId, opts.threadId, ackCursor)), - ); - const failed = results - .map((r, i) => ({ r, catId: ackTargets[i] })) - .filter((x): x is { r: PromiseRejectedResult; catId: CatId } => x.r.status === 'rejected'); - if (failed.length > 0) { - log.warn( - { - threadId, - triggerMessageId, - failedAckCats: failed.map((f) => f.catId), - }, - '[F27] A2A callback: mention auto-ack failed (best-effort)', - ); - } - } - log.info( + if (plan.stop?.reason === 'depth') { + deps.log.warn( { - threadId, - triggerMessageId, - enqueued, - targetCats, + threadId: opts.threadId, + triggerMessageId: stored.id, + catId: plan.stop.catId, + currentDepth: plan.stop.currentDepth, }, - '[F27] A2A callback: enqueued targets to parent worklist', - ); - return { enqueued, fallback: false, ...routingPreflightResult }; - } else if (pushResult.reason === 'not_found') { - // F122 AC-A3: Race condition — worklist vanished between hasWorklist() and pushToWorklist(). - // Fall through to standalone invocation path below. - log.warn( - { threadId, triggerMessageId, targetCats }, - '[F27] A2A callback: worklist vanished between has/push, falling back to standalone', + '[F122B] completed response A2A: depth limit reached', ); - } else { - if (pushResult.blockPingPong) { - // F167 L1 AC-A4: streak=4 — callback path must broadcast termination, - // parity with route-serial's inline block emit. - log.info( - { threadId, triggerMessageId, fromCatId, targetCats, pairCount: pushResult.pairCount }, - 'F167 L1: callback A2A ping-pong terminated (streak >= 4)', - ); - deps.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: fromCatId, - content: JSON.stringify({ - type: 'a2a_pingpong_terminated', - fromCatId, - targetCatId: targetCats[0], - pairCount: pushResult.pairCount ?? 0, - }), - timestamp: Date.now(), - }, - threadId, - ); - } else { - log.info( - { - threadId, - triggerMessageId, - targetCats, - reason: pushResult.reason, - }, - `[F27] A2A callback: targets not enqueued (${pushResult.reason})`, - ); + } else if (plan.stop?.reason === 'pingpong') { + const worklist = getWorklist(opts.threadId, opts.parentInvocationId); + if (worklist) { + updateStreakOnPush(worklist, opts.callerCatId, plan.stop.catId, { + hadSubstantiveToolCall: false, + outputLength: opts.message.content.length, + }); } - return { enqueued, fallback: false, ...routingPreflightResult }; + deps.socketManager.broadcastAgentMessage( + { + type: 'system_info', + catId: opts.callerCatId, + content: JSON.stringify({ + type: 'a2a_pingpong_terminated', + fromCatId: opts.callerCatId, + targetCatId: plan.stop.catId, + pairCount: plan.stop.pairCount, + }), + timestamp: Date.now(), + }, + opts.threadId, + ); } + return stored; } - // Fallback: no parent worklist — start standalone invocation. - // F108 slot-aware: tracker.start() only aborts same (threadId, catId) slot, - // so starting codex won't abort opus. Only skip targets already running. - const { invocationTracker } = deps; - if (invocationTracker?.has(threadId)) { - // Guard: shims may not implement getActiveSlots — fall back to empty (allow all) - const activeSlotIds = (invocationTracker.getActiveSlots?.(threadId) ?? []).map((s) => - typeof s === 'string' ? s : s.catId, - ); - const nonConflicting = targetCats.filter((catId) => !activeSlotIds.includes(catId)); - if (nonConflicting.length === 0) { - log.info( - { threadId, targetCats, activeSlotIds }, - '[F27] A2A fallback skipped: all targets already active in thread slots', - ); - return { enqueued: [], fallback: true, ...routingPreflightResult }; - } - if (nonConflicting.length < targetCats.length) { - log.info( - { threadId, targetCats, activeSlotIds, nonConflicting }, - '[F27] A2A fallback: filtered already-active targets, proceeding with remaining', - ); - } - // Proceed with non-conflicting targets only - await triggerA2AInvocation(deps, { - ...opts, - targetCats: nonConflicting, - callerTraceContext: ensureDispatchTraceContext(), + await enqueueA2ATargets(deps, { + targetCats: opts.targetCats, + content: opts.message.content, + userId: opts.userId, + ownerAuthProvenance: opts.ownerAuthProvenance, + threadId: opts.threadId, + triggerMessage: stored, + callerCatId: opts.callerCatId, + ...(opts.parentInvocationId ? { parentInvocationId: opts.parentInvocationId } : {}), + ...(opts.callerTraceContext ? { callerTraceContext: opts.callerTraceContext } : {}), + preplannedAdmission: plan, + ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), + ...(preAdmittedEntries ? { preAdmittedEntries, preAdmittedReplayed } : {}), + }); + return (await deps.messageStore.getById(stored.id)) ?? stored; +} + +/** + * Atomically publish one failed lifecycle response and admit one Queue-only + * control carrier for the exact A2A caller. The failed response remains the + * only public result; this carrier only schedules the caller to consume it. + */ +export async function commitFailedResponseAndEnqueueA2ACaller( + deps: A2ATriggerDeps, + opts: { + responseMessageId: string; + invocationId: string; + terminal: Pick & { status: 'failed' }; + message: AppendMessageInput; + userId: string; + ownerAuthProvenance: OwnerAuthProvenance; + threadId: string; + reporterCatId: CatId; + predecessorCatId: CatId; + parentInvocationId?: string; + callerTraceContext?: CallerTraceContext; + }, +): Promise { + if (!deps.messageStore) throw new Error('failed response A2A report requires MessageStore'); + const routingPreflight = await preflightA2ATargets(deps, { + targetCats: [opts.predecessorCatId], + content: opts.message.content, + userId: opts.userId, + }); + if (!routingPreflight.acceptedTargetCats.includes(opts.predecessorCatId)) { + emitA2ARoutingPreflightReceipts(deps, { + decision: routingPreflight.decision, + receiptCatId: opts.reporterCatId, + threadId: opts.threadId, }); - return { enqueued: nonConflicting, fallback: true, ...routingPreflightResult }; + return commitLifecycleResponseFromAppendInput( + deps.messageStore, + opts.responseMessageId, + opts.invocationId, + opts.terminal, + opts.message, + ); } + if (!deps.invocationQueue) throw new Error('failed response A2A report requires InvocationQueue'); - // Create standalone invocation like the old triggerA2AInvocation - log.warn( + const current = await deps.messageStore.getById(opts.responseMessageId); + if (!current) throw new Error(`lifecycle response not found: ${opts.responseMessageId}`); + const terminalPatch = lifecycleResponseTerminalPatchFromAppendInput( + current, + opts.invocationId, + opts.terminal, + opts.message, + ); + const admission = await deps.invocationQueue.terminalizeResponseAndEnqueueDurable( + deps.messageStore, + opts.responseMessageId, + terminalPatch, { - threadId, - targetCats, + from: { kind: 'agent', catId: opts.reporterCatId }, + threadId: opts.threadId, + userId: opts.userId, + kind: 'message_wake', + ownerAuthProvenance: normalizeOwnerAuthProvenance(opts.ownerAuthProvenance), + content: opts.message.content, + messageId: opts.responseMessageId, + sourceId: opts.responseMessageId, + sourceCategory: 'a2a_failure', + targetCats: [opts.predecessorCatId], + intent: 'execute', + autoExecute: true, + a2aParentInvocationId: opts.parentInvocationId, + callerTraceContext: opts.callerTraceContext + ? wrapWithDispatchSpan(opts.callerTraceContext, 1, opts.reporterCatId) + : undefined, + a2aTriggerMessageId: opts.responseMessageId, }, - '[F27] A2A callback: no parent worklist found, falling back to standalone invocation', ); + if (admission.outcome === 'full') throw new Error('failed response A2A report Queue admission is full'); + const stored = admission.message; + await settleLifecycleResponseInputs(deps.messageStore, stored, opts.responseMessageId); - // F167 PR1 history note: originally this path filtered role-gated targets before - // fallback; Phase E retires L3, so targetCats == opts.targetCats now. Kept the - // explicit spread for intent clarity and future filter hooks. - await triggerA2AInvocation(deps, { ...opts, targetCats, callerTraceContext: ensureDispatchTraceContext() }); - return { enqueued: targetCats, fallback: true, ...routingPreflightResult }; + await enqueueA2ATargets(deps, { + targetCats: [opts.predecessorCatId], + content: opts.message.content, + userId: opts.userId, + ownerAuthProvenance: opts.ownerAuthProvenance, + threadId: opts.threadId, + triggerMessage: stored, + callerCatId: opts.reporterCatId, + ...(opts.parentInvocationId ? { parentInvocationId: opts.parentInvocationId } : {}), + ...(opts.callerTraceContext ? { callerTraceContext: opts.callerTraceContext } : {}), + preplannedAdmission: { + requestedTargetCats: [opts.predecessorCatId], + acceptedTargetCats: [opts.predecessorCatId], + streakTargetCats: [], + }, + ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), + preAdmittedEntries: admission.entries, + preAdmittedReplayed: admission.deduped, + }); + return (await deps.messageStore.getById(stored.id)) ?? stored; } /** - * Legacy standalone invocation (fallback + backward compat). - * Kept for edge cases where callback fires outside a routeSerial context. + * Enqueue @mentioned cats into the canonical InvocationQueue lifecycle. */ -export async function triggerA2AInvocation( +export async function enqueueA2ATargets( deps: A2ATriggerDeps, opts: { targetCats: CatId[]; content: string; userId: string; + /** Inherited unchanged from the authenticated parent invocation, or explicit unknown. */ ownerAuthProvenance: OwnerAuthProvenance; threadId: string; triggerMessage: StoredMessage; + /** The cat that triggered this A2A callback (for worklist caller guard). */ callerCatId?: CatId; + /** F108: parentInvocationId for concurrent worklist isolation. */ + parentInvocationId?: string; /** F153: caller trace context for cross-route A2A propagation */ callerTraceContext?: CallerTraceContext; + /** F167 Phase S: persistent subject/action/slot generation fence. */ + actionSuccessorFence?: ActionSuccessorFence; + cloudDispatchProvenance?: CloudDispatchProvenance; + requiresExactCloudDispatchProvenance?: boolean; + /** Exact policy plan already persisted with a newly appended source message. */ + preplannedAdmission?: A2AFanoutAdmissionPlan; + /** Fresh routing decision already obtained before atomic source/ledger admission. */ + routingPreflightDecision?: RoutingPreflightDecisionV1; + /** + * Register consumer-specific completion observers after canonical Queue custody is durable + * and before any accepted carrier can start. Multi-mention uses this to aggregate sibling + * results without owning a second dispatch/admission implementation. + */ + onQueueEntriesAdmitted?: (entries: readonly QueueEntry[]) => void; + /** Rows atomically admitted with a terminal response before publication side effects run. */ + preAdmittedEntries?: readonly QueueEntry[]; + preAdmittedReplayed?: boolean; }, -): Promise { - const { router, invocationRecordStore, socketManager, invocationTracker, log } = deps; - const { targetCats, content, userId, threadId, triggerMessage } = opts; +): Promise<{ enqueued: CatId[]; coalesced?: CatId[]; routingPreflight?: RoutingPreflightDecisionV1 }> { + if (!deps.invocationQueue || !deps.queueProcessor?.requestDrain) { + throw new Error('A2A dispatch requires InvocationQueue and QueueProcessor'); + } + const { log } = deps; + const { threadId, callerCatId } = opts; const ownerAuthProvenance = normalizeOwnerAuthProvenance(opts.ownerAuthProvenance); - const fromCatId = opts.callerCatId ?? triggerMessage.catId ?? getDefaultCatId(); - const statusCatId = targetCats[0] ?? getDefaultCatId(); - const intent = parseIntent(content, targetCats.length); - - // F108 slot-aware: tracker.start(threadId, catId) only aborts the SAME slot, - // so starting a different cat won't abort the parent. Only skip if all targets - // are already covered by active slots (redundancy short-circuit). - const parentActive = invocationTracker?.has(threadId) ?? false; - if (parentActive) { - const activeCats = (invocationTracker?.getActiveSlots?.(threadId) ?? []).map((s) => - typeof s === 'string' ? s : s.catId, - ); - // Redundant A2A short-circuit (砚砚 4ee660b defense-in-depth): - // if parent already includes all targets, skip entirely. - if (targetCats.length > 0 && targetCats.every((catId) => activeCats.includes(catId))) { - log.info( - { - threadId, - targetCats, - activeCats, - triggerMessageId: triggerMessage.id, - }, - '[callbacks] A2A skipped: target already covered by active parent invocation', - ); - return; - } - // Targets differ from active slots — safe to proceed because - // tracker.start() is slot-aware and won't abort other cats' slots. - log.info( - { - threadId, - targetCats, - activeCats, - triggerMessageId: triggerMessage.id, - }, - '[F27] A2A standalone: parent active in different slots, safe to start new targets', - ); + const triggerMessageId = opts.triggerMessage.id; + const isCrossThread = + !!opts.triggerMessage.extra?.crossPost?.sourceThreadId && + opts.triggerMessage.extra.crossPost.sourceThreadId !== opts.triggerMessage.threadId; + if (!deps.messageStore) { + throw new Error('A2A Queue dispatch requires durable message custody'); } - - const createResult = await invocationRecordStore.create({ + const persistedQueueTrigger = await deps.messageStore.getById(triggerMessageId); + if ( + !persistedQueueTrigger || + persistedQueueTrigger.from?.kind !== 'agent' || + persistedQueueTrigger.deliveryStatus === 'canceled' || + persistedQueueTrigger.visibility === 'whisper' || + persistedQueueTrigger.recall || + persistedQueueTrigger._tombstone + ) { + throw new Error('A2A Queue dispatch requires one persisted public agent source message'); + } + // F167 Phase E (KD-20): L3 role-gate retired. Role-based handoff permission is + // no longer harness-enforced — cat-config.restrictions flows into sender & target + // prompts (buildTeammateRoster / buildStaticIdentity); cats self-regulate. + const fromCatId = persistedQueueTrigger.from.catId as CatId; + if (callerCatId && callerCatId !== fromCatId) { + throw new Error('A2A Queue dispatch caller does not match persisted MessageFrom'); + } + const requestedTargetCats = opts.targetCats; + const routingPreflight = opts.routingPreflightDecision + ? { + requestedTargetCats, + acceptedTargetCats: requestedTargetCats.filter( + (catId) => + opts.routingPreflightDecision?.targets.find((target) => target.targetCatId === catId)?.disposition !== + 'rejected', + ), + decision: opts.routingPreflightDecision, + } + : await preflightA2ATargets(deps, { + targetCats: requestedTargetCats, + content: opts.content, + userId: opts.userId, + }); + emitA2ARoutingPreflightReceipts(deps, { + decision: routingPreflight.decision, + receiptCatId: fromCatId, threadId, - userId, - targetCats, - intent: intent.intent, - idempotencyKey: triggerMessage.id, - actionLeaseCarrier: { kind: 'none' }, }); - - if (createResult.outcome === 'duplicate') return; - - // Safe: no active parent invocation, so tracker.start() won't abort anything unexpected. - const controller = invocationTracker?.start(threadId, statusCatId, userId, targetCats, createResult.invocationId); - if (controller?.signal.aborted) { - invocationTracker?.complete(threadId, statusCatId, controller); - await invocationRecordStore.update(createResult.invocationId, { - status: 'canceled', - }); - return; + const dispatchedTargetCats = new Set( + persistedQueueTrigger.lifecycle?.dispatchRefs?.map((dispatch) => dispatch.targetId) ?? [], + ); + const targetCats = routingPreflight.acceptedTargetCats.filter((catId) => !dispatchedTargetCats.has(catId)); + if (targetCats.length === 0) { + return { + enqueued: [], + ...(routingPreflight.decision ? { routingPreflight: routingPreflight.decision } : {}), + }; } - await invocationRecordStore.update(createResult.invocationId, { - userMessageId: triggerMessage.id, - }); + // F153 Phase I (Maine Coon P1): Lazy-create mention_dispatch span + a2a.dispatch.count counter + // ONLY when a target is about to actually dispatch (passes all guards and reaches a real enqueue + // invocation). Pre-creating would mint span/counter even when ALL cats are blocked + // by depth limit / dedup / ping-pong streak — polluting Step Summary + // a2a_dispatch_count with phantom dispatches. + let dispatchTraceContext: CallerTraceContext | undefined; + const ensureDispatchTraceContext = (): CallerTraceContext | undefined => { + if (dispatchTraceContext === undefined && opts.callerTraceContext) { + dispatchTraceContext = wrapWithDispatchSpan(opts.callerTraceContext, targetCats.length, fromCatId); + } + return dispatchTraceContext; + }; - const { queueProcessor } = deps; + // ADR-043: the ledger row is the complete durable delivery work order. The + // source message remains ordinary History and never carries Queue admission + // or per-target custody mirrors. One source message has one deterministic + // row, so replay converges in the ledger and distinct bodies are never merged. + const admissionOptions: A2AFanoutAdmissionOptions = { + targetCats, + requestedTargetCats, + content: opts.content, + userId: opts.userId, + ownerAuthProvenance, + threadId, + createdAt: opts.triggerMessage.timestamp, + ...(callerCatId ? { callerCatId } : {}), + ...(opts.parentInvocationId ? { parentInvocationId: opts.parentInvocationId } : {}), + ...(isCrossThread ? { isCrossThread: true } : {}), + ...(opts.actionSuccessorFence ? { actionSuccessorFence: opts.actionSuccessorFence } : {}), + }; + const plan = + (opts.preplannedAdmission + ? { + ...opts.preplannedAdmission, + acceptedTargetCats: opts.preplannedAdmission.acceptedTargetCats.filter((catId) => targetCats.includes(catId)), + streakTargetCats: opts.preplannedAdmission.streakTargetCats.filter((catId) => targetCats.includes(catId)), + } + : undefined) ?? + (() => { + const replayEntry = deps.invocationQueue?.getEntrySnapshot(threadId, opts.userId, queueEntryId(triggerMessageId)); + const replayTargets = new Set(targetCats.filter((catId) => replayEntry?.targets.includes(catId))); + const freshTargets = targetCats.filter((catId) => !replayTargets.has(catId)); + const freshPlan = planA2AFanoutAdmission(deps, { ...admissionOptions, targetCats: freshTargets }); + const acceptedFresh = new Set(freshPlan.acceptedTargetCats); + return { + requestedTargetCats: [...requestedTargetCats], + acceptedTargetCats: targetCats.filter((catId) => replayTargets.has(catId) || acceptedFresh.has(catId)), + streakTargetCats: freshPlan.streakTargetCats, + ...(freshPlan.stop ? { stop: freshPlan.stop } : {}), + }; + })(); + if (JSON.stringify(plan.requestedTargetCats) !== JSON.stringify(requestedTargetCats)) { + throw new Error('A2A fan-out admission plan requested-target mismatch'); + } + if (plan.acceptedTargetCats.some((catId) => !targetCats.includes(catId))) { + throw new Error('A2A fan-out admission plan contains an unrequested target'); + } + if ( + opts.preAdmittedEntries?.some((entry) => + entry.targets.some((targetId) => !plan.acceptedTargetCats.includes(targetId as CatId)), + ) + ) { + throw new Error('A2A routing preflight must run before atomic ledger admission'); + } - // Background execution — fire and forget - void (async () => { - let finalStatus: 'succeeded' | 'failed' | 'canceled' = 'failed'; - const terminalDispositions = new PerCatTerminalDispositionCollector({ - targetCatIds: targetCats, - isCanceled: (catId) => invocationTracker?.getSlotState?.(threadId, catId) === 'canceled', - }); - try { - await invocationRecordStore.update(createResult.invocationId, { - status: 'running', + if (plan.stop?.reason === 'depth') { + log.warn( + { threadId, triggerMessageId, currentDepth: plan.stop.currentDepth, catId: plan.stop.catId }, + '[F122B] A2A callback: depth limit reached, skipping source fan-out', + ); + } else if (plan.stop?.reason === 'pingpong' && callerCatId) { + const worklist = getWorklist(threadId, opts.parentInvocationId); + if (worklist) { + updateStreakOnPush(worklist, callerCatId, plan.stop.catId, { + hadSubstantiveToolCall: false, + outputLength: opts.content.length, }); + } + deps.socketManager.broadcastAgentMessage( + { + type: 'system_info', + catId: fromCatId, + content: JSON.stringify({ + type: 'a2a_pingpong_terminated', + fromCatId, + targetCatId: plan.stop.catId, + pairCount: plan.stop.pairCount, + }), + timestamp: Date.now(), + }, + threadId, + ); + } - // #768: Defer intent_mode broadcast until CLI produces first event. - let intentModeBroadcast = false; - - // F070: track governance block errorCode for recoverable failure marking - let governanceErrorCode: string | undefined; - - for await (const msg of router.routeExecution(userId, content, threadId, triggerMessage.id, targetCats, intent, { - ownerAuthProvenance, - humanDispositionInvocationOrigin: 'a2a', - turnCustodyWakeForCat: (catId) => - buildA2ADispatchTurnCustodyWake({ - threadId, - targetCatId: catId, - messageId: triggerMessage.id, - fromCatId, - }), - ...(controller?.signal ? { signal: controller.signal } : {}), - ...createA2ASlotTrackingBridge( - invocationTracker, - controller ?? new AbortController(), - createResult.invocationId, - ), - ...(deps.invocationQueue - ? { - deferA2AEnqueue: (entry: Parameters[0]) => - deps.invocationQueue?.enqueue({ ...entry, ownerAuthProvenance }), - } - : {}), - parentInvocationId: createResult.invocationId, - onPromptMessagesExposed: (input) => queueProcessor?.markPromptMessagesSeen?.(input) ?? Promise.resolve(), - callerTraceContext: opts.callerTraceContext, - a2aTriggerMessageId: triggerMessage.id, - // F222 P1: A2A direct execution is not user-origin — suppress frustration detection - frustrationAutoIssueEligible: false, - })) { - // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. - if (!intentModeBroadcast) { - socketManager.broadcastToRoom(`thread:${threadId}`, 'intent_mode', { - threadId, - mode: intent.intent, - targetCats, - invocationId: createResult.invocationId, - }); - intentModeBroadcast = true; - } - if (controller?.signal.aborted) break; - terminalDispositions.observe(msg); - if (isTerminalDispositionEvent(msg) && msg.catId) { - invocationTracker?.completeSlot?.(threadId, msg.catId, controller); - } - if (msg.type === 'done' && msg.errorCode) { - governanceErrorCode = msg.errorCode; - } - // F194 Phase Z9 (砚砚 R1 P1-2): unified visible turn stamp via helper. - socketManager.broadcastAgentMessage( - { ...msg, ...stampVisibleTurn(createResult.invocationId, msg.invocationId) }, - threadId, - ); - } - - if (controller?.signal.aborted) { - finalStatus = 'canceled'; - await invocationRecordStore.update(createResult.invocationId, { - status: 'canceled', - }); - } else if (governanceErrorCode) { - // F070: Governance gate blocked — mark as failed with errorCode for retry - finalStatus = 'failed'; - await invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - error: governanceErrorCode, + const pendingAcceptedTargetCats = plan.acceptedTargetCats.filter((catId) => targetCats.includes(catId)); + for (const catId of pendingAcceptedTargetCats) { + if (plan.streakTargetCats.includes(catId) && callerCatId) { + const worklist = getWorklist(threadId, opts.parentInvocationId); + if (worklist) { + updateStreakOnPush(worklist, callerCatId, catId, { + hadSubstantiveToolCall: false, + outputLength: opts.content.length, }); - } else { - const successfulCatIds = terminalDispositions.getSuccessfulCatIds() as CatId[]; - if (successfulCatIds.length === 0) { - finalStatus = 'failed'; - await invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - error: - terminalDispositions.getPrimaryTerminalError() ?? 'all targeted cats completed without a success witness', - }); - } else { - await requireInvocationRecordUpdate({ - store: invocationRecordStore, - invocationId: createResult.invocationId, - update: { - status: 'succeeded', - successfulCatIds, - }, - writer: 'standalone A2A callback', - }); - finalStatus = 'succeeded'; - } } - } catch (err) { - if (controller?.signal.aborted) { - finalStatus = 'canceled'; - await invocationRecordStore.update(createResult.invocationId, { - status: 'canceled', - }); - } else { - log.error(`[callbacks] Standalone A2A invocation failed: ${String(err)}`); - try { - await invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - ...(err instanceof Error ? { error: err.message } : {}), - }); - } catch { - /* best-effort */ + } + } + + const enqueued: CatId[] = []; + const coalesced: CatId[] = []; + const acceptedEntries: QueueEntry[] = []; + const queueDiagnostics: Array<{ targetCats: CatId[]; outcome: string; entryId?: string; createdAt?: number }> = []; + const preAdmittedEntry = opts.preAdmittedEntries?.find((entry) => + pendingAcceptedTargetCats.every((catId) => entry.targets.includes(catId)), + ); + if (pendingAcceptedTargetCats.length > 0) { + const idempotencyKey = opts.actionSuccessorFence + ? `action:${opts.actionSuccessorFence.leaseId}:${opts.actionSuccessorFence.generation}` + : `a2a:${triggerMessageId}`; + const result = preAdmittedEntry + ? { + outcome: 'enqueued' as const, + entry: preAdmittedEntry, + deduped: opts.preAdmittedReplayed === true, } - socketManager.broadcastAgentMessage( - { - type: 'error', - catId: statusCatId, - error: err instanceof Error ? err.message : String(err), - timestamp: Date.now(), - }, + : await deps.invocationQueue.enqueueExistingMessageDurable(deps.messageStore, triggerMessageId, { + from: { kind: 'agent', catId: fromCatId }, threadId, - ); - socketManager.broadcastAgentMessage( - { - type: 'done', - catId: statusCatId, - isFinal: true, - timestamp: Date.now(), - }, - threadId, - ); + userId: opts.userId, + kind: 'message_wake', + ownerAuthProvenance, + content: opts.content, + messageId: triggerMessageId, + sourceId: triggerMessageId, + sourceCategory: 'a2a', + targetCats: pendingAcceptedTargetCats, + intent: 'execute', + autoExecute: true, + a2aParentInvocationId: opts.parentInvocationId, + callerTraceContext: ensureDispatchTraceContext(), + a2aTriggerMessageId: triggerMessageId, + idempotencyKey, + ...(opts.actionSuccessorFence ? { actionSuccessorFence: opts.actionSuccessorFence } : {}), + ...(opts.cloudDispatchProvenance ? { cloudDispatchProvenance: opts.cloudDispatchProvenance } : {}), + ...(opts.requiresExactCloudDispatchProvenance ? { requiresExactCloudDispatchProvenance: true } : {}), + }); + queueDiagnostics.push({ + targetCats: pendingAcceptedTargetCats, + outcome: result.outcome, + ...('entry' in result && result.entry ? { entryId: result.entry.id, createdAt: result.entry.enqueuedAt } : {}), + }); + if (result.outcome === 'enqueued') { + // A failure carrier is an exact scheduling reference to an existing + // response, not a new business dispatch authored by the reporter. + if (result.entry?.sourceCategory !== 'a2a_failure') { + deps.queueProcessor.registerCallerDispatchInitialTargets?.(persistedQueueTrigger, pendingAcceptedTargetCats); } - } finally { - if (controller) { - invocationTracker?.complete(threadId, statusCatId, controller); + if (result.deduped) { + coalesced.push(...pendingAcceptedTargetCats); + } else if (result.entry) { + enqueued.push(...pendingAcceptedTargetCats); + acceptedEntries.push(result.entry); } - queueProcessor - ?.onInvocationComplete( + } + } + + opts.onQueueEntriesAdmitted?.(acceptedEntries); + if (deps.queueProcessor.tryAutoAppendExactEntry) { + for (const entry of acceptedEntries) { + for (const targetCatId of entry.targets) { + await deps.queueProcessor.tryAutoAppendExactEntry({ threadId, - statusCatId, - finalStatus, - createResult.invocationId, - finalStatus === 'succeeded' ? terminalDispositions.getSuccessfulCatIds() : [], - false, - terminalDispositions.getTerminalInvocationIdByCatId(), - [], - terminalDispositions.getTerminalConsumptionByInvocationId(), - ) - .catch(() => { - /* best-effort */ + userId: opts.userId, + entryId: entry.id, + targetCatId, }); + } } - })(); + } + if (enqueued.length > 0) { + await emitQueueUpdated( + deps.socketManager, + opts.userId, + threadId, + deps.invocationQueue.list(threadId, opts.userId), + deps.messageStore, + 'enqueued', + ); + } + log.info( + { threadId, triggerMessageId, callerCatId, targetCats, queueDiagnostics, enqueued }, + '[DIAG/a2a] enqueueA2ATargets single-ledger admission', + ); + await deps.queueProcessor.requestDrain(threadId); + return { + enqueued, + ...(coalesced.length > 0 ? { coalesced } : {}), + ...(routingPreflight.decision ? { routingPreflight: routingPreflight.decision } : {}), + }; } diff --git a/packages/api/src/routes/callback-auth-system-message.ts b/packages/api/src/routes/callback-auth-system-message.ts index 29d9d19032..06ff4373c1 100644 --- a/packages/api/src/routes/callback-auth-system-message.ts +++ b/packages/api/src/routes/callback-auth-system-message.ts @@ -236,8 +236,8 @@ export class CallbackAuthSystemMessageNotifier { try { const block = buildAuthFailureBlock({ ...params, failedAt: now }); stored = await this.messageStore.append({ + from: { kind: 'system', service: 'callback-auth' }, userId: params.userId, - catId: null, content: `[callback-auth] ${params.tool} → ${params.reason}${params.fallbackOk ? ' (fallback ok)' : ''}`, mentions: [], timestamp: now, diff --git a/packages/api/src/routes/callback-hold-ball-cancel-routes.ts b/packages/api/src/routes/callback-hold-ball-cancel-routes.ts index 6477e8dac3..5d7e0c04f2 100644 --- a/packages/api/src/routes/callback-hold-ball-cancel-routes.ts +++ b/packages/api/src/routes/callback-hold-ball-cancel-routes.ts @@ -30,7 +30,7 @@ import { isPendingHoldBallTask, readHoldLifecycle, } from './hold-ball-cancel.js'; -import { HOLD_BALL_SOURCE } from './hold-ball-source.js'; +import { persistHoldTerminalVisibility } from './hold-ball-terminal-visibility.js'; const log = createModuleLogger('routes/callback-hold-ball-cancel'); @@ -389,25 +389,18 @@ export function registerHoldBallCancelRoutes(app: FastifyInstance, deps: HoldBal try { const cancelMessage = cancellationVisibilityMessage(catId, cancellationState); - const stored = await messageStore.append({ - userId: 'system', - catId: null, - content: cancelMessage, - mentions: [], - timestamp: Date.now(), - threadId, - source: HOLD_BALL_SOURCE, - }); - socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: stored.id, - type: 'connector', - content: stored.content, - source: HOLD_BALL_SOURCE, - timestamp: stored.timestamp, + await persistHoldTerminalVisibility( + { messageStore, socketManager }, + { + taskId, + threadId, + userId: access.owner.userId ?? access.actor.userId, + content: cancelMessage, + catId, + outcome: cancellationState, + idempotencySuffix: 'cancel', }, - }); + ); } catch (err) { log.warn({ taskId, threadId, err }, 'F167 Phase J: failed to post hold cancel visibility message'); } diff --git a/packages/api/src/routes/callback-hold-ball-routes.ts b/packages/api/src/routes/callback-hold-ball-routes.ts index 1e3c3df2f4..98c44c9c60 100644 --- a/packages/api/src/routes/callback-hold-ball-routes.ts +++ b/packages/api/src/routes/callback-hold-ball-routes.ts @@ -3,11 +3,9 @@ * POST /api/callbacks/hold-ball — register ball hold + schedule wake-up via reminder template * * Semantic note (gpt52 review on PR #1289): - * The hold counter is a SLIDING WINDOW counter (single-bucket, lastAt-anchored). + * The hold counter is a ROLLING WINDOW counter, not a true "consecutive" counter. * A cat can hold up to MAX_HOLDS_PER_WINDOW times within HOLD_WINDOW_MS per - * (threadId, catId); each successful hold advances lastAt to now, so the 1h - * expiry slides forward from the most recent success — not per-item. Rejected - * 429 calls do NOT advance the window. State is process-local + * (threadId, catId); the window slides on each increment. State is process-local * (in-memory Map) — best-effort only. API restart or multi-instance deployments * will reset the counter. Durable enforcement would require sharing state with the * reminder scheduler; that is intentionally deferred. @@ -16,13 +14,9 @@ import type { SchedulerAwaitStateV1, WaitOwnerFence } from '@cat-cafe/shared'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { - A2ADispatchDispositionError, - type A2ADispatchDispositionService, -} from '../domains/ball-custody/A2ADispatchDispositionService.js'; import type { IBallCustodyIngest } from '../domains/ball-custody/BallCustodyIngest.js'; -import { buildHeldEvent, buildWakeConditionMetEvent } from '../domains/ball-custody/ball-custody-events.js'; import { + cancelDurableManagedGateJob, createDurableManagedGateJob, DURABLE_GATE_WALL_SLA_MS, type DurableManagedGateJob, @@ -31,20 +25,15 @@ import { settleDurableManagedGateJobFromRunner, } from '../domains/ball-custody/durable-managed-gate-job.js'; import { - buildAdmissionFactIdempotencyKey, createInitialManagedCommandWakeProjection, + type ManagedCommandWakeRecoveryResult, ManagedCommandWakeRecoverySweep, type RecordManagedCommandCompletionInput, } from '../domains/ball-custody/ManagedCommandWakeRecoverySweep.js'; -import { - ManagedHoldDispositionError, - type ManagedHoldDispositionService, -} from '../domains/ball-custody/ManagedHoldDispositionService.js'; import type { InvocationRecord as CallbackInvocationRecord, InvocationRegistry, } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; -import type { OwnerAuthProvenance } from '../domains/cats/services/owner-auth-provenance.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; @@ -71,6 +60,7 @@ import { readHoldLifecycle, } from './hold-ball-cancel.js'; import { HOLD_BALL_SOURCE } from './hold-ball-source.js'; +import { persistHoldTerminalVisibility } from './hold-ball-terminal-visibility.js'; import { resolveManagedHoldTriggerUserId } from './managed-hold-trigger-user.js'; const log = createModuleLogger('routes/callback-hold-ball'); @@ -227,6 +217,11 @@ export function getActiveRunnerCount(): number { return activeRunners.size; } +/** Runtime-only liveness proof used by startup/periodic recovery. */ +export function isManagedWakeRunnerActive(taskId: string): boolean { + return activeRunners.has(taskId); +} + interface PreparedManagedWakeRunner { registryKey: string; entry: ActiveManagedRunner; @@ -358,7 +353,7 @@ export interface HoldBallRouteDeps { */ taskStore?: CrossStoreTaskStore; invocationRecordStore: IInvocationRecordStore; - managedCommandWakeRecovery?: Pick & + managedCommandWakeRecovery?: Pick & Partial>; /** * F167 Phase P: invocation trigger for wakeWhen command completion. @@ -373,14 +368,9 @@ export interface HoldBallRouteDeps { message: string, messageId: string, contentBlocks?: undefined, - policy?: { sourceCategory?: string; forceQueue?: boolean; ownerAuthProvenance?: OwnerAuthProvenance }, + policy?: { sourceCategory?: string; forceQueue?: boolean }, ): Promise<'dispatched' | 'enqueued' | 'full'>; }; - /** F167×F254: exact current-wake terminal producer. */ - managedHoldDispositionService?: Pick & - Partial>; - /** F167: exact ordinary A2A dispatch terminal producer. */ - a2aDispatchDispositionService?: Pick; } export async function resolveHoldWaitOwnerFence( @@ -434,6 +424,25 @@ function launchWakeWhenRunner(opts: { const { wakeWhen, reason, nextStep, threadId, catId, taskId, deps, prepared, durableJob } = opts; const { registryKey, entry: activeEntry } = prepared; const { runner } = activeEntry; + const invokeTrigger = deps.invokeTrigger; + const recovery = + deps.managedCommandWakeRecovery ?? + new ManagedCommandWakeRecoverySweep({ + dynamicTaskStore: deps.dynamicTaskStore, + messageStore: deps.messageStore, + socketManager: deps.socketManager, + taskRunner: deps.taskRunner, + invocationRecordStore: deps.invocationRecordStore, + getInvokeTrigger: () => + invokeTrigger + ? { + trigger: async (...args) => { + const outcome = await invokeTrigger.trigger(...args); + return outcome === 'dispatched' ? 'enqueued' : outcome; + }, + } + : undefined, + }); let resolveExternalAdmission!: (result: import('../infrastructure/managed-runner.js').SpawnAdmission) => void; const admissionPromise = new Promise((resolve) => { @@ -441,6 +450,7 @@ function launchWakeWhenRunner(opts: { }); void (async () => { + let observedCompletion: RecordManagedCommandCompletionInput | undefined; try { const pending = activeRunners.get(registryKey); if (pending !== activeEntry || pending.taskId !== taskId) { @@ -456,6 +466,8 @@ function launchWakeWhenRunner(opts: { } const admitted = activeRunners.get(registryKey); if (admitted !== activeEntry || admitted.taskId !== taskId || admitted.phase !== 'pending_launch') { + await recovery.recordLost(taskId, 'spawn_failed', 'phase_mismatch'); + if (activeRunners.get(registryKey) === activeEntry) activeRunners.delete(registryKey); resolveExternalAdmission({ spawned: false, pid: null, error: 'phase_mismatch' }); return; } @@ -477,11 +489,11 @@ function launchWakeWhenRunner(opts: { const spawnResult = await spawnAdmission; if (!spawnResult.spawned) { - // Spawn failed — clean up registry and scheduler resources. + const recoveryResult = await recovery.recordLost(taskId, 'spawn_failed', spawnResult.error); if (activeRunners.get(registryKey) === activeEntry) activeRunners.delete(registryKey); log.warn( - { threadId, catId, command: wakeWhen.command, error: spawnResult.error }, - 'F167 Phase P: wakeWhen spawn failed — cleaning up, fallback reminder will fire', + { threadId, catId, command: wakeWhen.command, error: spawnResult.error, recoveryResult }, + 'F167 Phase P: wakeWhen spawn failed — durable terminal recovery recorded', ); resolveExternalAdmission(spawnResult); // Still await commandDone to drain resources @@ -507,18 +519,8 @@ function launchWakeWhenRunner(opts: { ...(result.tailOutput ? { tailOutput: result.tailOutput } : {}), }, }; + observedCompletion = completion; if (durableJob) settleDurableManagedGateJobFromRunner(durableJob, completion.result); - const recovery = - deps.managedCommandWakeRecovery ?? - new ManagedCommandWakeRecoverySweep({ - dynamicTaskStore: deps.dynamicTaskStore, - messageStore: deps.messageStore, - socketManager: deps.socketManager, - taskRunner: deps.taskRunner, - invocationRecordStore: deps.invocationRecordStore, - getInvokeTrigger: () => deps.invokeTrigger, - }); - // F295: an ordinary user message can retire the wake carrier, but it is // not an execution-scoped cancellation request. Let the independent // command finish, retain its exact terminal evidence, and suppress the @@ -556,21 +558,6 @@ function launchWakeWhenRunner(opts: { const ready = await enterManagedWakeDelivery(registryKey, runner, taskId); if (!ready) return; - deps.ballCustody - ?.record( - buildWakeConditionMetEvent({ - threadId, - catId, - taskId, - command: wakeWhen.command, - exitCode: result.exitCode, - timedOut: result.timedOut, - durationMs: result.durationMs, - at: Date.now(), - }), - ) - .catch((err) => log.warn({ threadId, catId, err }, 'F167 Phase P: failed to record ball.wake_condition_met')); - const recoveryResult = await recovery.recordCompletion(completion); log.info( @@ -592,13 +579,31 @@ function launchWakeWhenRunner(opts: { // never hangs on `await admissionPromise`. Calling resolve multiple // times is a no-op — only the first call wins. resolveExternalAdmission({ spawned: false, pid: null, error: 'internal_error' }); - // Clean up registry on unexpected failure too - if (activeRunners.get(registryKey)?.runner === runner) { - activeRunners.delete(registryKey); + let recoveryResult: ManagedCommandWakeRecoveryResult = 'pending'; + try { + recoveryResult = + activeEntry.phase === 'delivering' && observedCompletion + ? await recovery.recordCompletion(observedCompletion) + : await recovery.recordLost( + taskId, + activeEntry.phase === 'running' ? 'runner_failed' : 'spawn_failed', + err instanceof Error ? err.message : String(err), + ); + } catch (recoveryErr) { + log.error( + { threadId, catId, taskId, recoveryErr }, + 'F167 Phase P: managed-command recovery failed; durable task retained for the recovery sweep', + ); + } finally { + // Registry ownership is process-local and must never survive an + // unexpected recovery exception after the child has terminated. + if (activeRunners.get(registryKey)?.runner === runner) { + activeRunners.delete(registryKey); + } } log.error( - { threadId, catId, command: wakeWhen.command, err }, - 'F167 Phase P: wakeWhen runner failed — fallback reminder will still fire', + { threadId, catId, command: wakeWhen.command, err, recoveryResult }, + 'F167 Phase P: wakeWhen runner failed — durable terminal recovery recorded', ); } })(); @@ -710,8 +715,8 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB const holdKey = `${threadId}:${catIdStr}`; const holdEntry = holdCounts.get(holdKey); const rejectNow = Date.now(); - // +1: getHoldCount clears at strict `>` (now - lastAt > HOLD_WINDOW_MS), - // so lastAt + HOLD_WINDOW_MS is the last rejecting moment; +1 is the first admit. + // getHoldCount clears only after the strict boundary, so +1ms is the + // first instant at which the next hold can be admitted. const retryAtMs = holdEntry ? holdEntry.lastAt + HOLD_WINDOW_MS + 1 : rejectNow; const retryAfterMs = Math.max(0, retryAtMs - rejectNow); log.warn( @@ -721,7 +726,7 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB reply.status(429); return { error: - `maxHoldsPerWindow (${MAX_HOLDS_PER_WINDOW} per ~1h sliding window) reached. ` + + `maxHoldsPerWindow (${MAX_HOLDS_PER_WINDOW} per ~1h window) reached. ` + 'You MUST pass the ball now: @ another cat or @co-creator.', holdsInWindow: currentCount, maxHoldsPerWindow: MAX_HOLDS_PER_WINDOW, @@ -741,7 +746,7 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB // F167 Phase G (KD-23): single-slot semantics. Before scheduling a new hold // wake, cancel + remove any pending hold task for the same (threadId, catId). // Keyed on `createdBy === 'hold-ball:{catId}'` + `deliveryThreadId === threadId`. - // Per-cat sliding window counter is orthogonal (still enforced above). + // Per-cat rolling window counter is orthogonal (still enforced above). // // P1 fix (cloud Codex review on c04c5552a): the old sequence was // "cancel prior → insert new → register new", so if insert/register threw @@ -903,9 +908,93 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB // visibility persistence) can now race only through this canonical slot. const preparedWakeRunner = wakeWhen ? prepareWakeWhenRunner(threadId, catIdStr, taskId) : undefined; - deps.ballCustody - ?.record(buildHeldEvent({ threadId, catId: catIdStr, fireAt, at: Date.now() })) - .catch((err) => log.warn({ threadId, catId: catIdStr, taskId, err }, 'F233 PR3: failed to record ball.held')); + // The waiting History row is the durable, shared statement that this hold + // exists. Commit it before replacing the prior slot: if History is down, + // the new scheduler row is rolled back and the previous wake remains + // authoritative instead of silently leaving the thread with zero visible + // waits (or zero wake carriers). + const wakeAtStr = new Date(fireAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + const holdMessage = wakeWhen + ? `等待中:${reason}\n条件:命令「${wakeWhen.command}」完成\n之后:${nextStep}` + : `等待中:${reason}\n预计 ${wakeAtStr} 唤醒\n之后:${nextStep}`; + const holdSource = { + ...HOLD_BALL_SOURCE, + meta: { + managedHold: true, + phase: 'waiting', + mode: wakeWhen ? 'command' : 'timer', + taskId, + threadId, + catId: catIdStr, + }, + }; + let storedWaitingMessage: Awaited>; + const waitingIdempotencyKey = `hold-ball-waiting:${taskId}`; + try { + storedWaitingMessage = await messageStore.append({ + from: { kind: 'system', service: 'hold-ball' }, + userId: triggerUserId, + content: holdMessage, + mentions: [], + timestamp: Date.now(), + threadId, + idempotencyKey: waitingIdempotencyKey, + source: holdSource, + }); + } catch (err) { + let committedAfterError: Awaited>; + try { + committedAfterError = + typeof messageStore.getByIdempotencyKey === 'function' + ? await messageStore.getByIdempotencyKey(triggerUserId, threadId, waitingIdempotencyKey) + : null; + } catch (lookupErr) { + // The append outcome is ambiguous. Keep the scheduler row and runner + // reservation so startup/idempotent retry can recover the exact hold. + log.error( + { threadId, catId: catIdStr, taskId, err, lookupErr }, + 'F167 Phase G: waiting History outcome is unknown; preserving the recoverable hold carrier', + ); + reply.status(503); + return { error: 'Waiting state outcome is unknown', code: 'HOLD_WAITING_HISTORY_OUTCOME_UNKNOWN' }; + } + if (committedAfterError) { + storedWaitingMessage = committedAfterError; + } else { + cancelManagedWakeIfTaskMatches(taskId, threadId, catIdStr); + taskRunner.unregister(taskId); + dynamicTaskStore.remove(taskId); + if (durableJob) { + cancelDurableManagedGateJob(durableJob, { + cancelledBy: 'hold_registration_rollback', + reason: 'waiting_history_append_failed', + }); + } + log.error( + { threadId, catId: catIdStr, taskId, err }, + 'F167 Phase G: waiting History append failed — rolled back new hold; prior hold retained', + ); + reply.status(503); + return { error: 'Failed to persist hold waiting state', code: 'HOLD_WAITING_HISTORY_UNAVAILABLE' }; + } + } + try { + socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { + threadId, + message: { + id: storedWaitingMessage.id, + type: 'connector', + content: storedWaitingMessage.content, + source: holdSource, + timestamp: storedWaitingMessage.timestamp, + }, + }); + } catch (err) { + log.warn( + { threadId, catId: catIdStr, taskId, err }, + 'F167 Phase G: waiting History persisted but live broadcast failed', + ); + } // Cancel prior pending holds (best-effort — failure here leaves an extra // stale wake, not zero wakes, the milder failure mode). Telemetry: F192 @@ -937,6 +1026,10 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB priorLifecycle?.mode === 'wake_when' ? (prior.params.holdLifecycle as Record).managedCommand : undefined; + const priorCatId = prior.createdBy.startsWith('hold-ball:') + ? prior.createdBy.slice('hold-ball:'.length) + : catIdStr; + const priorUserId = typeof prior.params.triggerUserId === 'string' ? prior.params.triggerUserId : triggerUserId; if (priorCommand) { const retiredParams = { ...prior.params, @@ -952,7 +1045,29 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB } dynamicTaskStore.setEnabled(prior.id, false); taskRunner.unregister(prior.id); + await persistHoldTerminalVisibility( + { messageStore, socketManager }, + { + taskId: prior.id, + threadId, + userId: priorUserId, + catId: priorCatId, + outcome: 'retired_by_replacement', + content: `🏓 ${priorCatId} 的旧持球已由新持球替换;已启动的命令仍会独立记录终态`, + }, + ); } else { + await persistHoldTerminalVisibility( + { messageStore, socketManager }, + { + taskId: prior.id, + threadId, + userId: priorUserId, + catId: priorCatId, + outcome: 'retired_by_replacement', + content: `🏓 ${priorCatId} 的旧持球已由新持球替换`, + }, + ); taskRunner.unregister(prior.id); dynamicTaskStore.remove(prior.id); } @@ -981,40 +1096,6 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB const newCount = incrementHoldCount(threadId, catIdStr); - // ── Visibility message — F280 cancellation window ── - // Post BEFORE launch to preserve F280 pre-launch cancellation fence (lines 834–837): - // any cancellation admitted during this await wins because launchWakeWhenRunner's - // IIFE checks cancellation_reserved before spawning. The message says "待启动" - // because the command has not yet spawned; the HTTP response carries the real pid. - const wakeAtStr = new Date(fireAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); - const holdMessage = wakeWhen - ? `🏓 ${catIdStr} 持球中 — ${reason}。命令「${wakeWhen.command}」待启动,完成后自动唤醒。下一步:${nextStep}` - : `🏓 ${catIdStr} 持球中 — ${reason}。预计 ${wakeAtStr} 唤醒,下一步:${nextStep}`; - const holdSource = { ...HOLD_BALL_SOURCE, meta: { taskId, threadId, catId: catIdStr } }; - try { - const stored = await messageStore.append({ - userId: 'system', - catId: null, - content: holdMessage, - mentions: [], - timestamp: Date.now(), - threadId, - source: holdSource, - }); - socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: stored.id, - type: 'connector', - content: stored.content, - source: holdSource, - timestamp: stored.timestamp, - }, - }); - } catch (err) { - log.warn({ threadId, catId: catIdStr, err }, 'F167 C1: failed to post hold_ball visibility message'); - } - // ── F167 Phase P spawn admission truth: launch AFTER visibility append ── // F280 invariant preserved: the visibility append above is the cancellation // window. launchWakeWhenRunner's IIFE checks cancellation_reserved before @@ -1037,38 +1118,9 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB spawnedPid = spawnResult.pid; } - // ── Durable admission-fact projection ── - // The "待启动" message above is the pre-spawn cancellation window. - // Now that admission has resolved, project the truth so the thread - // timeline reflects the actual spawn outcome. - // - // Pre-launch cancellation deletes the registry entry, so the IIFE - // resolves with registry_mismatch; explicit cancellation_reserved also - // possible. Both are cancellation outcomes — no fallback exists. const isCancellationOutcome = !spawnResult.spawned && (spawnResult.error === 'registry_mismatch' || spawnResult.error === 'cancellation_reserved'); - - // ── Admission-fact: build the three-way text ── - // Three terminal states (P1-1 fix: cancellation distinct from spawn failure): - // 1. spawned → command running with pid - // 2. cancelled → hold terminated externally (no fallback remains) - // 3. spawn_failed → command couldn't start; message is neutral (no - // promise about fallback — phase_mismatch/internal_error may not fire it) - const admissionFact = spawnResult.spawned - ? `✅ 命令「${wakeWhen.command}」已启动 (pid: ${spawnResult.pid}${durableJob ? `, jobId: ${durableJob.jobId}` : ''}),等待完成。` - : isCancellationOutcome - ? `⛔ 命令「${wakeWhen.command}」未启动(已取消),持球已终止。` - : `⚠️ 命令「${wakeWhen.command}」未启动${spawnResult.error ? `(${spawnResult.error})` : ''}。`; - - // ── P1-2 fix: persist admission intent BEFORE attempting the append ── - // We store pid + admissionFact + admissionFactAppended:false so that the - // startup recovery sweep can idempotently re-deliver the visibility message - // if the append fails or the process crashes between persist and append. - // - // Concurrency safety: this runs in the microtask continuation of - // `await admissionPromise` — synchronous updateParams completes before - // any concurrent HTTP handler (cancel, next hold_ball) can interleave. let admissionDurable = isCancellationOutcome; // cancelled = terminal, no task to write if (!isCancellationOutcome) { try { @@ -1087,8 +1139,7 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB ...(spawnResult.processIdentity && durableJob ? { durableJob: { ...durableJob, processIdentity: spawnResult.processIdentity } } : {}), - admissionFact, - admissionFactAppended: false, + admissionFactAppended: true, }, }, }; @@ -1114,61 +1165,38 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB } } - // ── Visibility message: append admission-fact to thread ── - // R6 P1-1: share idempotencyKey with the startup recovery sweep so that - // a crash between "append committed" and "admissionFactAppended=true" - // cannot produce a duplicate — the sweep's getByIdempotencyKey will find - // the original message and skip re-append. - const admissionFactIdempotencyKey = buildAdmissionFactIdempotencyKey(taskId); - try { - const admStored = await messageStore.append({ - userId: 'system', - catId: null, - content: admissionFact, - mentions: [], - timestamp: Date.now(), - threadId, - idempotencyKey: admissionFactIdempotencyKey, - source: holdSource, - }); - socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { - threadId, - message: { - id: admStored.id, - type: 'connector', - content: admStored.content, - source: holdSource, - timestamp: admStored.timestamp, - }, - }); - // Mark admission-fact as delivered so startup recovery skips it - if (admissionDurable && !isCancellationOutcome) { - try { - const taskNow = deps.dynamicTaskStore.getById(taskId); - if (taskNow) { - const lifecycleNow = taskNow.params.holdLifecycle as Record | undefined; - const mcNow = lifecycleNow?.managedCommand as Record | undefined; - if (lifecycleNow && mcNow) { - deps.dynamicTaskStore.updateParams(taskId, { - ...taskNow.params, - holdLifecycle: { - ...lifecycleNow, - managedCommand: { ...mcNow, admissionFactAppended: true }, - }, - }); - } - } - } catch { - // Non-fatal: admission fact was delivered; recovery will find admissionFactAppended=false - // and re-deliver (idempotent duplicate is benign) - } + // Successful admission remains one waiting bubble. Only an exceptional + // launch terminal adds a second, distinct lifecycle fact. + if (!spawnResult.spawned && isCancellationOutcome) { + const admissionFact = `等待已取消:命令「${wakeWhen.command}」未启动。`; + const statusSource = { ...holdSource, meta: { ...holdSource.meta, phase: 'status' } }; + try { + const admStored = await messageStore.append({ + from: { kind: 'system', service: 'hold-ball' }, + userId: triggerUserId, + content: admissionFact, + mentions: [], + timestamp: Date.now(), + threadId, + idempotencyKey: `hold-ball-admission:${taskId}`, + source: statusSource, + }); + socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { + threadId, + message: { + id: admStored.id, + type: 'connector', + content: admStored.content, + source: statusSource, + timestamp: admStored.timestamp, + }, + }); + } catch (err) { + log.warn( + { threadId, catId: catIdStr, err, admissionDurable }, + 'F167 Phase P: failed to post exceptional command admission status', + ); } - } catch (err) { - // Append failed — admission intent is durable, startup recovery will re-deliver - log.warn( - { threadId, catId: catIdStr, err, admissionDurable }, - 'F167 Phase P: failed to post admission-fact visibility message — startup recovery will re-deliver if admission intent is durable', - ); } } @@ -1214,61 +1242,5 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB }; }); - app.post('/api/callbacks/complete-managed-hold', async (request, reply) => { - const record = requireCallbackAuth(request, reply); - if (!record) return; - if (!deps.managedHoldDispositionService) { - reply.status(503); - return { error: 'Managed hold disposition unavailable', code: 'MANAGED_HOLD_DISPOSITION_UNAVAILABLE' }; - } - const parsed = z - .object({ disposition: z.enum(['handled', 'completed']) }) - .strict() - .safeParse(request.body); - if (!parsed.success) { - reply.status(400); - return { error: 'Invalid request body', details: parsed.error.issues }; - } - try { - return await deps.managedHoldDispositionService.complete(record, parsed.data.disposition); - } catch (error) { - if (error instanceof ManagedHoldDispositionError) { - reply.status(409); - return { error: 'Managed hold disposition rejected', code: error.code }; - } - throw error; - } - }); - - app.post('/api/callbacks/complete-a2a-dispatch', async (request, reply) => { - const record = requireCallbackAuth(request, reply); - if (!record) return; - if (!deps.a2aDispatchDispositionService) { - reply.status(503); - return { error: 'A2A dispatch disposition unavailable', code: 'A2A_DISPATCH_DISPOSITION_UNAVAILABLE' }; - } - const parsed = z - .object({ disposition: z.enum(['handled', 'completed']) }) - .strict() - .safeParse(request.body); - if (!parsed.success) { - reply.status(400); - return { error: 'Invalid request body', details: parsed.error.issues }; - } - try { - return await deps.a2aDispatchDispositionService.complete(record, parsed.data.disposition); - } catch (error) { - if (error instanceof A2ADispatchDispositionError) { - reply.status(409); - return { - error: 'A2A dispatch disposition rejected', - code: error.code, - ...(error.replacement ? { replacement: error.replacement } : {}), - }; - } - throw error; - } - }); - registerHoldBallCancelRoutes(app, deps); } diff --git a/packages/api/src/routes/callback-limb-routes.ts b/packages/api/src/routes/callback-limb-routes.ts index 6870786213..2f7401f820 100644 --- a/packages/api/src/routes/callback-limb-routes.ts +++ b/packages/api/src/routes/callback-limb-routes.ts @@ -10,6 +10,7 @@ import type { LimbInvocationContext } from '@cat-cafe/shared'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { InvocationRecord as CallbackInvocationRecord } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { LimbEmbodimentBindingStore } from '../domains/limb/LimbEmbodimentBindingStore.js'; @@ -82,7 +83,12 @@ async function resolveLimbInvocationContext( } const message = await messageStore.getById(invocation.userMessageId); - if (!message || message.catId !== null || message.userId !== record.userId || message.threadId !== record.threadId) { + if ( + !message || + messageFrom(message).kind !== 'user' || + message.userId !== record.userId || + message.threadId !== record.threadId + ) { return context; } diff --git a/packages/api/src/routes/callback-multi-mention-routes.ts b/packages/api/src/routes/callback-multi-mention-routes.ts index 9e432661da..5e9bb2d00e 100644 --- a/packages/api/src/routes/callback-multi-mention-routes.ts +++ b/packages/api/src/routes/callback-multi-mention-routes.ts @@ -25,42 +25,28 @@ import { actionSuccessorFencesMatch, reconcileActionSuccessorEnqueue, } from '../domains/ball-custody/reconcile-action-successor-enqueue.js'; -import type { TurnCustodyWakeProvenance } from '../domains/ball-custody/TurnCustodyProjectionService.js'; -import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; +import { + type InvocationQueue, + queueEntryTargetCats, +} from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { InvocationRecord } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; import type { OwnerAuthProvenance } from '../domains/cats/services/agents/invocation/owner-auth-provenance.js'; -import { isTerminalDispositionEvent } from '../domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.js'; -import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; import { resolveCatTarget } from '../domains/cats/services/agents/routing/cat-target-resolver.js'; import { type MultiMentionCreateParams, MultiMentionOrchestrator, } from '../domains/cats/services/agents/routing/MultiMentionOrchestrator.js'; -import { userFacingSystemInfoNoticeContent } from '../domains/cats/services/agents/routing/persist-system-info-warnings.js'; -import { createA2ASlotTrackingBridge } from '../domains/cats/services/agents/routing/route-helpers.js'; import type { CloudDispatchProvenance } from '../domains/cats/services/cloud-bridge/types.js'; -import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; -import { - checkFreshnessForPostMessage, - createQueueChecker, -} from '../domains/cats/services/freshness/checkFreshnessForPostMessage.js'; -import type { FreshnessAttentionEventLog } from '../domains/cats/services/freshness/FreshnessAttentionEventLog.js'; -import { FreshnessInvocationStateStore } from '../domains/cats/services/freshness/FreshnessInvocationStateStore.js'; -import { - descriptorFromDriver, - descriptorFromProviderFallback, - resolveFreshnessDescriptorProvider, -} from '../domains/cats/services/freshness/RuntimeCapabilityDescriptor.js'; import type { AgentRouter } from '../domains/cats/services/index.js'; -import type { DeliveryCursorStore } from '../domains/cats/services/stores/ports/DeliveryCursorStore.js'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import { + type AppendMessageInput, type IMessageStore, isDelivered, type StoredMessage, } from '../domains/cats/services/stores/ports/MessageStore.js'; -import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { ITurnExecutionStore, TurnExecutionRecord, @@ -74,7 +60,13 @@ import { successorUnfencedSingleTargetMultiMention, } from '../infrastructure/telemetry/instruments.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { emitParallelRoutingPills } from './a2a-routing-projection.js'; +import { + type A2AFanoutAdmissionPlan, + type A2ATriggerDeps, + appendA2ASourceWithLedgerAdmission, + enqueueA2ATargets, + preflightA2ATargets, +} from './callback-a2a-trigger.js'; import { requireCallbackAuth } from './callback-auth-prehandler.js'; import { resolveCallbackActionLeaseRef } from './callback-scope-helpers.js'; @@ -241,27 +233,19 @@ export interface MultiMentionRouteDeps { invocationTracker?: InvocationTracker | undefined; /** Durable prompt-causality truth for the callback child. */ turnExecutionStore?: Pick; - /** F254 AC-A6: DeliveryCursorStore for freshness gate (seenCursor) */ - deliveryCursorStore?: DeliveryCursorStore; - /** F254 AC-A7: Event log for recording held/forward decisions */ - freshnessEventLog?: FreshnessAttentionEventLog; - /** F254 R2: ThreadStore for play-mode visibility filter in freshness gate */ - threadStore?: IThreadStore; - /** F254 AC-C2: Redis client for FreshnessInvocationStateStore carrierTier lookup */ - redis?: import('@cat-cafe/shared/utils').RedisClient; /** F167 Phase S: durable subject/action/slot single-flight admission. */ actionSuccessorAdmissionService?: Pick< ActionSuccessorAdmissionService, 'admit' | 'markUnavailable' | 'markReturnedDelivered' >; + /** F293: fresh routing policy at the actual Queue admission boundary. */ + routingDispatchPreflight?: A2ATriggerDeps['routingDispatchPreflight']; /** F122B B6: InvocationQueue for unified dispatch */ - invocationQueue?: Pick< - InvocationQueue, - 'enqueue' | 'countAgentEntriesForThread' | 'hasQueuedAgentForCat' | 'list' | 'getQueuedFreshnessMessagesForCat' - >; + invocationQueue?: A2ATriggerDeps['invocationQueue'] & + Pick; /** F122B B6: QueueProcessor for execution + response hook */ queueProcessor?: { - tryAutoExecute?(threadId: string): Promise; + requestDrain?(threadId: string): Promise; registerEntryCompleteHook?( entryId: string, hook: ( @@ -269,6 +253,7 @@ export interface MultiMentionRouteDeps { status: 'succeeded' | 'failed' | 'canceled' | 'canceled_by_user', responseText: string, ) => void, + targetCatId?: string, ): void; unregisterEntryCompleteHook?(entryId: string): void; markPromptMessagesSeen?(input: { @@ -277,8 +262,7 @@ export interface MultiMentionRouteDeps { catId: string; invocationId: string; messageIds: readonly string[]; - seenAt: number; - }): Promise; + }): Promise; }; } @@ -327,93 +311,80 @@ function registerMultiMentionCompletionHook(input: { log: FastifyBaseLogger; }): void { const orch = getMultiMentionOrchestrator(); - input.queueProcessor.registerEntryCompleteHook?.(input.entryId, (_entryId, status, responseText) => { - if (status === 'canceled' || status === 'canceled_by_user') { + input.queueProcessor.registerEntryCompleteHook?.( + input.entryId, + (_entryId, status, responseText) => { + if (status === 'canceled' || status === 'canceled_by_user') { + input.log.info( + { requestId: input.requestId, catId: input.catId }, + '[F122B B6] multi-mention queue entry canceled, skipping recordResponse', + ); + return; + } + const finalResponse = responseText || (status === 'failed' ? '[dispatch error]' : ''); + const newStatus = orch.recordResponse(input.requestId, input.catId, finalResponse); input.log.info( - { requestId: input.requestId, catId: input.catId }, - '[F122B B6] multi-mention queue entry canceled, skipping recordResponse', + { requestId: input.requestId, catId: input.catId, newStatus, responseLength: finalResponse.length }, + '[F122B B6] multi-mention queue response recorded', ); - return; - } - const finalResponse = responseText || (status === 'failed' ? '[dispatch error]' : ''); - const newStatus = orch.recordResponse(input.requestId, input.catId, finalResponse); - input.log.info( - { requestId: input.requestId, catId: input.catId, newStatus, responseLength: finalResponse.length }, - '[F122B B6] multi-mention queue response recorded', - ); - settleGroupIfComplete(input.deps, input.requestId, input.threadId, input.userId, input.log); - }); + settleGroupIfComplete(input.deps, input.requestId, input.threadId, input.userId, input.log); + }, + input.catId, + ); } -function enqueueMultiMentionTarget(input: { - deps: MultiMentionRouteDeps; +function planMultiMentionFanout(input: { invocationQueue: NonNullable; - queueProcessor: NonNullable; - requestId: string; - catId: CatId; - messageContent: string; + targetCatIds: readonly CatId[]; + requestedTargetCatIds?: readonly CatId[]; threadId: string; - userId: string; - ownerAuthProvenance: OwnerAuthProvenance; - initiator: CatId; - cloudDispatchProvenance: CloudDispatchProvenance | undefined; - parentInvocationId: string; - log: FastifyBaseLogger; actionFence: ActionSuccessorFence | undefined; -}): 'enqueued' | 'skipped' | 'depth_limited' | 'unavailable' { +}): A2AFanoutAdmissionPlan { const MAX_MM_DEPTH = 10; - if (input.invocationQueue.countAgentEntriesForThread(input.threadId) >= MAX_MM_DEPTH) { - input.log.warn( - { threadId: input.threadId, requestId: input.requestId, catId: input.catId }, - '[F122B B6] multi-mention: depth limit reached', - ); - return 'depth_limited'; - } - if (!input.actionFence && input.invocationQueue.hasQueuedAgentForCat(input.threadId, input.catId)) { - input.log.info( - { threadId: input.threadId, requestId: input.requestId, catId: input.catId }, - '[F122B B6] multi-mention: skipping duplicate agent entry', - ); - return 'skipped'; + const acceptedTargetCats: CatId[] = []; + const predictedDepth = input.invocationQueue.countAgentEntriesForThread(input.threadId); + let stop: A2AFanoutAdmissionPlan['stop']; + for (const catId of input.targetCatIds) { + if (predictedDepth >= MAX_MM_DEPTH) { + stop = { reason: 'depth', catId, currentDepth: predictedDepth }; + break; + } + acceptedTargetCats.push(catId); } + return { + requestedTargetCats: [...(input.requestedTargetCatIds ?? input.targetCatIds)], + acceptedTargetCats, + streakTargetCats: [], + ...(stop ? { stop } : {}), + }; +} - const result = input.invocationQueue.enqueue({ - threadId: input.threadId, - userId: input.userId, - ownerAuthProvenance: input.ownerAuthProvenance, - content: input.messageContent, - source: 'agent', - targetCats: [input.catId], - intent: 'execute', - autoExecute: true, - callerCatId: input.initiator, - a2aParentInvocationId: input.parentInvocationId, - idempotencyKey: input.actionFence - ? `action:${input.actionFence.leaseId}:${input.actionFence.generation}:${input.catId}` - : `multi-mention:${input.requestId}:${input.catId}`, - ...(input.cloudDispatchProvenance ? { cloudDispatchProvenance: input.cloudDispatchProvenance } : {}), - requiresExactCloudDispatchProvenance: true, - ...(input.actionFence - ? { - actionSuccessorFence: input.actionFence, - } - : {}), - }); - - if (result.outcome === 'enqueued' && result.entry) { - registerMultiMentionCompletionHook({ - deps: input.deps, - queueProcessor: input.queueProcessor, - entryId: result.entry.id, - requestId: input.requestId, - catId: input.catId, - threadId: input.threadId, - userId: input.userId, - log: input.log, - }); - return 'enqueued'; +async function resolveMultiMentionSourceMessage( + deps: MultiMentionRouteDeps, + input: { invocationId: string; callerCatId: CatId; threadId: string; userId: string }, +): Promise { + const source = await deps.messageStore.getByIdempotencyKey( + input.userId, + input.threadId, + `message-lifecycle-response:${input.invocationId}`, + ); + const from = source ? messageFrom(source) : null; + if ( + !source || + source.threadId !== input.threadId || + source.userId !== input.userId || + from?.kind !== 'agent' || + from.catId !== input.callerCatId || + source.lifecycle?.kind !== 'response' || + source.lifecycle.invocationId !== input.invocationId || + source.deliveryStatus === 'canceled' || + source.visibility === 'whisper' || + source.recall || + source._tombstone + ) { + return null; } - return input.actionFence ? 'unavailable' : 'skipped'; + return source; } // ── Dispatch via InvocationQueue (F122B B6) ───────────────────────── @@ -428,19 +399,11 @@ async function dispatchViaQueue( ownerAuthProvenance: OwnerAuthProvenance, initiator: CatId, cloudDispatchProvenance: CloudDispatchProvenance | undefined, - parentInvocationId: string, log: FastifyBaseLogger, + sourceMessage: StoredMessage, + parentInvocationId: string, actionFence?: ActionSuccessorFence, actionCarrierDisposition?: ActionSuccessorCarrierDisposition, - /** - * F086/F216: runs AFTER every target has real Queue custody and BEFORE any target is started. - * Projection must describe admitted siblings only — announcing the requested target list before - * admission is the exact "projection ahead of fact" defect this change exists to remove. - * Deliberately NOT awaited by the caller: see the call site: custody is durable by then, and - * awaiting a projection write in front of `tryAutoExecute` would turn a slow message store into - * a scheduling stall. - */ - onAdmitted?: (admitted: CatId[]) => Promise, ): Promise { const { invocationQueue, queueProcessor } = deps; if (!invocationQueue || !queueProcessor) return; @@ -449,33 +412,97 @@ async function dispatchViaQueue( '\n\n', ); - const unavailable: CatId[] = []; - /** Targets that actually obtained Queue custody — the only honest basis for a fan-out pill. */ - const admitted: CatId[] = []; - for (const catId of targetCatIds) { - const enqueueOutcome = enqueueMultiMentionTarget({ - deps, - invocationQueue, + // Resolve routing before publishing the source so Message + pending Queue targets + // are committed as one fact. The lifecycle response that invoked this tool remains + // the causal parent; it must not impersonate a different synthetic Queue body. + const routingPreflight = await preflightA2ATargets( + deps.routingDispatchPreflight ? { routingDispatchPreflight: deps.routingDispatchPreflight } : {}, + { targetCats: targetCatIds, content: messageContent, userId }, + ); + const preplannedAdmission = planMultiMentionFanout({ + invocationQueue, + targetCatIds: routingPreflight.acceptedTargetCats, + requestedTargetCatIds: routingPreflight.requestedTargetCats, + threadId, + actionFence, + }); + const sourceInput: AppendMessageInput = { + from: { kind: 'agent', catId: initiator }, + userId, + threadId, + content: messageContent, + mentions: [...targetCatIds], + replyTo: sourceMessage.id, + origin: 'callback', + timestamp: Date.now(), + idempotencyKey: `multi-mention-source:${requestId}`, + extra: { + isExplicitPost: true, + causal: { kind: 'invocation_reply', triggerMessageId: sourceMessage.id }, + }, + }; + const atomicAdmission = await appendA2ASourceWithLedgerAdmission( + { messageStore: deps.messageStore, invocationQueue }, + sourceInput, + { + plan: preplannedAdmission, + ownerAuthProvenance, + parentInvocationId, + ...(actionFence ? { actionSuccessorFence: actionFence } : {}), + ...(cloudDispatchProvenance ? { cloudDispatchProvenance } : {}), + requiresExactCloudDispatchProvenance: true, + }, + ); + const result = await enqueueA2ATargets( + { + socketManager: deps.socketManager, + invocationTracker: deps.invocationTracker, queueProcessor, - requestId, - catId, - messageContent, - threadId, + messageStore: deps.messageStore, + invocationQueue, + ...(deps.routingDispatchPreflight ? { routingDispatchPreflight: deps.routingDispatchPreflight } : {}), + log, + }, + { + targetCats: targetCatIds, + content: messageContent, userId, ownerAuthProvenance, - initiator, - cloudDispatchProvenance, + threadId, + triggerMessage: atomicAdmission.message, + callerCatId: initiator, parentInvocationId, - log, - actionFence, - }); - if (enqueueOutcome === 'depth_limited') { - unavailable.push(...targetCatIds.slice(targetCatIds.indexOf(catId))); - break; - } - if (enqueueOutcome === 'unavailable') unavailable.push(catId); - if (enqueueOutcome === 'enqueued') admitted.push(catId); - } + preplannedAdmission, + ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), + ...(atomicAdmission.preAdmittedEntries + ? { + preAdmittedEntries: atomicAdmission.preAdmittedEntries, + preAdmittedReplayed: atomicAdmission.preAdmittedReplayed, + } + : {}), + ...(cloudDispatchProvenance ? { cloudDispatchProvenance } : {}), + requiresExactCloudDispatchProvenance: true, + ...(actionFence ? { actionSuccessorFence: actionFence } : {}), + onQueueEntriesAdmitted: (entries) => { + for (const entry of entries) { + for (const catId of queueEntryTargetCats(entry)) { + registerMultiMentionCompletionHook({ + deps, + queueProcessor, + entryId: entry.id, + requestId, + catId: catId as CatId, + threadId, + userId, + log, + }); + } + } + }, + }, + ); + const admitted = [...result.enqueued, ...(result.coalesced ?? [])]; + const unavailable = targetCatIds.filter((catId) => !admitted.includes(catId)); await reconcileActionSuccessorEnqueue({ service: deps.actionSuccessorAdmissionService, @@ -495,298 +522,6 @@ async function dispatchViaQueue( orch.recordResponse(requestId, catId, '[dispatch unavailable: target was not admitted to the queue]'); } if (rejected.length > 0) settleGroupIfComplete(deps, requestId, threadId, userId, log); - - // Custody is durable here and start has not happened yet — the only correct window to describe - // the fan-out. Deliberately NOT awaited: the projection is downstream of durable custody, so it - // must never sit in front of `tryAutoExecute`. Awaiting it (even after custody) let a slow or - // never-settling message store stall every admitted sibling's start — the same "cannot gate - // scheduling" claim being false for a second time (砚砚 R2 P1). Fire-and-forget is the contract; - // failures are logged and can only lose a pill, never a dispatch. - if (onAdmitted) { - void onAdmitted(admitted).catch((err) => { - log.warn({ threadId, requestId, err }, 'parallel routing projection failed (custody + start unaffected)'); - }); - } - - await queueProcessor.tryAutoExecute?.(threadId); -} - -// ── Legacy dispatch (direct routeExecution, fallback) ──────────────── - -/** Custody handle produced by {@link admitLegacyTarget} and consumed by `dispatchToTarget`. */ -interface LegacyAdmission { - controller: AbortController; - invocationId: string; - /** Parsed once at admission and carried forward — no second parse of the same content (砚砚 R4 P2). */ - intent: ReturnType; -} - -/** - * One legacy target's ADMISSION half, split out of `dispatchToTarget` (砚砚 R3 P1). - * - * Requirement 2 applies to both dispatch families: for an explicit parallel fan-out every sibling - * must hold custody before ANY of them starts. The Queue path got that by enqueueing all targets - * before `tryAutoExecute`; the legacy path used to interleave admission with execution, so a fast - * first target could terminate while a later sibling had not yet created its invocation — and it - * can still drop out entirely here (pre-start cancel at the tracker gate, or a duplicate record). - * Hoisting admission lets the caller batch it, then project only what was admitted, then start. - * - * Returns null when the target did not obtain custody; the tracker slot is released in that case. - * On success the CALLER owns the slot and must pass the handle to `dispatchToTarget`, whose - * `finally` performs the (idempotent) release. - */ -async function admitLegacyTarget(input: { - deps: MultiMentionRouteDeps; - requestId: string; - targetCatId: CatId; - threadId: string; - userId: string; - intent: ReturnType; - log: FastifyBaseLogger; -}): Promise { - const { deps, requestId, targetCatId, threadId, userId, intent, log } = input; - const { invocationRecordStore, invocationTracker } = deps; - const orch = getMultiMentionOrchestrator(); - - // F122 AC-A9: occupy the tracker slot BEFORE create to close the TOCTOU window. - const controller = invocationTracker?.start(threadId, targetCatId, userId, [targetCatId]) ?? new AbortController(); - // 砚砚 R4 P1: hoisted so the catch can SEE a record it already created. Splitting admission out of - // `dispatchToTarget` moved the happy path but left that function's catch behind, so a throw after - // create (the state machine's canonical `queued → failed` pre-start failure, e.g. the - // `status: running` update failing) leaked a record stuck at `queued` forever AND never recorded a - // response — the whole group then sat at `partial` until the timeout fired. Extracting a function - // means extracting its failure obligations too, not just its success path. - let invocationId: string | undefined; - try { - if (controller.signal.aborted) { - log.info({ requestId, targetCatId }, '[F086] Multi-mention dispatch canceled before start (deleting)'); - invocationTracker?.complete(threadId, targetCatId, controller); - return null; - } - - const createResult = await invocationRecordStore.create({ - threadId, - userId, - targetCats: [targetCatId], - intent: intent.intent, - idempotencyKey: `mm-${requestId}-${targetCatId}`, - actionLeaseCarrier: { kind: 'none' }, - }); - - if (createResult.outcome === 'duplicate') { - log.info({ requestId, targetCatId }, '[F086] Dispatch skipped: duplicate invocation'); - invocationTracker?.complete(threadId, targetCatId, controller); - return null; - } - - invocationId = createResult.invocationId; - invocationTracker?.bindExecutionId?.(threadId, [targetCatId], controller, invocationId); - await invocationRecordStore.update(invocationId, { status: 'running' }); - orch.registerDispatch(requestId, targetCatId, controller); - return { controller, invocationId, intent }; - } catch (err) { - log.error({ requestId, targetCatId, err }, '[F086] Multi-mention admission failed'); - // Converge the record we already created — otherwise it is orphaned at `queued`. - if (invocationId) { - try { - await invocationRecordStore.update(invocationId, { - status: controller.signal.aborted ? 'canceled' : 'failed', - ...(controller.signal.aborted ? {} : { error: 'admission_error' }), - }); - } catch (updateErr) { - log.warn( - { requestId, targetCatId, invocationId, err: updateErr }, - '[F086] Failed to converge InvocationRecord after admission error', - ); - } - } - // Register the failure so the group can reach a terminal state on its own instead of - // hanging at `partial` until the timeout. - orch.recordResponse( - requestId, - targetCatId, - `[admission error: ${err instanceof Error ? err.message : String(err)}]`, - ); - invocationTracker?.complete(threadId, targetCatId, controller); - return null; - } -} - -async function dispatchToTarget( - deps: MultiMentionRouteDeps, - requestId: string, - targetCatId: CatId, - question: string, - context: string | undefined, - threadId: string, - userId: string, - ownerAuthProvenance: OwnerAuthProvenance, - initiator: CatId, - cloudDispatchProvenance: CloudDispatchProvenance | undefined, - log: FastifyBaseLogger, - /** Custody obtained by `admitLegacyTarget`. The caller admitted every sibling before any start. */ - admission: LegacyAdmission, -): Promise { - const orch = getMultiMentionOrchestrator(); - const { router, invocationRecordStore, socketManager, invocationTracker } = deps; - - // Build the message for this target - // Include multi-mention context as structured prefix so the target cat - // understands the request is from another cat, not the user directly. - const messageContent = [`[Multi-Mention from ${initiator}]`, question, ...(context ? ['---', context] : [])].join( - '\n\n', - ); - - // Parsed once at admission; re-parsing the identical content here was dead duplication (砚砚 R4 P2). - const intent = admission.intent; - - // Collect response text from the routing execution - let responseText = ''; - const toolsUsed: string[] = []; - // Custody was established by `admitLegacyTarget` before ANY sibling started (requirement 2). - const { controller } = admission; - const invocationId: string | undefined = admission.invocationId; - - try { - let governanceErrorCode: string | undefined; - - try { - // #768: Defer intent_mode broadcast until CLI produces first event. - let intentModeBroadcast = false; - - for await (const msg of router.routeExecution( - userId, - messageContent, - threadId, - cloudDispatchProvenance?.sourceMessageId ?? invocationId, - [targetCatId], - intent, - { - ownerAuthProvenance, - humanDispositionInvocationOrigin: 'callback', - signal: controller.signal, - ...createA2ASlotTrackingBridge(invocationTracker, controller, admission.invocationId), - ...(deps.invocationQueue - ? { - deferA2AEnqueue: (entry: Parameters[0]) => - deps.invocationQueue?.enqueue({ ...entry, ownerAuthProvenance }), - } - : {}), - parentInvocationId: invocationId, - ...(cloudDispatchProvenance ? { cloudDispatchProvenance } : {}), - requiresExactCloudDispatchProvenance: true, - onPromptMessagesExposed: (input) => deps.queueProcessor?.markPromptMessagesSeen?.(input) ?? Promise.resolve(), - // F222 P1: Multi-mention fallback dispatch is callback-authenticated cat-to-cat - // work (callerCatId = record.catId), consistent with queue path source:'agent'. - frustrationAutoIssueEligible: false, - }, - )) { - // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. - if (!intentModeBroadcast) { - socketManager.broadcastToRoom(`thread:${threadId}`, 'intent_mode', { - threadId, - mode: intent.intent, - targetCats: [targetCatId], - invocationId: admission.invocationId, - }); - intentModeBroadcast = true; - } - if (controller.signal.aborted) break; - if (isTerminalDispositionEvent(msg) && msg.catId) { - invocationTracker?.completeSlot?.(threadId, msg.catId, controller); - } - - // Capture text + tool usage for response aggregation - if (msg.catId === targetCatId) { - if (msg.type === 'text' && msg.content) { - responseText += msg.content; - } else if (msg.type === 'system_info' && msg.content) { - const visibleNotice = userFacingSystemInfoNoticeContent(msg.content, targetCatId); - if (visibleNotice) responseText = responseText ? `${responseText}\n${visibleNotice}` : visibleNotice; - } else if (msg.type === 'tool_use' && msg.toolName) { - toolsUsed.push(msg.toolName); - } - } - if (msg.type === 'done' && msg.errorCode) { - governanceErrorCode = msg.errorCode; - } - - // F194 Phase Z9 (砚砚 R1 P1-2): unified visible turn stamp via helper. - socketManager.broadcastAgentMessage({ ...msg, ...stampVisibleTurn(invocationId, msg.invocationId) }, threadId); - } - - const finalInvocationStatus = controller.signal.aborted - ? 'canceled' - : governanceErrorCode - ? 'failed' - : 'succeeded'; - await invocationRecordStore.update(invocationId, { - status: finalInvocationStatus, - ...(governanceErrorCode ? { error: governanceErrorCode } : {}), - }); - } finally { - orch.unregisterDispatch(requestId, targetCatId); - } - - // If aborted or governance-blocked, do NOT record response - // or flush result — the partial/empty text would produce a misleading summary. - if (controller.signal.aborted || governanceErrorCode) { - log.info( - { requestId, targetCatId, governanceErrorCode }, - '[F086] Multi-mention dispatch aborted/blocked, skipping recordResponse', - ); - return; - } - - // If no text captured but tools were used, generate a tool-usage summary - // so the aggregation doesn't show "(空回答)" for cats that responded via tools - const finalResponse = - responseText || (toolsUsed.length > 0 ? `(通过工具回复: ${[...new Set(toolsUsed)].join(', ')})` : ''); - - // Record response in orchestrator - const newStatus = orch.recordResponse(requestId, targetCatId, finalResponse); - log.info( - { requestId, targetCatId, newStatus, responseLength: finalResponse.length, toolsUsed: toolsUsed.length }, - '[F086] Multi-mention response recorded', - ); - - settleGroupIfComplete(deps, requestId, threadId, userId, log); - } catch (err) { - log.error( - { requestId, targetCatId, err: err instanceof Error ? err.message : String(err) }, - '[F086] Multi-mention dispatch failed for target', - ); - if (invocationId) { - try { - await invocationRecordStore.update(invocationId, { - status: controller.signal.aborted ? 'canceled' : 'failed', - error: controller.signal.aborted ? undefined : 'dispatch_error', - }); - } catch (updateErr) { - log.warn( - { - requestId, - targetCatId, - invocationId, - err: updateErr instanceof Error ? updateErr.message : String(updateErr), - }, - '[F086] Failed to converge InvocationRecord after dispatch error', - ); - } - } - // Record failure response in orchestrator - orch.recordResponse( - requestId, - targetCatId, - `[dispatch error: ${err instanceof Error ? err.message : String(err)}]`, - ); - settleGroupIfComplete(deps, requestId, threadId, userId, log); - } finally { - // F122 AC-A7: unconditional slot release — covers early return, registerDispatch - // throw, routeExecution crash, and normal completion. InvocationTracker.complete() - // is idempotent (no-op if slot already removed or controller doesn't match). - invocationTracker?.complete(threadId, targetCatId, controller); - } } // ── Result flush ───────────────────────────────────────────────────── @@ -869,8 +604,8 @@ async function flushResult( // Post aggregated result to thread (with source for persistence) const stored = await messageStore.append({ + from: { kind: 'external', connectorId: 'multi-mention-result' }, userId, - catId: result.request.callbackTo, content, mentions: [], timestamp: Date.now(), @@ -982,98 +717,6 @@ export function registerMultiMentionRoutes(app: FastifyInstance, deps: MultiMent }); } - // F254 AC-A6: Freshness gate — hold multi_mention if initiator has unseen messages - // in their thread. Fail-open: no cursor → forward; error → forward (log + continue). - if (deps.deliveryCursorStore) { - try { - // Full visibility filter aligned with post_message gate — baseline - // publication rules plus play-mode whisper privacy. Stream is transport - // provenance, so persisted cat speech remains freshness-relevant. - const needsFreshnessPlayFilter = deps.threadStore - ? await (async () => { - const thread = await deps.threadStore!.get(record.threadId); - return !!thread && (thread.thinkingMode ?? 'debug') === 'play'; - })() - : false; - const freshnessViewer = needsFreshnessPlayFilter - ? { type: 'cat' as const, catId: callerCatId } - : { type: 'user' as const }; - - const messageFilter = (msg: Record): boolean => { - // Baseline visibility (applies in ALL modes): - if (msg.deletedAt) return false; - if (!isDelivered(msg as unknown as Parameters[0])) return false; - // #1200 codex R12 P1: system-generated messages (persisted error badges) - // are display-only — route-helpers.ts:744-745 excludes them from freshness. - // All 4 freshness filter sites now consistent. - if (msg.userId === 'system') return false; - if (msg.origin === 'briefing') return false; - // Play-mode visibility: - if (needsFreshnessPlayFilter) { - if (!canViewMessage(msg as unknown as Parameters[0], freshnessViewer)) return false; - } - return true; - }; - - // F254 AC-C2/C3: Derive RuntimeCapabilityDescriptor from stored carrierTier. - // Provider-only fallback (gpt52 terminal review P1/R2): preserve the - // most specific provider marker when present (e.g. openai-chatgpt-pro). - const callerCatConfig = catRegistry.tryGet(callerCatId as string); - const callerProvider = resolveFreshnessDescriptorProvider(callerCatConfig?.config); - let freshnessDescriptor; - if (deps.redis && record.invocationId) { - const stateStore = new FreshnessInvocationStateStore(deps.redis); - const state = await stateStore.get(record.invocationId); - if (state?.carrierTier) { - freshnessDescriptor = descriptorFromDriver(callerProvider, state.carrierTier); - } else { - freshnessDescriptor = descriptorFromProviderFallback(callerProvider); - } - } else { - freshnessDescriptor = descriptorFromProviderFallback(callerProvider); - } - - const freshnessDecision = await checkFreshnessForPostMessage({ - userId: record.userId, - catId: callerCatId, - threadId: record.threadId, - invocationId: record.invocationId, - toolName: 'multi_mention', - cursorStore: deps.deliveryCursorStore, - messageStore: deps.messageStore, - messageFilter, - // AC-A7: record held/forward decisions as events - eventLog: deps.freshnessEventLog, - // F254 queue-aware gate: detect queued messages hidden by isDelivered() - queueChecker: deps.invocationQueue - ? createQueueChecker(deps.invocationQueue, { parentInvocationId: expectedParentInvocationId }) - : undefined, - // F254 AC-C3: descriptor parameterizes held/notice behavior per carrier tier - descriptor: freshnessDescriptor, - ...(turnExecution?.causal?.coveredMessageIds - ? { coveredMessageIds: turnExecution.causal.coveredMessageIds } - : {}), - }); - if (freshnessDecision.decision === 'held') { - return { - status: 'held', - reason: 'newer_messages_available', - unseenCount: freshnessDecision.unseenCount, - previews: freshnessDecision.previews ?? [], - omittedCount: freshnessDecision.omittedCount ?? 0, - // P2 fix (gpt52 R1): multi_mention has no acknowledgeHeld param, - // so don't advertise send_with_acknowledge action - actions: ['read_latest', 'revise'], - }; - } - } catch (err) { - request.log.warn( - { err, catId: callerCatId, threadId: record.threadId }, - '[F254] multi_mention freshness gate error, fail-open', - ); - } - } - let actionFence: ActionSuccessorFence | undefined; let actionAdmissionOutcome: ActionSuccessorCarrierAdmissionOutcome | undefined; const actionCarrierDisposition: ActionSuccessorCarrierDisposition | undefined = body.action @@ -1131,6 +774,20 @@ export function registerMultiMentionRoutes(app: FastifyInstance, deps: MultiMent } } + if (!deps.invocationQueue || !deps.queueProcessor?.requestDrain) { + return reply.code(503).send({ error: 'Multi-mention dispatch requires InvocationQueue and QueueProcessor' }); + } + + const sourceMessage = await resolveMultiMentionSourceMessage(deps, { + invocationId: record.invocationId, + callerCatId, + threadId: record.threadId, + userId: record.userId, + }); + if (!sourceMessage) { + return reply.code(409).send({ status: 'lifecycle_source_unavailable' }); + } + const createParams = { threadId: record.threadId, initiator: callerCatId, @@ -1161,7 +818,7 @@ export function registerMultiMentionRoutes(app: FastifyInstance, deps: MultiMent const accepted = deps.invocationQueue ?.list(record.threadId, record.userId) - .some((entry) => actionSuccessorFencesMatch(entry.actionSuccessorFence, actionFence)) ?? false; + .some((entry) => actionSuccessorFencesMatch(entry.execution.actionSuccessorFence, actionFence)) ?? false; await reconcileActionSuccessorEnqueue({ service: deps.actionSuccessorAdmissionService, fence: actionFence, @@ -1191,98 +848,23 @@ export function registerMultiMentionRoutes(app: FastifyInstance, deps: MultiMent : undefined, ); - // F086/F216: a real fan-out renders as "A ⇉ B(并行 N/M)", distinct from a serial leg's - // "A → B(串行 1/2)" / "A ⇢ B(串行 2/2·排队中)". Before this the parallel path emitted no - // pill at all, so the only multi-target projection users ever saw was the sequential one. - // The projection runs from inside the dispatch, keyed on ADMITTED targets only (砚砚 R1 P1). - const projectAdmittedFanOut = (admitted: CatId[]): Promise => - emitParallelRoutingPills({ - ...(deps.messageStore ? { messageStore: deps.messageStore } : {}), - socketManager: deps.socketManager, - threadId: record.threadId, - fromCatId: callerCatId, - targetCatIds: admitted, - log: request.log, - }); - - // Dispatch to all targets in parallel (fire and forget) - // F122B B6: Use InvocationQueue when available, legacy direct dispatch as fallback - if (deps.invocationQueue && deps.queueProcessor) { - await dispatchViaQueue( - deps, - mmRequest.id, - targetCatIds, - body.question, - body.context, - record.threadId, - record.userId, - record.ownerAuthProvenance, - callerCatId, - cloudDispatchProvenance, - record.invocationId, - request.log, - actionFence, - actionCarrierDisposition, - projectAdmittedFanOut, - ); - } else { - // Legacy direct-dispatch fallback, now TWO-PHASE (砚砚 R3 P1): admit every target first, then - // project only what was admitted, then start. Previously each target admitted itself inside - // its own fire-and-forget dispatch, so a fast sibling could terminate before a later one had - // an invocation — requirement 2 held on the Queue path only. Targets that drop out during - // admission (pre-start cancel / duplicate record) are simply never announced. - const legacyMessageContent = [ - `[Multi-Mention from ${callerCatId}]`, - body.question, - ...(body.context ? ['---', body.context] : []), - ].join('\n\n'); - const legacyIntent = parseIntent(legacyMessageContent, 1); - const admissions = await Promise.all( - targetCatIds.map(async (targetCatId) => ({ - targetCatId, - admission: await admitLegacyTarget({ - deps, - requestId: mmRequest.id, - targetCatId, - threadId: record.threadId, - userId: record.userId, - intent: legacyIntent, - log: request.log, - }), - })), - ); - const admittedLegacy = admissions.flatMap((a) => - a.admission ? [{ targetCatId: a.targetCatId, admission: a.admission }] : [], - ); - - void projectAdmittedFanOut(admittedLegacy.map((a) => a.targetCatId)).catch((err) => { - request.log.warn({ err, threadId: record.threadId }, 'parallel routing projection failed'); - }); - - // 砚砚 R4 P1: admission failures already recorded their own failure response, so a group where - // every target failed is already terminal — settle it now instead of leaving the initiator - // waiting on the timeout timer. - if (admittedLegacy.length < targetCatIds.length) { - settleGroupIfComplete(deps, mmRequest.id, record.threadId, record.userId, request.log); - } - - for (const { targetCatId, admission } of admittedLegacy) { - void dispatchToTarget( - deps, - mmRequest.id, - targetCatId, - body.question, - body.context, - record.threadId, - record.userId, - record.ownerAuthProvenance, - callerCatId, - cloudDispatchProvenance, - request.log, - admission, - ); - } - } + await dispatchViaQueue( + deps, + mmRequest.id, + targetCatIds, + body.question, + body.context, + record.threadId, + record.userId, + record.ownerAuthProvenance, + callerCatId, + cloudDispatchProvenance, + request.log, + sourceMessage, + expectedParentInvocationId, + actionFence, + actionCarrierDisposition, + ); request.log.info( { diff --git a/packages/api/src/routes/callback-typed-wait-source.ts b/packages/api/src/routes/callback-typed-wait-source.ts index 55dac6991c..261a06e5f7 100644 --- a/packages/api/src/routes/callback-typed-wait-source.ts +++ b/packages/api/src/routes/callback-typed-wait-source.ts @@ -1,38 +1,50 @@ -import type { ManagedHoldDispositionService } from '../domains/ball-custody/ManagedHoldDispositionService.js'; import type { TypedWaitSource } from '../domains/ball-custody/TypedWaitRegistration.js'; import type { InvocationRecord } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; +import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import { isOwnerVisibleManagedHoldConnector } from '../domains/cats/services/stores/visibility.js'; -/** Start at callback entry: describe() snapshots adopted sources synchronously before its first await. */ +/** + * Snapshot the exact lifecycle inputs at callback entry. Later queue adoption + * must not retrospectively grant a wait registration authority over a source + * that was not part of this callback's initial view. + */ export async function captureTypedWaitSource( auth: InvocationRecord, deps: { readonly messageStore: Pick; - readonly managedHoldDispositionService?: Partial>; + readonly invocationTracker?: Pick; }, ): Promise { + const activeRun = deps.invocationTracker + ?.getActiveSlots(auth.threadId) + .find((slot) => slot.catId === auth.catId && slot.activeRun?.invocationId === auth.invocationId)?.activeRun; + const inputMessageIds = activeRun ? [...activeRun.inputMessageIds] : []; + try { - const selectionPromise = deps.managedHoldDispositionService?.describe?.(auth); - const selection = await selectionPromise; - if (selection?.state === 'ambiguous_multiple_pending') return undefined; - const selected = selection?.state === 'single_canonical_pending' ? selection.candidates[0] : undefined; - if (selected) { - const source = await deps.messageStore.getById(selected.sourceMessageId); + const managedHoldSources: Extract[] = []; + for (const sourceMessageId of inputMessageIds) { + const source = await deps.messageStore.getById(sourceMessageId); + const taskId = source?.source?.meta?.taskId; if ( !source || !isOwnerVisibleManagedHoldConnector(source, auth.userId) || source.source?.meta?.catId !== auth.catId || - source.source.meta.taskId !== selected.taskId - ) - return undefined; - return { - kind: selected.sourceMessageId === auth.originTriggerMessageId ? 'primary' : 'adopted_hold', - sourceMessageId: selected.sourceMessageId, - holdTaskId: selected.taskId, - }; + typeof taskId !== 'string' || + taskId.length === 0 + ) { + continue; + } + managedHoldSources.push({ + kind: sourceMessageId === auth.originTriggerMessageId ? 'primary' : 'adopted_hold', + sourceMessageId, + holdTaskId: taskId, + }); } - if (!auth.originTriggerMessageId) return undefined; + if (managedHoldSources.length > 1) return undefined; + if (managedHoldSources[0]) return managedHoldSources[0]; + + if (!auth.originTriggerMessageId || !inputMessageIds.includes(auth.originTriggerMessageId)) return undefined; const origin = await deps.messageStore.getById(auth.originTriggerMessageId); if ( !origin || @@ -41,11 +53,12 @@ export async function captureTypedWaitSource( origin.source?.connector === 'hold-ball' || origin.deletedAt !== undefined || origin._tombstone - ) + ) { return undefined; + } return { kind: 'primary', sourceMessageId: origin.id }; } catch { - // Registration remains useful without authority to consume an unresolved source. + // The public tracking task remains useful without private continuation authority. return undefined; } } diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 785187bde4..7942c5349f 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -17,6 +17,7 @@ import type { LocalReviewVerdict, PrAutomationState, RichBlock, + RoutingPreflightDecisionV1, SuggestedCrossPostAction, } from '@cat-cafe/shared'; import { @@ -71,20 +72,19 @@ import { actionSuccessorFencesMatch, reconcileActionSuccessorEnqueue, } from '../domains/ball-custody/reconcile-action-successor-enqueue.js'; -import { turnCustodyAdoptionRegistry } from '../domains/ball-custody/TurnCustodyAdoptionRegistry.js'; -import type { TurnCustodyWakeProvenance } from '../domains/ball-custody/TurnCustodyProjectionService.js'; import { createTypedWaitRegistration } from '../domains/ball-custody/TypedWaitRegistration.js'; import { transitionWaitState } from '../domains/ball-custody/wait-state-machine.js'; -import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; +import { + type InvocationQueue, + type QueueEntry, + queueEntryTargetCats, +} from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; import { MessageDeliveryService } from '../domains/cats/services/agents/invocation/MessageDeliveryService.js'; -import { - queueSourceTargetState, - readQueueCarrierMessages, -} from '../domains/cats/services/agents/invocation/QueueCarrierSourceProjection.js'; -import type { QueuedMessageCustodyCoordinator } from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; +import type { QueueLedgerEntry } from '../domains/cats/services/agents/invocation/queue-ledger/QueueLedger.js'; import { getRichBlockBuffer } from '../domains/cats/services/agents/invocation/RichBlockBuffer.js'; +import type { ThreadExecutionSituationSource } from '../domains/cats/services/agents/invocation/thread-execution-situation.js'; import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; import { extractImagePaths, extractImageUrls } from '../domains/cats/services/agents/providers/image-paths.js'; import { analyzeA2AMentions } from '../domains/cats/services/agents/routing/a2a-mentions.js'; @@ -93,20 +93,7 @@ import { extractRichFromText } from '../domains/cats/services/agents/routing/ric import { buildVoteNotification } from '../domains/cats/services/agents/routing/vote-intercept.js'; import { buildCloudReturnMessageIdempotencyKey } from '../domains/cats/services/cloud-bridge/cloud-return-message.js'; import { getSenderName } from '../domains/cats/services/context/ContextAssembler.js'; -import { checkFreshnessForNotice } from '../domains/cats/services/freshness/checkFreshnessForNotice.js'; -import { - checkFreshnessForPostMessage, - createQueueChecker, - decideCrossThreadFreshnessGate, -} from '../domains/cats/services/freshness/checkFreshnessForPostMessage.js'; import { FreshnessAttentionEventLog } from '../domains/cats/services/freshness/FreshnessAttentionEventLog.js'; -import { FreshnessInvocationStateStore } from '../domains/cats/services/freshness/FreshnessInvocationStateStore.js'; -import { recordQueuedSeenTelemetry } from '../domains/cats/services/freshness/freshness-queue-telemetry.js'; -import { - descriptorFromDriver, - descriptorFromProviderFallback, - resolveFreshnessDescriptorProvider, -} from '../domains/cats/services/freshness/RuntimeCapabilityDescriptor.js'; import type { AgentRouter } from '../domains/cats/services/index.js'; import { classifyLocalReviewLoopBrake, @@ -114,16 +101,17 @@ import { } from '../domains/cats/services/local-review-artifact.js'; import type { EventAuditLog } from '../domains/cats/services/orchestration/EventAuditLog.js'; import type { IRuntimeSessionStore } from '../domains/cats/services/runtime-session/RuntimeSessionStore.js'; -import { compareCursors, cursorFor, parseCursor } from '../domains/cats/services/stores/cursor.js'; +import { compareCursors, cursorFor } from '../domains/cats/services/stores/cursor.js'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IBacklogStore } from '../domains/cats/services/stores/ports/BacklogStore.js'; import type { DeliveryCursorStore } from '../domains/cats/services/stores/ports/DeliveryCursorStore.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import { + type AppendMessageInput, hydrateCrossThreadReplyHint, hydrateReplyPreview, type IMessageStore, isDelivered, - isTimelinePublished, type StoredMessage, } from '../domains/cats/services/stores/ports/MessageStore.js'; import { isManagedWorkBindingConflictError } from '../domains/cats/services/stores/ports/TaskManagedWorkBinding.js'; @@ -140,10 +128,10 @@ import { import { canViewMessage, getTimelineOrderTime, - isDurablyReadableByCat, + isAgentReadableManagedHoldMessage, isInternalNonQuotableParent, isSystemUserMessage, - passesManagedHoldViewerBoundary, + isTimelinePublished, resolveVisibleReplyParent, type Viewer, } from '../domains/cats/services/stores/visibility.js'; @@ -181,11 +169,16 @@ import { } from '../infrastructure/telemetry/instruments.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; import { scoreKeywordRelevance, tokenizeKeyword } from '../utils/keyword-relevance.js'; -import { emitQueueUpdated } from '../utils/queue-enrichment.js'; import { getDefaultUploadDir } from '../utils/upload-paths.js'; import { recordAnchorDrillEvent, recordAnchorPreviewEvent } from './anchor-event-log.js'; import { recordAnchorFullDrill, recordAnchorReturned } from './anchor-telemetry.js'; -import { enqueueA2ATargets, triggerA2AInvocation } from './callback-a2a-trigger.js'; +import { + type A2AFanoutAdmissionPlan, + appendA2ASourceWithLedgerAdmission, + enqueueA2ATargets, + planA2AFanoutAdmission, + preflightA2ATargets, +} from './callback-a2a-trigger.js'; import { anchorPendingMention, anchorThreadMessage, truncateHead } from './callback-anchor-helpers.js'; import { extractCallbackCredentials, @@ -252,7 +245,7 @@ import { registerCallbackWithdrawThreadProposalRoutes } from './callback-withdra import { registerCallbackWorkflowSopRoutes } from './callback-workflow-sop-routes.js'; import { resolveCrossThreadCoordination } from './cross-thread-coordination.js'; import { type FeatIndexEntry, readFeatIndexEntries } from './feat-index-doc-import.js'; -import { buildThreadIdsByFeatId, normalizeFeatId, resolveFeatureOwnerCatId } from './feature-thread-resolver.js'; +import { buildThreadIdsByFeatId, normalizeFeatId } from './feature-thread-resolver.js'; import { verifyKeeperOwnership } from './gate-keeping-cross-store.js'; import { checkGateKeepingGuard } from './gate-keeping-guard.js'; import { @@ -269,7 +262,6 @@ import { clearVoteTimer, closeVoteInternal, voteTimers } from './votes.js'; import { publishDispatchProposal } from './wave2-proposal-publication.js'; const log = createModuleLogger('routes/callbacks'); -const PUBLISHED_TIMELINE_READ = { includeQueuedCatMessages: true } as const; const CALLBACK_EXACT_DUPLICATE_WINDOW_MS = 5_000; /** @@ -300,6 +292,58 @@ function hasPlausibleLineStartMention(content: string): boolean { return false; } +function resolveSlashSeparatedOwnerCatId(ownerWithoutAnnotations: string): CatId | undefined { + const segments = ownerWithoutAnnotations + .split(/[//]/) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + if (segments.length < 2) return undefined; + + const firstResolved = resolveCatTarget(segments[0]); + if (!('ok' in firstResolved)) return undefined; + + const resolved = new Set(); + const unresolved: string[] = []; + for (const segment of segments) { + const result = resolveCatTarget(segment); + if ('ok' in result) { + resolved.add(result.ok); + } else { + unresolved.push(segment); + } + } + + if (resolved.size !== 1) return undefined; + if (unresolved.some((segment) => /[@\u4E00-\u9FFF]/.test(segment))) return undefined; + return firstResolved.ok; +} + +function resolveFeatureOwnerCatId(owner: string | undefined): string | undefined { + if (!owner) return undefined; + const trimmed = owner.trim(); + if (!trimmed) return undefined; + const ownerWithoutAnnotations = trimmed + .replace(/\s*[((][^()()]*[))]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (/[++、,,;;]/.test(ownerWithoutAnnotations)) return undefined; + const slashSeparatedOwnerCatId = resolveSlashSeparatedOwnerCatId(ownerWithoutAnnotations); + if (slashSeparatedOwnerCatId) return slashSeparatedOwnerCatId; + + const candidates = [ + trimmed, + trimmed.match(/@[^\s,,、/+()()]+/)?.[0], + ownerWithoutAnnotations, + ownerWithoutAnnotations.split(/\s+/)[0], + ].filter((value): value is string => Boolean(value && value.trim().length > 0)); + + for (const candidate of candidates) { + const resolved = resolveCatTarget(candidate); + if ('ok' in resolved) return resolved.ok; + } + return undefined; +} + function buildFeatIndexQueryHaystack(item: FeatIndexEntry): string { const ownerCatId = resolveFeatureOwnerCatId(item.owner); return [item.featId, item.name, item.status, item.owner, ownerCatId].filter(Boolean).join(' ').toLowerCase(); @@ -358,6 +402,44 @@ function buildPostMessageRoutingMessage( return parts.length > 0 ? parts.join(' ') : '消息已存储。'; } +function checkRoutingMismatch( + rawDeclaredTargets: readonly string[] | undefined, + resolvedDeclaredTargets: readonly CatId[], + contentTargets: readonly CatId[], +): + | { held: false } + | { + held: true; + response: { + status: 'held'; + reason: 'routing_mismatch'; + declaredTargets: string[]; + parsedTargets: string[]; + unexpectedTargets: string[]; + actions: ['revise_content', 'expand_target_cats']; + guidance: string; + }; + } { + if (!rawDeclaredTargets || rawDeclaredTargets.length === 0) return { held: false }; + const resolvedSet = new Set(resolvedDeclaredTargets.map(String)); + const unexpectedTargets = contentTargets.filter((catId) => !resolvedSet.has(String(catId))).map(String); + if (unexpectedTargets.length === 0) return { held: false }; + return { + held: true, + response: { + status: 'held', + reason: 'routing_mismatch', + declaredTargets: [...rawDeclaredTargets], + parsedTargets: contentTargets.map(String), + unexpectedTargets, + actions: ['revise_content', 'expand_target_cats'], + guidance: + `content 行首 @ 解析出的目标(${unexpectedTargets.map((id) => `@${id}`).join('、')})不在声明的 targetCats 内。` + + '消息未发送。请修改 content 的 @ 写法,或把这些猫加进 targetCats 后重试。', + }, + }; +} + function buildRoutingOutcome(requestedIds: string[], enqueuedIds: readonly string[], enqueueAttempted: boolean) { if (!enqueueAttempted) { return { routed: [], notEnqueued: requestedIds }; @@ -456,6 +538,18 @@ function isCompleteLocalReviewAnchor(input: LocalReviewAnchorInput): boolean { ); } +function isMentionOwnedByQueue( + invocationQueue: InvocationQueue | undefined, + message: StoredMessage, + catId: CatId, +): boolean { + return ( + invocationQueue + ?.list(message.threadId, message.userId) + .some((entry) => entry.payload.messageId === message.id && queueEntryTargetCats(entry).includes(catId)) ?? false + ); +} + function hasAnyLocalReviewAnchor(input: LocalReviewAnchorInput): boolean { return Boolean(input.reviewSubjectRef || input.acceptedSourceRef || input.acceptedRevision); } @@ -769,27 +863,23 @@ function hasQueuedActionSuccessorFence( return ( invocationQueue ?.list(threadId, userId) - .some((entry) => actionSuccessorFencesMatch(entry.actionSuccessorFence, fence)) ?? false + .some((entry) => actionSuccessorFencesMatch(entry.execution.actionSuccessorFence, fence)) ?? false ); } -async function recoverQueuedDuplicateCallbackMessage(input: { +/** Re-establish only the missing recipient wake for an already-public duplicate. */ +async function ensureDuplicateCallbackWake(input: { duplicateMsg: StoredMessage; - willEnqueueToQueue: boolean; + canEnqueueA2A: boolean; invocationQueue?: InvocationQueue; threadId: string; userId: string; actionFence?: ActionSuccessorFence; log: Pick; enqueueA2A: () => Promise<{ enqueued: readonly CatId[] }>; - markDelivered?: (deliveredAt: number) => Promise | unknown; - zeroEnqueuedWarnMessage: string; enqueueFailureMessage: string; - broadcastNow: () => Promise | void; - preserveQueuedOnEnqueueFailure?: boolean; }): Promise { - if (input.duplicateMsg.deliveryStatus !== 'queued') return false; - if (!input.willEnqueueToQueue) return false; + if (!input.canEnqueueA2A) return false; if (hasQueuedA2AEntryForMessage(input.invocationQueue, input.threadId, input.duplicateMsg.id)) { return input.actionFence ? hasQueuedActionSuccessorFence(input.invocationQueue, input.threadId, input.userId, input.actionFence) @@ -798,23 +888,12 @@ async function recoverQueuedDuplicateCallbackMessage(input: { const deliveryDecision = await MessageDeliveryService.resolveCallbackDeliveryDecision({ canEnqueueA2A: true, - willEnqueueToQueue: input.willEnqueueToQueue, messageId: input.duplicateMsg.id, threadId: input.threadId, log: input.log, enqueueA2A: input.enqueueA2A, - markDelivered: input.markDelivered, - zeroEnqueuedWarnMessage: input.zeroEnqueuedWarnMessage, enqueueFailureMessage: input.enqueueFailureMessage, - ...(input.preserveQueuedOnEnqueueFailure ? { preserveQueuedOnEnqueueFailure: true } : {}), }); - - if ( - deliveryDecision.shouldBroadcastNow && - !hasQueuedA2AEntryForMessage(input.invocationQueue, input.threadId, input.duplicateMsg.id) - ) { - await input.broadcastNow(); - } return ( deliveryDecision.enqueued.length > 0 || hasQueuedA2AEntryForMessage(input.invocationQueue, input.threadId, input.duplicateMsg.id) @@ -835,6 +914,8 @@ export interface CallbackRoutesOptions { 'claim' | 'commit' | 'release' >; messageStore: IMessageStore; + /** Exact lifecycle-backed execution state shared by prompts, UI, and thread-context reads. */ + threadExecutionSituationSource?: ThreadExecutionSituationSource; socketManager: SocketManager; /** F174 D2b-1: in-context surface for callback auth failures (optional — back-compat). */ callbackAuthNotifier?: CallbackAuthSystemMessageNotifier; @@ -872,6 +953,7 @@ export interface CallbackRoutesOptions { workspacePersonResolver?: import('../domains/memory/people/WorkspacePersonResolver.js').WorkspacePersonResolver; /** F292: late-bound, source-authoritative, version-fenced meeting artifact reader. */ meetingArtifactReaderHolder?: MeetingArtifactReaderHolder; + /** F202: late-bound named-cat content editor shared with the human surface. */ namedCatContentHolder?: NamedCatContentHolder; /** F287 Phase C: owner-scoped opaque cue drill and content-free outcome callbacks. */ memoryCueDeps?: CallbackMemoryCueDeps; @@ -961,8 +1043,14 @@ export interface CallbackRoutesOptions { holdBallDeps?: HoldBallRouteDeps; /** Queue auto-dequeue on A2A invocation completion */ queueProcessor?: { - onInvocationComplete: import('../domains/cats/services/agents/invocation/QueueProcessor.js').QueueProcessor['onInvocationComplete']; - tryAutoExecute(threadId: string): Promise; + onInvocationComplete( + threadId: string, + catId: string, + status: 'succeeded' | 'failed' | 'canceled' | 'canceled_by_user', + invocationId: string | undefined, + completedCatIds: readonly string[], + ): Promise; + requestDrain(threadId: string): Promise; registerEntryCompleteHook( entryId: string, hook: ( @@ -972,17 +1060,24 @@ export interface CallbackRoutesOptions { ) => void, ): void; unregisterEntryCompleteHook(entryId: string): void; - markPromptMessagesSeen?: import('../domains/cats/services/agents/invocation/QueueProcessor.js').QueueProcessor['markPromptMessagesSeen']; - resolvePromptMessageCustodyWakes?(input: { + adoptExposedQueuedEntries?(input: { threadId: string; + userId: string; catId: string; - messageIds: readonly string[]; - }): Promise; + invocationId: string; + entries: readonly { entryId: string; messageId: string }[]; + seenAt?: number; + }): Promise< + | { outcome: 'adopted'; adoptedEntryIds: string[] } + | { + outcome: 'rejected'; + reason: 'active_run_missing' | 'state_changed' | 'lifecycle_conflict' | 'persistence_unavailable'; + entryId?: string; + } + >; }; /** F122B: InvocationQueue for agent-sourced A2A entries */ invocationQueue?: import('../domains/cats/services/agents/invocation/InvocationQueue.js').InvocationQueue; - /** F254: await exact Queue ACK persistence before releasing callback evidence. */ - queueCustodyCoordinator?: QueuedMessageCustodyCoordinator; /** F167 Phase S: durable subject/action/slot admission for A2A successor dispatch. */ actionSuccessorAdmissionService?: import('../domains/ball-custody/ActionSuccessorAdmissionService.js').ActionSuccessorAdmissionService; /** F126: Limb node registry for device/hardware capability management */ @@ -1046,8 +1141,6 @@ const postMessageSchema = z.object({ reviewSubjectRef: reviewSubjectRefSchema.optional(), acceptedSourceRef: acceptedSourceRefSchema.optional(), acceptedRevision: acceptedRevisionSchema.optional(), - // F254 Phase A: acknowledge held — escape hatch to force-send despite unseen messages - acknowledgeHeld: z.boolean().optional(), // F167 Phase S: structured subject/action/slot successor identity. action: actionSuccessorMetadataSchema.optional(), // F246 Workstream 2: authority proposed for later operator promotion. @@ -1364,11 +1457,12 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(threadStore ? { threadStore } : {}), }); } - if (opts.namedCatContentHolder) + if (opts.namedCatContentHolder) { registerCallbackContentEditorRoutes(app, { holder: opts.namedCatContentHolder, ...(threadStore ? { threadStore } : {}), }); + } if (threadStore && opts.sessionChainStore && opts.runtimeSessionStore) { registerCallbackRuntimeSessionRoutes(app, { threadStore, @@ -1588,17 +1682,6 @@ export const callbacksRoutes: FastifyPluginAsync = async reply.status(contentProjection.statusCode); return { kind: contentProjection.kind, message: contentProjection.message }; } - let richBlocks = extractedBlocks; - const synthesizer = getVoiceBlockSynthesizer(); - if (synthesizer && richBlocks.some((b) => b.kind === 'audio' && 'text' in b)) { - try { - richBlocks = await synthesizer.resolveVoiceBlocks(richBlocks, principal.catId); - } catch (err) { - app.log.error({ err }, '[agent-key/post-message] Voice block synthesis failed'); - } - } - - // F182 AC-C1: use analyzeA2AMentions (captures routing_warnings for disabled cats) const contentAnalysis = analyzeA2AMentions(storedContent, senderCatId); const contentTargets = contentAnalysis.mentions; const validExplicitTargets: CatId[] = []; @@ -1611,6 +1694,21 @@ export const callbacksRoutes: FastifyPluginAsync = async routing_warnings.push(resolved.error); } } + const mismatch = checkRoutingMismatch(explicitTargetCats, validExplicitTargets, contentTargets); + if (mismatch.held) { + return { ...mismatch.response, ...(clientMessageId ? { clientMessageId } : {}) }; + } + + let richBlocks = extractedBlocks; + const synthesizer = getVoiceBlockSynthesizer(); + if (synthesizer && richBlocks.some((b) => b.kind === 'audio' && 'text' in b)) { + try { + richBlocks = await synthesizer.resolveVoiceBlocks(richBlocks, principal.catId); + } catch (err) { + app.log.error({ err }, '[agent-key/post-message] Voice block synthesis failed'); + } + } + const mergedTargets = new Set([...contentTargets, ...validExplicitTargets]); // F177-H: Agent-key participant awareness — same check as invocation-auth @@ -1684,7 +1782,6 @@ export const callbacksRoutes: FastifyPluginAsync = async const extra = Object.keys(extraParts).length > 0 ? extraParts : undefined; const hasA2AMentions = !!(mentions.length > 0 && router && invocationRecordStore && effectiveThreadId); - const willEnqueueToQueue = !!(hasA2AMentions && opts.invocationQueue); const now = Date.now(); const cloudReturnGrantScope = usesServerGrant && validatedReplyTo @@ -1767,16 +1864,11 @@ export const callbacksRoutes: FastifyPluginAsync = async now, }) : undefined); - const recoverPersistedCallbackMessage = async (duplicateMsg: StoredMessage) => { - // A durable exact-source duplicate is recovery truth from a previous - // append, not a fresh delivery request. Route and broadcast only the - // persisted message so regenerated retry content/targets cannot change - // who receives which trigger after a process restart. - const duplicateMentions = [...duplicateMsg.mentions]; - const duplicateReplyTo = duplicateMsg.replyTo; - const duplicateExplicitTargets = duplicateMsg.extra?.targetCats ?? []; - const duplicateWillEnqueueToQueue = Boolean( - duplicateMentions.length > 0 && router && invocationRecordStore && opts.invocationQueue, + const recoverPersistedCallbackMessage = async (persisted: StoredMessage) => { + const persistedMentions = [...persisted.mentions]; + const persistedReplyTo = persisted.replyTo; + const canRecoverWake = Boolean( + persistedMentions.length > 0 && router && invocationRecordStore && opts.invocationQueue, ); if (cloudReturnGrantClaim && cloudReturnGrantStore && cloudReturnGrantScope) { await commitCloudReturnGrantAfterPersistence({ @@ -1785,12 +1877,12 @@ export const callbacksRoutes: FastifyPluginAsync = async log: app.log, threadId: effectiveThreadId, sourceMessageId: cloudReturnGrantScope.sourceMessageId, - messageId: duplicateMsg.id, + messageId: persisted.id, }); } - const recovered = await recoverQueuedDuplicateCallbackMessage({ - duplicateMsg, - willEnqueueToQueue: duplicateWillEnqueueToQueue, + const recovered = await ensureDuplicateCallbackWake({ + duplicateMsg: persisted, + canEnqueueA2A: canRecoverWake, ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), threadId: effectiveThreadId, userId: principal.userId, @@ -1798,77 +1890,41 @@ export const callbacksRoutes: FastifyPluginAsync = async enqueueA2A: () => enqueueA2ATargets( { - router: router!, - invocationRecordStore: invocationRecordStore!, socketManager, messageStore, ...(invocationTracker ? { invocationTracker } : {}), ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.ballCustody ? { ballCustody: opts.ballCustody } : {}), ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, { - targetCats: duplicateMentions, - content: duplicateMsg.content, + targetCats: persistedMentions, + content: persisted.content, userId: principal.userId, ownerAuthProvenance: 'unknown', threadId: effectiveThreadId, - triggerMessage: duplicateMsg, + triggerMessage: persisted, callerCatId: senderCatId, }, ), - markDelivered: (deliveredAt) => messageStore.markDelivered?.(duplicateMsg.id, deliveredAt), - ...(localReviewVerdict ? { preserveQueuedOnEnqueueFailure: true } : {}), - zeroEnqueuedWarnMessage: '[agent-key/post-message] queued duplicate had no A2A entry — broadcasting anyway', - enqueueFailureMessage: '[agent-key/post-message] queued duplicate recovery failed — broadcasting anyway', - broadcastNow: async () => { - const replyPreview = duplicateReplyTo - ? await hydrateReplyPreview(messageStore, duplicateReplyTo) - : undefined; - socketManager.broadcastAgentMessage( - { - type: 'text', - catId: principal.catId, - content: duplicateMsg.content, - origin: 'callback', - messageId: duplicateMsg.id, - invocationId: duplicateMsg.id, - // #814: Always include isExplicitPost in broadcast so frontend TD112 dedup skips merge - extra: { - isExplicitPost: true, - ...(duplicateExplicitTargets.length ? { targetCats: duplicateExplicitTargets } : {}), - }, - ...(duplicateMsg.mentionsUser ? { mentionsUser: true } : {}), - ...(duplicateReplyTo ? { replyTo: duplicateReplyTo } : {}), - ...(replyPreview ? { replyPreview } : {}), - timestamp: Date.now(), - }, - effectiveThreadId, - ); - }, + enqueueFailureMessage: '[agent-key/post-message] duplicate wake admission failed', }); - if ( - localReviewVerdict && - duplicateMsg.deliveryStatus === 'queued' && - duplicateWillEnqueueToQueue && - !recovered - ) { + if (localReviewVerdict && persisted.deliveryStatus === 'queued' && canRecoverWake && !recovered) { reply.status(503); return { kind: 'review_delivery_pending', message: 'The review fact is durable; retry the same clientMessageId to restore its author wake.', - messageId: duplicateMsg.id, + messageId: persisted.id, clientMessageId, }; } return { status: 'duplicate', threadId: effectiveThreadId, - messageId: duplicateMsg.id, - ...(duplicateReplyTo ? { replyTo: duplicateReplyTo } : {}), + messageId: persisted.id, + ...(persistedReplyTo ? { replyTo: persistedReplyTo } : {}), ...(clientMessageId ? { clientMessageId } : {}), }; }; @@ -1885,11 +1941,6 @@ export const callbacksRoutes: FastifyPluginAsync = async } if (!usesServerGrant && !localReviewVerdict) { - // Race-safe backstop for ordinary agent-key posts (for example the - // shared Antigravity MCP). Server-grant returns deliberately bypass - // this transient fingerprint: their exact-source idempotency key is - // durable and atomic, while a failed append must remain retryable with - // identical content after the grant lease is released. const agentKeyContentDuplicate = await claimCallbackContentOrDuplicate(messageStore, { threadId: effectiveThreadId, userId: principal.userId, @@ -1907,48 +1958,99 @@ export const callbacksRoutes: FastifyPluginAsync = async if (agentKeyContentDuplicate) return agentKeyContentDuplicate; } - let storedMsg: Awaited>; - try { - const appendInput: Parameters[0] = { - threadId: effectiveThreadId, - userId: principal.userId, - catId: principal.catId, - content: persistedContent, - mentions, - ...(mentionsUser ? { mentionsUser } : {}), - origin: 'callback', - timestamp: now, - ...(extra ? { extra } : {}), - ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), - ...(willEnqueueToQueue ? { deliveryStatus: 'queued' as const } : {}), - ...(cloudReturnMessageIdempotencyKey || localReviewFactMessageIdempotencyKey - ? { idempotencyKey: cloudReturnMessageIdempotencyKey ?? localReviewFactMessageIdempotencyKey } - : {}), - }; - if (cloudReturnMessageIdempotencyKey || localReviewFactMessageIdempotencyKey) { - const appendResult = await messageStore.appendIdempotent(appendInput); - storedMsg = appendResult.message; - if (appendResult.idempotent) { - if (localReviewVerdict && !localReviewDuplicateMatches(storedMsg)) { - reply.status(409); - return { - kind: 'review_fact_idempotency_conflict', - message: 'This clientMessageId already names a different durable local review fact.', - messageId: storedMsg.id, - clientMessageId, - }; - } - return recoverPersistedCallbackMessage(storedMsg); + let a2aAdmissionPlan: A2AFanoutAdmissionPlan | undefined; + let a2aRoutingPreflightDecision: RoutingPreflightDecisionV1 | undefined; + const a2aAdmissionOptions = hasA2AMentions + ? { + targetCats: mentions, + content: persistedContent, + userId: principal.userId, + ownerAuthProvenance: 'unknown' as const, + threadId: effectiveThreadId, + createdAt: now, + callerCatId: senderCatId, } + : undefined; + if (a2aAdmissionOptions) { + if (!opts.invocationQueue || !queueProcessor?.requestDrain) { + reply.status(503); + return { + kind: 'a2a_admission_unavailable', + message: 'Recipient wake admission is unavailable; no message was published.', + }; + } + const routingPreflight = await preflightA2ATargets( + opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}, + { targetCats: mentions, content: storedContent, userId: principal.userId }, + ); + a2aRoutingPreflightDecision = routingPreflight.decision; + a2aAdmissionPlan = planA2AFanoutAdmission( + { invocationQueue: opts.invocationQueue }, + { + ...a2aAdmissionOptions, + targetCats: routingPreflight.acceptedTargetCats, + requestedTargetCats: routingPreflight.requestedTargetCats, + }, + ); + } + + const appendInput: AppendMessageInput = { + from: { kind: 'agent', catId: principal.catId }, + threadId: effectiveThreadId, + userId: principal.userId, + content: persistedContent, + mentions, + ...(mentionsUser ? { mentionsUser } : {}), + origin: 'callback', + timestamp: now, + ...(extra ? { extra } : {}), + ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), + ...(cloudReturnMessageIdempotencyKey || localReviewFactMessageIdempotencyKey + ? { idempotencyKey: cloudReturnMessageIdempotencyKey ?? localReviewFactMessageIdempotencyKey } + : {}), + }; + let atomicAdmission: Awaited>; + let persistedReplay = false; + try { + if (a2aAdmissionPlan) { + atomicAdmission = await appendA2ASourceWithLedgerAdmission( + { messageStore, invocationQueue: opts.invocationQueue }, + appendInput, + { + plan: a2aAdmissionPlan, + ownerAuthProvenance: 'unknown', + }, + ); + } else if (appendInput.idempotencyKey) { + const result = await messageStore.appendIdempotent(appendInput); + atomicAdmission = { message: result.message }; + persistedReplay = result.idempotent; } else { - storedMsg = await messageStore.append(appendInput); + atomicAdmission = { message: await messageStore.append(appendInput) }; } } catch (error) { if (cloudReturnGrantClaim && cloudReturnGrantStore) { await cloudReturnGrantStore.release(cloudReturnGrantClaim); } + if (localReviewVerdict && a2aAdmissionPlan) { + app.log.warn( + { err: error, threadId: effectiveThreadId, clientMessageId }, + '[agent-key/post-message] atomic local-review admission unavailable', + ); + reply.status(503); + return { + kind: 'review_delivery_pending', + message: + 'The review fact was not published; retry the same clientMessageId for atomic author wake admission.', + ...(clientMessageId ? { clientMessageId } : {}), + }; + } throw error; } + const storedMsg = atomicAdmission.message; + if (persistedReplay || atomicAdmission.preAdmittedReplayed) { + return recoverPersistedCallbackMessage(storedMsg); + } if (cloudReturnGrantClaim && cloudReturnGrantStore && cloudReturnGrantScope) { const committed = await commitCloudReturnGrantAfterPersistence({ store: cloudReturnGrantStore, @@ -1970,22 +2072,18 @@ export const callbacksRoutes: FastifyPluginAsync = async const deliveryDecision = await MessageDeliveryService.resolveCallbackDeliveryDecision({ canEnqueueA2A: hasA2AMentions, - willEnqueueToQueue, messageId: storedMsg.id, threadId: effectiveThreadId, log: app.log, enqueueA2A: () => enqueueA2ATargets( { - router: router!, - invocationRecordStore: invocationRecordStore!, socketManager, messageStore, ...(invocationTracker ? { invocationTracker } : {}), ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.ballCustody ? { ballCustody: opts.ballCustody } : {}), ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, @@ -1997,61 +2095,51 @@ export const callbacksRoutes: FastifyPluginAsync = async threadId: effectiveThreadId, triggerMessage: storedMsg, callerCatId: senderCatId, + ...(a2aAdmissionPlan ? { preplannedAdmission: a2aAdmissionPlan } : {}), + ...(a2aRoutingPreflightDecision ? { routingPreflightDecision: a2aRoutingPreflightDecision } : {}), + ...(atomicAdmission.preAdmittedEntries + ? { + preAdmittedEntries: atomicAdmission.preAdmittedEntries, + preAdmittedReplayed: atomicAdmission.preAdmittedReplayed, + } + : {}), }, ), - markDelivered: (deliveredAt) => messageStore.markDelivered?.(storedMsg.id, deliveredAt), - ...(localReviewVerdict ? { preserveQueuedOnEnqueueFailure: true } : {}), - zeroEnqueuedWarnMessage: - '[agent-key/post-message] routing preflight or queue guards left no A2A target — broadcasting receipt carrier', - enqueueFailureMessage: '[agent-key/post-message] enqueueA2ATargets failed — falling back to broadcast', + enqueueFailureMessage: '[agent-key/post-message] wake admission failed', }); - if (localReviewVerdict && deliveryDecision.enqueueFailed) { - reply.status(503); - return { - kind: 'review_delivery_pending', - message: 'The review fact is durable; retry the same clientMessageId to restore its author wake.', + socketManager.broadcastAgentMessage( + { + type: 'text', + catId: principal.catId, + content: storedContent, + origin: 'callback', messageId: storedMsg.id, - clientMessageId, - }; - } + invocationId: storedMsg.id, + // #814: Always include isExplicitPost in broadcast so frontend TD112 dedup skips merge + extra: { + isExplicitPost: true, + ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + }, + ...(mentionsUser ? { mentionsUser } : {}), + ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), + ...(replyPreview ? { replyPreview } : {}), + timestamp: Date.now(), + }, + effectiveThreadId, + ); - // #607: Only broadcast when message is not queued — queued messages are - // broadcast later via messages_delivered when QueueProcessor delivers them. - if (deliveryDecision.shouldBroadcastNow) { + for (const block of richBlocks) { socketManager.broadcastAgentMessage( { - type: 'text', + type: 'system_info' as const, catId: principal.catId, - content: persistedContent, - origin: 'callback', - messageId: storedMsg.id, + content: JSON.stringify({ type: 'rich_block', block, messageId: storedMsg.id }), invocationId: storedMsg.id, - // #814: Always include isExplicitPost in broadcast so frontend TD112 dedup skips merge - extra: { - isExplicitPost: true, - ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), - }, - ...(mentionsUser ? { mentionsUser } : {}), - ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), - ...(replyPreview ? { replyPreview } : {}), timestamp: Date.now(), }, effectiveThreadId, ); - - for (const block of richBlocks) { - socketManager.broadcastAgentMessage( - { - type: 'system_info' as const, - catId: principal.catId, - content: JSON.stringify({ type: 'rich_block', block, messageId: storedMsg.id }), - invocationId: storedMsg.id, - timestamp: Date.now(), - }, - effectiveThreadId, - ); - } } if (opts.outboundHook) { @@ -2108,12 +2196,12 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(clientMessageId ? { clientMessageId } : {}), ...(routing_warnings.length > 0 ? { routing_warnings } : {}), message: buildPostMessageRoutingMessage(routingOutcome.routed, routing_warnings, routingOutcome.notEnqueued), - ...(deliveryDecision.routingPreflight ? { routing_preflight: deliveryDecision.routingPreflight } : {}), }; } const record = request.callbackAuth!; const actor = deriveCallbackActor(record); + const senderCatId = createCatId(actor.catId); const parsed = postMessageSchema.safeParse(request.body); if (!parsed.success) { @@ -2134,7 +2222,6 @@ export const callbacksRoutes: FastifyPluginAsync = async reviewSubjectRef, acceptedSourceRef, acceptedRevision, - acknowledgeHeld, action, proposedAction, streamDisposition, @@ -2688,6 +2775,11 @@ export const callbacksRoutes: FastifyPluginAsync = async }; } + const { cleanText: storedContent, blocks: extractedBlocks } = extractRichFromText(content); + // Parse routing syntax before lifecycle projection. Terminal coordination + // may suppress only unaddressed courtesy prose; a line-start @mention or + // structured targetCats is a new active hop and must never be swallowed. + const contentAnalysis = analyzeA2AMentions(storedContent, isCrossThread ? undefined : senderCatId); const incomingCrossThreadHint = record.a2aTriggerMessageId ? await hydrateCrossThreadReplyHint(messageStore, record.a2aTriggerMessageId) : null; @@ -2695,29 +2787,17 @@ export const callbacksRoutes: FastifyPluginAsync = async // this carrier starts substantive successor work, not a courtesy ACK to the // terminal message that triggered the current invocation. Make that proof // an active coordination transition even when the caller omits the redundant - // coordination field. Plain targetCats/@mentions remain suppressible so the - // original ACK-of-ACK guard stays fail-closed without free-text intent parsing. + // coordination field. Any explicit routing credential reopens a fresh + // active generation below. const effectiveCoordination = action ? { ...(explicitCoordination ?? { phase: 'active' as const }), subjectRef: action.subjectRef, } : explicitCoordination; - const crossThreadFreshnessPolicy = decideCrossThreadFreshnessGate({ - isCrossThread, - ...(effectClass ? { effectClass } : {}), - ...(explicitCoordination?.id - ? { coordinationId: explicitCoordination.id } - : incomingCrossThreadHint?.coordination?.id - ? { coordinationId: incomingCrossThreadHint.coordination.id } - : {}), - ...(replyTo ? { replyToMessageId: replyTo } : {}), - }); - // Resolve the ordinary carrier's canonical targets before any policy that // can claim, buffer, queue, or emit it. The proposal store's index is // deny-only; canonical fields are revalidated inside the store lookup. - const senderCatId = createCatId(actor.catId); const coordinationResult = effectiveCoordination || incomingCrossThreadHint?.coordination ? resolveCrossThreadCoordination({ @@ -2731,6 +2811,11 @@ export const callbacksRoutes: FastifyPluginAsync = async } : {}), targetThreadId: effectiveThreadId, + hasExplicitRoutingIntent: + !action && + (contentAnalysis.mentions.length > 0 || + contentAnalysis.routing_warnings.length > 0 || + (explicitTargetCats?.length ?? 0) > 0), }) : { suppressRouting: false, coordination: undefined }; if (coordinationResult.conflict) { @@ -2775,127 +2860,8 @@ export const callbacksRoutes: FastifyPluginAsync = async return negativeAuthorizationFence.body; } - // F254 Phase A: Freshness gate — hold post_message if cat has unseen messages - // in the target thread. Uses independent seenCursor (NOT deliveryCursor — AC-A9). - // Gate is fail-open: no cursor → forward; error → forward (log + continue). - // - // Placement: AFTER all validation checks that can reject the request — - // resolveScopedThreadId (403), cross_post_no_routing (400), assign_work (400). - // The gate must not run before these or it would return 'held' instead of - // the correct error contract (gpt52 R1-P2 + R2-P2). - if (deliveryCursorStore && crossThreadFreshnessPolicy.mode === 'gate') { - try { - // Build visibility filter aligned with thread-context's canIncludeContextItem. - // ALWAYS applied (not just play mode) — deleted/briefing/undelivered messages - // must be excluded in ALL modes to prevent false holds and preview leaks (gpt52 R2-P1). - const needsFreshnessPlayFilter = threadStore - ? await (async () => { - const thread = await threadStore.get(effectiveThreadId); - return !!thread && (thread.thinkingMode ?? 'debug') === 'play'; - })() - : false; - const freshnessViewer = needsFreshnessPlayFilter - ? { type: 'cat' as const, catId: createCatId(actor.catId) } - : { type: 'user' as const }; - - const messageFilter = (msg: Record): boolean => { - // Baseline visibility (applies in ALL modes): - if (msg.deletedAt) return false; - if (!isDelivered(msg as unknown as Parameters[0])) return false; - // #1200 codex R11 P1: system-generated messages (persisted error badges) - // are display-only — route-helpers.ts:744-745 excludes them from freshness. - if (msg.userId === 'system') return false; - if (msg.origin === 'briefing') return false; - // Play-mode privacy visibility. `origin` is transport provenance; - // persisted cat speech remains freshness-relevant. - if (needsFreshnessPlayFilter) { - if (!canViewMessage(msg as unknown as Parameters[0], freshnessViewer)) return false; - } - if (isCrossThread && crossThreadFreshnessPolicy.reason === 'cross_thread_causal_overlap') { - const coordinationId = explicitCoordination?.id ?? incomingCrossThreadHint?.coordination?.id; - const messageExtra = msg.extra as - | { - coordination?: { id?: string }; - crossPost?: { coordination?: { id?: string } }; - } - | undefined; - const messageCoordinationId = messageExtra?.coordination?.id ?? messageExtra?.crossPost?.coordination?.id; - const coordinationMatches = Boolean(coordinationId && messageCoordinationId === coordinationId); - const replyMatches = Boolean(replyTo && (msg.id === replyTo || msg.replyTo === replyTo)); - if (!coordinationMatches && !replyMatches) return false; - } - return true; - }; - - // AC-A7: wire event log for recording held/forward decisions (P1 fix gpt52 R1) - const freshnessEventLog = opts.redis ? new FreshnessAttentionEventLog(opts.redis) : undefined; - - // F254 AC-C2/C3: Derive RuntimeCapabilityDescriptor from stored carrierTier. - // carrierTier is stored in FreshnessInvocationStateStore at invocation start; - // when not yet stored (pre-wiring), descriptor is undefined → backward compat. - // Provider-only fallback (gpt52 terminal review P1/R2): non-Claude - // services may not write carrierTier, so derive from the most specific - // provider marker when available (e.g. openai-chatgpt-pro cloud-only). - const actorCatConfig = catRegistry.tryGet(actor.catId); - const actorProvider = resolveFreshnessDescriptorProvider(actorCatConfig?.config); - let freshnessDescriptor; - if (opts.redis && invocationId) { - const freshnessStateStore = new FreshnessInvocationStateStore(opts.redis); - const freshnessState = await freshnessStateStore.get(invocationId); - if (freshnessState?.carrierTier) { - freshnessDescriptor = descriptorFromDriver(actorProvider, freshnessState.carrierTier); - } else { - freshnessDescriptor = descriptorFromProviderFallback(actorProvider); - } - } else { - freshnessDescriptor = descriptorFromProviderFallback(actorProvider); - } - - const freshnessDecision = await checkFreshnessForPostMessage({ - userId: actor.userId, - catId: actor.catId as CatId, - threadId: effectiveThreadId, - invocationId, - // AC-A6: cross_post_message uses its own toolName for audit trail - toolName: isCrossThread ? 'cross_post_message' : 'post_message', - cursorStore: deliveryCursorStore, - messageStore, - acknowledgeHeld, - messageFilter, - eventLog: freshnessEventLog, - // F254 queue-aware gate: detect queued messages hidden by isDelivered() - queueChecker: opts.invocationQueue - ? createQueueChecker(opts.invocationQueue, { parentInvocationId: effectiveInvId }) - : undefined, - // F254 AC-C3: descriptor parameterizes held/notice behavior per carrier tier - descriptor: freshnessDescriptor, - ...(turnExecution?.causal?.coveredMessageIds - ? { coveredMessageIds: turnExecution.causal.coveredMessageIds } - : {}), - }); - if (freshnessDecision.decision === 'held') { - return { - status: 'held', - reason: 'newer_messages_available', - unseenCount: freshnessDecision.unseenCount, - previews: freshnessDecision.previews ?? [], - omittedCount: freshnessDecision.omittedCount ?? 0, - actions: ['read_latest', 'revise', 'send_with_acknowledge'], - ...(clientMessageId ? { clientMessageId } : {}), - }; - } - } catch (err) { - // Fail-open: if freshness check errors, log and continue (don't block the cat) - app.log.warn( - { err, catId: actor.catId, threadId: effectiveThreadId }, - '[F254] freshness gate error, fail-open', - ); - } - } - let actionFence: ActionSuccessorFence | undefined; let actionAdmissionOutcome: ActionSuccessorCarrierAdmissionOutcome | undefined; - let interruptedActionCarrierRecoveryKey: string | undefined; const actionCarrierDisposition: ActionSuccessorCarrierDisposition | undefined = action ? action.returnToPredecessor ? 'return' @@ -2973,18 +2939,14 @@ export const callbacksRoutes: FastifyPluginAsync = async } if (admission.outcome === 'safe_wait') { const carrier = await resolveDirectActionSuccessorCarrier({ - messageStore, + invocationQueue: opts.invocationQueue!, lease: admission.lease, admissionInput, }); if (carrier.disposition === 'live') { return { status: 'safe_wait', actionLease: admission.lease, clientMessageId }; } - if (carrier.disposition === 'restart_interrupted') { - actionAdmissionOutcome = 'replayed'; - actionFence = carrier.fence; - interruptedActionCarrierRecoveryKey = `action-carrier-recovery:${carrier.fence.leaseId}:${carrier.fence.generation}`; - } else { + if (carrier.reason !== 'carrier_missing') { reply.status(409); return { status: 'action_carrier_unavailable', @@ -2993,6 +2955,14 @@ export const callbacksRoutes: FastifyPluginAsync = async clientMessageId, }; } + // The active generation still owns exact authority, but a restart + // can remove its pending-only Queue carrier before the callback + // retry arrives. Continue through ordinary duplicate recovery so + // the already-persisted source is re-enqueued under the same fence; + // Queue lifecycle reconciliation prevents an already-dispatched + // source from re-entering the provider. + actionAdmissionOutcome = 'replayed'; + actionFence = buildActionSuccessorFence(admission.lease, admission.lease.dispatchId); } else if (admission.outcome !== 'replayed') { return { status: admission.outcome, actionLease: admission.lease, clientMessageId }; } else { @@ -3048,7 +3018,6 @@ export const callbacksRoutes: FastifyPluginAsync = async // Content-shape rejection must precede the idempotency claim so a corrected // retry can reuse its clientMessageId. Buffered blocks are intentionally not // admission evidence: a navigation action must be carried by this callback. - const { cleanText: storedContent, blocks: extractedBlocks } = extractRichFromText(content); const contentProjection = await projectCallbackContentForStorage( threadStore, opts.conciergeConfigStore, @@ -3062,13 +3031,10 @@ export const callbacksRoutes: FastifyPluginAsync = async reply.status(contentProjection.statusCode); return { kind: contentProjection.kind, message: contentProjection.message }; } - // At-least-once de-duplication: retries with same clientMessageId are treated as duplicate. - // A proven interrupted action carrier is the exception: its stable append key - // lets the same request finish a crash-after-append recovery idempotently. if (clientMessageId && !localReviewVerdict) { const isFirstSeen = await registry.claimClientMessageId(invocationId, clientMessageId); - if (!isFirstSeen && !interruptedActionCarrierRecoveryKey) { + if (!isFirstSeen) { if (actionFence && actionCarrierDisposition === 'return') { const accepted = hasQueuedActionSuccessorFence( opts.invocationQueue, @@ -3116,7 +3082,6 @@ export const callbacksRoutes: FastifyPluginAsync = async // Parse line-start @mentions (A2A rule: only line-start, strip code blocks, single target) // Uses analyzeA2AMentions to capture routing_warnings for disabled cats (F182 KD-10). // F52: Cross-thread posts skip self-reference filter so @codex can trigger target thread's codex - const contentAnalysis = analyzeA2AMentions(storedContent, isCrossThread ? undefined : senderCatId); // Action-scoped dispatch uses explicit targetCats as the authoritative holder set. // Text mentions remain presentation only and cannot silently mutate lease cardinality. const contentTargets = action ? [] : contentAnalysis.mentions; @@ -3138,6 +3103,13 @@ export const callbacksRoutes: FastifyPluginAsync = async } } } + const invocationPathMismatch = checkRoutingMismatch(explicitTargetCats, validExplicitTargets, contentTargets); + if (invocationPathMismatch.held) { + return { + ...invocationPathMismatch.response, + ...(clientMessageId ? { clientMessageId } : {}), + }; + } const mergedTargets = new Set([...contentTargets, ...validExplicitTargets]); // F177-H: Cross-post participant awareness — warn when target cats are not @@ -3189,15 +3161,10 @@ export const callbacksRoutes: FastifyPluginAsync = async } const suppressTerminalRouting = coordinationResult.suppressRouting; if (suppressTerminalRouting && mergedTargets.size > 0) { - routing_warnings.push({ - kind: 'suppressed_by_terminal_ack', - droppedMentions: [...mergedTargets], - }); + throw new Error('terminal ACK projection cannot suppress explicit routing targets'); } - const hasDedupBlockingRoutingWarnings = routing_warnings.some( - (warning) => warning.kind !== 'suppressed_by_terminal_ack', - ); - const mentions: CatId[] = suppressTerminalRouting ? [] : [...mergedTargets]; + const hasDedupBlockingRoutingWarnings = routing_warnings.length > 0; + const mentions: CatId[] = [...mergedTargets]; if ( localReviewVerdict && (mentions.length !== 1 || mentions[0] === senderCatId || hasDedupBlockingRoutingWarnings) @@ -3301,7 +3268,6 @@ export const callbacksRoutes: FastifyPluginAsync = async // store with deliveryStatus:'queued' so ContextAssembler excludes this message // from other invocations' context until QueueProcessor.executeEntry marks it delivered. const hasA2AMentions = !!(mentions.length > 0 && router && invocationRecordStore && effectiveThreadId); - const willEnqueueToQueue = !!(hasA2AMentions && opts.invocationQueue); // #573: persisted record's extra.stream.invocationId aligned to effectiveInvId // (parent/outer) so F5/hydration broadcasts match what live broadcasts use. // Merge with any existing extra (cross-post / explicit targets) without losing it. @@ -3361,7 +3327,7 @@ export const callbacksRoutes: FastifyPluginAsync = async } const duplicateMsg = durableLocalReviewDuplicate ?? - (!localReviewVerdict && !hasDedupBlockingRoutingWarnings && !interruptedActionCarrierRecoveryKey + (!localReviewVerdict && !hasDedupBlockingRoutingWarnings ? await findRecentExactCallbackDuplicate(messageStore, { threadId: effectiveThreadId, userId: actor.userId, @@ -3381,9 +3347,9 @@ export const callbacksRoutes: FastifyPluginAsync = async const newlyClaimedActionLease = Boolean(actionFence && actionAdmissionOutcome !== 'replayed'); let recoveredDuplicateCarrier = false; if (!newlyClaimedActionLease) { - recoveredDuplicateCarrier = await recoverQueuedDuplicateCallbackMessage({ + recoveredDuplicateCarrier = await ensureDuplicateCallbackWake({ duplicateMsg, - willEnqueueToQueue, + canEnqueueA2A: !!(hasA2AMentions && opts.invocationQueue), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), threadId: effectiveThreadId, userId: actor.userId, @@ -3392,15 +3358,12 @@ export const callbacksRoutes: FastifyPluginAsync = async enqueueA2A: () => enqueueA2ATargets( { - router: router!, - invocationRecordStore: invocationRecordStore!, socketManager, messageStore, ...(invocationTracker ? { invocationTracker } : {}), ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.ballCustody ? { ballCustody: opts.ballCustody } : {}), ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, @@ -3417,45 +3380,7 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(actionFence ? { actionSuccessorFence: actionFence } : {}), }, ), - markDelivered: (deliveredAt) => messageStore.markDelivered?.(duplicateMsg.id, deliveredAt), - zeroEnqueuedWarnMessage: '[callbacks/post-message] queued duplicate had no A2A entry — broadcasting anyway', - enqueueFailureMessage: '[callbacks/post-message] queued duplicate recovery failed — broadcasting anyway', - ...(localReviewVerdict ? { preserveQueuedOnEnqueueFailure: true } : {}), - broadcastNow: async () => { - const replyPreview = validatedReplyTo - ? await hydrateReplyPreview(messageStore, validatedReplyTo) - : undefined; - socketManager.broadcastAgentMessage( - { - type: 'text', - catId: actor.catId, - content: duplicateMsg.content, - origin: 'callback', - messageId: duplicateMsg.id, - ...stampVisibleTurn(effectiveInvId, invocationId), - extra: { - ...standaloneExplicitPostExtra, - ...(isCrossThread - ? { - crossPost: { - sourceThreadId: actor.threadId, - sourceInvocationId: effectiveInvId, - }, - } - : {}), - ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}), - ...(!suppressTerminalRouting && validExplicitTargets.length - ? { targetCats: validExplicitTargets } - : {}), - }, - ...(duplicateMsg.mentionsUser ? { mentionsUser: true } : {}), - ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), - ...(replyPreview ? { replyPreview } : {}), - timestamp: Date.now(), - }, - effectiveThreadId, - ); - }, + enqueueFailureMessage: '[callbacks/post-message] duplicate wake admission failed', }); } if (actionFence && actionCarrierDisposition === 'return') { @@ -3477,7 +3402,8 @@ export const callbacksRoutes: FastifyPluginAsync = async if ( localReviewVerdict && duplicateMsg.deliveryStatus === 'queued' && - willEnqueueToQueue && + hasA2AMentions && + !!opts.invocationQueue && !recoveredDuplicateCarrier ) { reply.status(503); @@ -3497,72 +3423,145 @@ export const callbacksRoutes: FastifyPluginAsync = async }; }; if (duplicateMsg) return recoverPersistedCallbackMessage(duplicateMsg); - // Race-safe backstop: the exact-duplicate scan above is check-then-act, so an atomic content - // claim makes the at-most-once decision (root cause of the byte-identical duplicate bug). - const contentDuplicate = - interruptedActionCarrierRecoveryKey || localReviewVerdict - ? null - : await claimCallbackContentOrDuplicate(messageStore, { - threadId: effectiveThreadId, - userId: actor.userId, - catId: actor.catId, - content: persistedContent, - ...(richBlocks.length > 0 ? { richBlocks } : {}), - mentions, - ...(mentionsUser ? { mentionsUser } : {}), - ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), - isExplicitPost: isStandaloneExplicitPost, - ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}), - ...(coordinationDedupKey ? { coordinationDedupKey } : {}), - ...(clientMessageId ? { clientMessageId } : {}), - now, - hasRoutingWarnings: hasDedupBlockingRoutingWarnings, - }); - if (contentDuplicate) { - if (actionFence && actionAdmissionOutcome !== 'replayed' && actionCarrierDisposition !== 'return') { - await opts.actionSuccessorAdmissionService?.markUnavailable({ + let a2aAdmissionPlan: A2AFanoutAdmissionPlan | undefined; + let a2aRoutingPreflightDecision: RoutingPreflightDecisionV1 | undefined; + const a2aAdmissionOptions = hasA2AMentions + ? { + targetCats: mentions, + content: storedContent, + userId: actor.userId, + ownerAuthProvenance: record.ownerAuthProvenance, + threadId: effectiveThreadId, + createdAt: now, + callerCatId: senderCatId, + ...(record.parentInvocationId ? { parentInvocationId: record.parentInvocationId } : {}), + ...(isCrossThread ? { isCrossThread: true } : {}), + ...(actionFence ? { actionSuccessorFence: actionFence } : {}), + } + : undefined; + if (a2aAdmissionOptions) { + if (!opts.invocationQueue || !queueProcessor?.requestDrain) { + await reconcileActionSuccessorEnqueue({ + service: opts.actionSuccessorAdmissionService, fence: actionFence, - holderCatIds: actionHolderCatIds, - evidenceRef: `callback:${invocationId}:${clientMessageId}:content_duplicate`, + disposition: actionCarrierDisposition, + unavailableCatIds: actionHolderCatIds, now: Date.now(), }); + reply.status(503); + return { + kind: 'a2a_admission_unavailable', + message: 'Recipient wake admission is unavailable; no message was published.', + }; } - return contentDuplicate; + const routingPreflight = await preflightA2ATargets( + opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}, + { targetCats: mentions, content: storedContent, userId: actor.userId }, + ); + a2aRoutingPreflightDecision = routingPreflight.decision; + a2aAdmissionPlan = planA2AFanoutAdmission( + { invocationQueue: opts.invocationQueue }, + { + ...a2aAdmissionOptions, + targetCats: routingPreflight.acceptedTargetCats, + requestedTargetCats: routingPreflight.requestedTargetCats, + }, + ); } - const appendInput: Parameters[0] = { - userId: actor.userId, - catId: actor.catId, - content: persistedContent, - mentions, - ...(mentionsUser ? { mentionsUser } : {}), + // Race-safe backstop: the exact-duplicate scan above is check-then-act, so an atomic content + // claim makes the at-most-once decision (root cause of the byte-identical duplicate bug). + const contentDuplicate = localReviewVerdict + ? null + : await claimCallbackContentOrDuplicate(messageStore, { + threadId: effectiveThreadId, + userId: actor.userId, + catId: actor.catId, + content: persistedContent, + ...(richBlocks.length > 0 ? { richBlocks } : {}), + mentions, + ...(mentionsUser ? { mentionsUser } : {}), + ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), + isExplicitPost: isStandaloneExplicitPost, + ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}), + ...(coordinationDedupKey ? { coordinationDedupKey } : {}), + ...(clientMessageId ? { clientMessageId } : {}), + now, + hasRoutingWarnings: hasDedupBlockingRoutingWarnings, + }); + if (contentDuplicate) { + if (actionFence && actionAdmissionOutcome !== 'replayed' && actionCarrierDisposition !== 'return') { + await opts.actionSuccessorAdmissionService?.markUnavailable({ + fence: actionFence, + holderCatIds: actionHolderCatIds, + evidenceRef: `callback:${invocationId}:${clientMessageId}:content_duplicate`, + now: Date.now(), + }); + } + return contentDuplicate; + } + const appendInput: AppendMessageInput = { + from: { kind: 'agent', catId: actor.catId }, + userId: actor.userId, + content: persistedContent, + mentions, + ...(mentionsUser ? { mentionsUser } : {}), origin: 'callback', timestamp: now, threadId: effectiveThreadId, extra: persistedExtra, ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), - ...(willEnqueueToQueue ? { deliveryStatus: 'queued' as const } : {}), - ...(interruptedActionCarrierRecoveryKey || localReviewFactMessageIdempotencyKey - ? { idempotencyKey: interruptedActionCarrierRecoveryKey ?? localReviewFactMessageIdempotencyKey } - : {}), + ...(localReviewFactMessageIdempotencyKey ? { idempotencyKey: localReviewFactMessageIdempotencyKey } : {}), }; - let storedMsg: Awaited>; - if (interruptedActionCarrierRecoveryKey || localReviewFactMessageIdempotencyKey) { - const appendResult = await messageStore.appendIdempotent(appendInput); - storedMsg = appendResult.message; - if (appendResult.idempotent) { - if (localReviewVerdict && !localReviewDuplicateMatches(storedMsg)) { - reply.status(409); - return { - kind: 'review_fact_idempotency_conflict', - message: 'This clientMessageId already names a different durable local review fact.', - messageId: storedMsg.id, - clientMessageId, - }; - } - return recoverPersistedCallbackMessage(storedMsg); + let atomicAdmission: Awaited>; + let persistedReplay = false; + try { + if (a2aAdmissionPlan) { + atomicAdmission = await appendA2ASourceWithLedgerAdmission( + { messageStore, invocationQueue: opts.invocationQueue }, + appendInput, + { + plan: a2aAdmissionPlan, + ownerAuthProvenance: record.ownerAuthProvenance, + parentInvocationId: record.parentInvocationId, + callerTraceContext: record.traceContext, + ...(actionFence ? { actionSuccessorFence: actionFence } : {}), + }, + ); + } else if (appendInput.idempotencyKey) { + const result = await messageStore.appendIdempotent(appendInput); + atomicAdmission = { message: result.message }; + persistedReplay = result.idempotent; + } else { + atomicAdmission = { message: await messageStore.append(appendInput) }; } - } else { - storedMsg = await messageStore.append(appendInput); + } catch (error) { + if (localReviewVerdict && a2aAdmissionPlan) { + app.log.warn( + { err: error, threadId: effectiveThreadId, clientMessageId }, + '[invocation-callback] atomic local-review admission unavailable', + ); + reply.status(503); + return { + kind: 'review_delivery_pending', + message: + 'The review fact was not published; retry the same clientMessageId for atomic author wake admission.', + ...(clientMessageId ? { clientMessageId } : {}), + }; + } + throw error; + } + const storedMsg = atomicAdmission.message; + if (persistedReplay || atomicAdmission.preAdmittedReplayed) { + if (localReviewVerdict && !localReviewDuplicateMatches(storedMsg)) { + reply.status(409); + return { + kind: 'review_fact_idempotency_conflict', + message: 'This clientMessageId already names a different durable local review fact.', + messageId: storedMsg.id, + clientMessageId, + }; + } + return recoverPersistedCallbackMessage(storedMsg); } if (coordinationResult.coordination?.phase === 'active') { coordinationActiveDispatchCount.add(1); @@ -3578,28 +3577,24 @@ export const callbacksRoutes: FastifyPluginAsync = async // F27: Enqueue @mentioned cats into parent worklist (unified A2A path) const deliveryDecision = await MessageDeliveryService.resolveCallbackDeliveryDecision({ canEnqueueA2A: hasA2AMentions, - willEnqueueToQueue, messageId: storedMsg.id, threadId: effectiveThreadId, log: app.log, enqueueA2A: () => enqueueA2ATargets( { - router: router!, - invocationRecordStore: invocationRecordStore!, socketManager, messageStore, ...(invocationTracker ? { invocationTracker } : {}), ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.ballCustody ? { ballCustody: opts.ballCustody } : {}), ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, { targetCats: mentions, - content: interruptedActionCarrierRecoveryKey ? storedMsg.content : storedContent, + content: storedContent, userId: actor.userId, threadId: effectiveThreadId, triggerMessage: storedMsg, @@ -3608,26 +3603,19 @@ export const callbacksRoutes: FastifyPluginAsync = async ownerAuthProvenance: record.ownerAuthProvenance, callerTraceContext: record.traceContext, ...(actionFence ? { actionSuccessorFence: actionFence } : {}), + ...(a2aAdmissionPlan ? { preplannedAdmission: a2aAdmissionPlan } : {}), + ...(a2aRoutingPreflightDecision ? { routingPreflightDecision: a2aRoutingPreflightDecision } : {}), + ...(atomicAdmission.preAdmittedEntries + ? { + preAdmittedEntries: atomicAdmission.preAdmittedEntries, + preAdmittedReplayed: atomicAdmission.preAdmittedReplayed, + } + : {}), }, ), - markDelivered: (deliveredAt) => messageStore.markDelivered?.(storedMsg.id, deliveredAt), - zeroEnqueuedWarnMessage: - '[callbacks/post-message] routing preflight or queue guards left no A2A target — broadcasting receipt carrier', - enqueueFailureMessage: '[invocation-callback] enqueueA2ATargets failed — falling back to broadcast', - ...(localReviewVerdict || interruptedActionCarrierRecoveryKey ? { preserveQueuedOnEnqueueFailure: true } : {}), + enqueueFailureMessage: '[invocation-callback] wake admission failed', }); - if (interruptedActionCarrierRecoveryKey && deliveryDecision.enqueueFailed) { - reply.status(503); - return { - kind: 'action_carrier_recovery_pending', - message: - 'The replacement carrier has durable Queue admission, but delivery is not committed. Runtime startup reconciliation is required to restore Queue delivery; retrying this clientMessageId only confirms the admission.', - messageId: storedMsg.id, - ...(clientMessageId ? { clientMessageId } : {}), - }; - } - if (localReviewVerdict && deliveryDecision.enqueueFailed) { reply.status(503); return { @@ -3648,59 +3636,55 @@ export const callbacksRoutes: FastifyPluginAsync = async now: Date.now(), }); - // #607: Only broadcast when message is not queued — queued messages are - // broadcast later via messages_delivered when QueueProcessor delivers them. - if (deliveryDecision.shouldBroadcastNow) { + socketManager.broadcastAgentMessage( + { + type: 'text', + catId: actor.catId, + content: persistedContent, + origin: 'callback', + messageId: storedMsg.id, + // F194 Phase Z9 (砚砚 R1 P1-2): unified visible turn stamp via helper. + ...stampVisibleTurn(effectiveInvId, invocationId), + // F52+F098-C1: Include crossPost + targetCats in real-time broadcast. + // #1332: replace_final omits the standalone marker so live UI replaces + // the provider stream bubble and suppresses later stream chunks. + extra: { + ...standaloneExplicitPostExtra, + ...(isCrossThread + ? { + crossPost: { + sourceThreadId: actor.threadId, + sourceInvocationId: effectiveInvId, + }, + } + : {}), + ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}), + ...(!suppressTerminalRouting && validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + }, + ...(mentionsUser ? { mentionsUser } : {}), + ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), + ...(replyPreview ? { replyPreview } : {}), + timestamp: Date.now(), + }, + effectiveThreadId, + ); + + // #83: Broadcast each extracted rich block as SSE event for live rendering + // P2 cloud-review: include messageId for frontend correlation + // #454/573: include effectiveInvId (parent/outer) so frontend can exact-match + // callback to stream bubble. + // F194 Phase Z9 (砚砚 R1 P1-2): rich_block broadcast — unified stamp via helper. + for (const block of richBlocks) { socketManager.broadcastAgentMessage( { - type: 'text', + type: 'system_info' as const, catId: actor.catId, - content: persistedContent, - origin: 'callback', - messageId: storedMsg.id, - // F194 Phase Z9 (砚砚 R1 P1-2): unified visible turn stamp via helper. + content: JSON.stringify({ type: 'rich_block', block, messageId: storedMsg.id }), ...stampVisibleTurn(effectiveInvId, invocationId), - // F52+F098-C1: Include crossPost + targetCats in real-time broadcast. - // #1332: replace_final omits the standalone marker so live UI replaces - // the provider stream bubble and suppresses later stream chunks. - extra: { - ...standaloneExplicitPostExtra, - ...(isCrossThread - ? { - crossPost: { - sourceThreadId: actor.threadId, - sourceInvocationId: effectiveInvId, - }, - } - : {}), - ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}), - ...(!suppressTerminalRouting && validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), - }, - ...(mentionsUser ? { mentionsUser } : {}), - ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), - ...(replyPreview ? { replyPreview } : {}), timestamp: Date.now(), }, effectiveThreadId, ); - - // #83: Broadcast each extracted rich block as SSE event for live rendering - // P2 cloud-review: include messageId for frontend correlation - // #454/573: include effectiveInvId (parent/outer) so frontend can exact-match - // callback to stream bubble. - // F194 Phase Z9 (砚砚 R1 P1-2): rich_block broadcast — unified stamp via helper. - for (const block of richBlocks) { - socketManager.broadcastAgentMessage( - { - type: 'system_info' as const, - catId: actor.catId, - content: JSON.stringify({ type: 'rich_block', block, messageId: storedMsg.id }), - ...stampVisibleTurn(effectiveInvId, invocationId), - timestamp: Date.now(), - }, - effectiveThreadId, - ); - } } if (opts.outboundHook) { @@ -3749,11 +3733,6 @@ export const callbacksRoutes: FastifyPluginAsync = async deliveryDecision.enqueueAttempted, ); - // F254: seenCursor is NOT pushed on send. Sending ≠ reading — advancing the - // cursor here would hide messages that arrived between the freshness check and - // the actual send (TOCTOU race, gpt52 P1-3). Self-message exclusion in - // FreshnessGateService handles the "don't hold on own messages" case. - return { status: suppressTerminalRouting ? 'terminal_ack_recorded' : 'ok', threadId: effectiveThreadId, @@ -3763,16 +3742,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(clientMessageId ? { clientMessageId } : {}), ...(routing_warnings.length > 0 ? { routing_warnings } : {}), ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}), - ...(deliveryDecision.routingPreflight ? { routing_preflight: deliveryDecision.routingPreflight } : {}), - ...(isCrossThread && crossThreadFreshnessPolicy.mode === 'bypass' - ? { - freshness: { - gate: 'bypassed', - reason: crossThreadFreshnessPolicy.reason, - senderLagPossible: true, - }, - } - : {}), ...(actionFence && actionAdmissionOutcome ? { actionLease: { @@ -3826,7 +3795,10 @@ export const callbacksRoutes: FastifyPluginAsync = async : await messageStore.getMentionsFor(record.catId, 20, record.userId, record.threadId, lastAckId); // F35: Filter out whispers not intended for this cat const mentionViewer = { type: 'cat' as const, catId }; - const mentions = rawMentions.filter((m) => canViewMessage(m, mentionViewer)); + const mentions = rawMentions.filter( + (message) => + canViewMessage(message, mentionViewer) && !isMentionOwnedByQueue(opts.invocationQueue, message, catId), + ); // #1200 P2-8 (Sol R2): pair-domain acked resolution via compareCursors. // Uses the same (seq, id) pair comparison as the Lua CAS and in-memory // cursor store, ensuring consistent ordering semantics across all @@ -4037,9 +4009,20 @@ export const callbacksRoutes: FastifyPluginAsync = async } const principalCatId = principal.kind === 'invocation' ? principal.catId : principal.catId; const principalUserId = principal.userId; + let executionSituation: Awaited> | undefined; + if (opts.threadExecutionSituationSource) { + try { + executionSituation = await opts.threadExecutionSituationSource.resolve(effectiveThreadId); + } catch { + executionSituation = { + kind: 'thread_execution_situation.v1', + complete: false, + activeRuns: [], + }; + } + } const exposureAwareThreadRead = { includeQueuedCatMessages: true, - includeExposedQueuedUserMessagesForCatId: principalCatId, } as const; // F148 Phase B (AC-B2): tokenize keyword for relevance scoring const keywordTerms = keyword ? tokenizeKeyword(keyword) : []; @@ -4100,9 +4083,10 @@ export const callbacksRoutes: FastifyPluginAsync = async // F148 Phase E (AC-E2): briefing messages are non-routing, never enter cat context if (item.origin === 'briefing') return false; if (filterCatId) { + const from = messageFrom(item); if (filterCatId === 'user') { - if (item.catId !== null) return false; - } else if (item.catId !== filterCatId) { + if (from.kind !== 'user') return false; + } else if (from.kind !== 'agent' || from.catId !== filterCatId) { return false; } } @@ -4112,7 +4096,7 @@ export const callbacksRoutes: FastifyPluginAsync = async item: Awaited>[number], ): boolean => { if (item.deletedAt) return false; - if (!isDurablyReadableByCat(item, principalCatId)) return false; + if (!isTimelinePublished(item)) return false; if (item.userId !== principalUserId && !isSystemUserMessage(item)) return false; if (!canViewMessage(item, viewer)) return false; return matchesExtraFilters(item); @@ -4123,14 +4107,17 @@ export const callbacksRoutes: FastifyPluginAsync = async return getKeywordScore(item) > 0; }; - // Must mirror the freshness gate: unread-delta selection and seen-cursor - // advancement are two views of the same visibility contract. + // Unread-delta selection and seen-cursor advancement are two views of the + // same visibility contract. This cursor remains read evidence; it is no + // longer consulted by post_message to block an outbound side effect. const isFreshnessRelevant = (item: Awaited>[number]): boolean => { if (item.deletedAt) return false; if (!isDelivered(item)) return false; - if (item.userId === 'system') return false; + if (messageFrom(item).kind === 'system') return false; if (item.origin === 'briefing') return false; - if (!item.extra?.crossPost && item.catId !== null && item.catId === principalCatId) return false; + const itemFrom = messageFrom(item); + const authorCatId = itemFrom.kind === 'agent' ? itemFrom.catId : null; + if (!item.extra?.crossPost && authorCatId !== null && authorCatId === principalCatId) return false; if (needsPlayFilter && !canViewMessage(item, viewer)) return false; return true; }; @@ -4362,25 +4349,45 @@ export const callbacksRoutes: FastifyPluginAsync = async // Sparse reads (keyword/window/cat filter) may skip messages, so they must not ack queued bodies either. // Queued-body ledger checks run after envelope selection below; no omitted body may be confirmed as read. const publishedMessageIds = new Set(filtered.map((message) => message.id)); + const queuedSourceMessages = new Map( + ( + await Promise.all( + queuedFullEntries.map(async (entry) => + entry.messageId ? await messageStore.getById(entry.messageId) : null, + ), + ) + ) + .filter((message): message is StoredMessage => message !== null) + .map((message) => [message.id, message]), + ); const queuedFullMessages = queuedFullEntries .filter( (entry) => - ![entry.messageId, ...(entry.mergedMessageIds ?? [])].some( + ![entry.messageId].some( (candidateMessageId) => candidateMessageId && publishedMessageIds.has(candidateMessageId), ), ) .map((entry) => { const id = entry.messageId ?? `queued:${entry.entryId}`; - const speaker = - entry.source === 'user' - ? getSenderName(null) - : entry.callerCatId - ? getSenderName(entry.callerCatId) - : entry.source; + const sourceMessage = entry.messageId ? queuedSourceMessages.get(entry.messageId) : undefined; + const speaker = (() => { + switch (entry.from.kind) { + case 'user': + return getSenderName(null); + case 'agent': + return getSenderName(entry.from.catId); + case 'external': + return entry.from.sender?.name ?? entry.from.sender?.id ?? entry.from.connectorId; + case 'plugin': + return entry.from.instanceId; + case 'system': + return entry.from.service; + } + })(); return { id, threadId: effectiveThreadId, - timestamp: Date.now(), + timestamp: sourceMessage?.timestamp ?? Date.now(), speaker, content: entry.content, contentLength: entry.content.length, @@ -4409,13 +4416,22 @@ export const callbacksRoutes: FastifyPluginAsync = async } => entry.fact !== null, ) : null; + const publishedQueueEntries: Map = opts.invocationQueue + ? await opts.invocationQueue.getDurableEntriesForMessages( + effectiveThreadId, + filtered.map((message) => message.id), + ) + : new Map(); const publishedCandidates: ThreadContextEnvelopeCandidate>[] = filtered.map((item) => { const imagePaths = extractImagePaths(item.contentBlocks, uploadDir); const imageUrls = extractImageUrls(item.contentBlocks); - const queuedProjection = - item.deliveryStatus === 'queued' && item.queueCustody - ? { deliveryStatus: 'queued' as const, queueEntryId: item.queueCustody.entryId } - : {}; + const queueEntry = publishedQueueEntries + .get(item.id) + ?.find( + (entry) => + entry.status !== 'terminal' && (entry.targets.length === 0 || entry.targets.includes(principalCatId)), + ); + const queuedProjection = queueEntry ? { deliveryStatus: 'queued' as const, queueEntryId: queueEntry.id } : {}; const anchored = anchorThreadMessage(item, { effectiveThreadId, speaker: getSenderName(item.catId), @@ -4513,11 +4529,22 @@ export const callbacksRoutes: FastifyPluginAsync = async }; }, ); + const allCandidates = + queuedCandidates.length === 0 + ? publishedCandidates + : [...publishedCandidates, ...queuedCandidates].sort((left, right) => { + const leftTimestamp = Number(left.projection.timestamp); + const rightTimestamp = Number(right.projection.timestamp); + const timestampDelta = leftTimestamp - rightTimestamp; + return timestampDelta === 0 ? left.id.localeCompare(right.id) : timestampDelta; + }); let envelopePage: ReturnType>>; try { envelopePage = pageThreadContextEnvelope({ base: { threadId: effectiveThreadId, + contextScope: contextSelection.kind === 'unread' ? 'unread_delta' : 'recent_history', + ...(executionSituation ? { situation: executionSituation } : {}), ...(keywordScanTiming ? { scanCapped: keywordScanTiming.scanCapped } : {}), ...(workflowSop ? { workflowSop } : {}), }, @@ -4525,12 +4552,14 @@ export const callbacksRoutes: FastifyPluginAsync = async ? { boundedBase: { threadId: effectiveThreadId, + contextScope: contextSelection.kind === 'unread' ? 'unread_delta' : 'recent_history', + ...(executionSituation ? { situation: executionSituation } : {}), ...(keywordScanTiming ? { scanCapped: keywordScanTiming.scanCapped } : {}), workflowSop: boundedWorkflowSop, }, } : {}), - allCandidates: [...publishedCandidates, ...queuedCandidates], + allCandidates, cursor: decodedCursor, scopeHash: cursorScopeHash, selection: contextSelection, @@ -4540,7 +4569,7 @@ export const callbacksRoutes: FastifyPluginAsync = async reply.status(400); return { error: error.message, code: 'INVALID_THREAD_CONTEXT_CURSOR' }; } - const payload = envelopePage.payload; + let payload = envelopePage.payload; const returnedPublishedIds = new Set( envelopePage.candidates.filter((candidate) => candidate.source === 'published').map((candidate) => candidate.id), ); @@ -4549,8 +4578,8 @@ export const callbacksRoutes: FastifyPluginAsync = async .filter((candidate) => candidate.source === 'published' && typeof candidate.projection.content === 'string') .map((candidate) => candidate.id), ); - const returnedFiltered = filtered.filter((message) => returnedPublishedIds.has(message.id)); - const fullyReturnedFiltered = filtered.filter((message) => fullPublishedIds.has(message.id)); + let returnedFiltered = filtered.filter((message) => returnedPublishedIds.has(message.id)); + let fullyReturnedFiltered = filtered.filter((message) => fullPublishedIds.has(message.id)); const returnedQueuedEntryIds = new Set( envelopePage.candidates .filter( @@ -4562,48 +4591,129 @@ export const callbacksRoutes: FastifyPluginAsync = async const fullyReturnedQueuedEntries = queuedFullEntries.filter( (entry) => returnedQueuedEntryIds.has(entry.entryId) || - [entry.messageId, ...(entry.mergedMessageIds ?? [])].some( - (messageId) => typeof messageId === 'string' && fullPublishedIds.has(messageId), - ), + [entry.messageId].some((messageId) => typeof messageId === 'string' && fullPublishedIds.has(messageId)), ); - if (fullyReturnedQueuedEntries.length > 0 && principal.kind === 'invocation' && opts.turnExecutionStore) { - let exposureExecution: TurnExecutionRecord | null; - try { - exposureExecution = await opts.turnExecutionStore.get(principal.invocationId); - } catch (err) { - app.log.error( - { err, invocationId: principal.invocationId, threadId: effectiveThreadId, catId: principalCatId }, - '[turn-execution] queued body exposure ledger read failed', + const adoptableQueuedEntries = fullyReturnedQueuedEntries.filter( + (entry): entry is typeof entry & { messageId: string } => + entry.readDisposition === 'adopt' && typeof entry.messageId === 'string', + ); + const seenOnlyQueuedEntries = fullyReturnedQueuedEntries.filter((entry) => entry.readDisposition === 'seen_only'); + const suppressedQueuedEntryIds = new Set(); + const suppressedQueuedMessageIds = new Set(); + const suppressQueuedBody = (entry: (typeof fullyReturnedQueuedEntries)[number]): void => { + suppressedQueuedEntryIds.add(entry.entryId); + if (typeof entry.messageId === 'string') suppressedQueuedMessageIds.add(entry.messageId); + }; + + if (fullyReturnedQueuedEntries.length > 0 && opts.invocationQueue && principal.kind === 'invocation') { + const queuedSeenInvocationId = principal.invocationId; + const seenAt = Date.now(); + const adoptExposedQueuedEntries = queueProcessor?.adoptExposedQueuedEntries?.bind(queueProcessor); + let adoptionAllowed = Boolean(adoptExposedQueuedEntries); + if (adoptableQueuedEntries.length > 0 && opts.turnExecutionStore) { + let exposureExecution: TurnExecutionRecord | null; + try { + exposureExecution = await opts.turnExecutionStore.get(principal.invocationId); + } catch (err) { + app.log.error( + { err, invocationId: principal.invocationId, threadId: effectiveThreadId, catId: principalCatId }, + '[turn-execution] queued body adoption ledger read failed', + ); + reply.status(503); + return { error: 'Turn execution ledger unavailable', code: 'TURN_EXECUTION_LEDGER_UNAVAILABLE' }; + } + adoptionAllowed = Boolean( + adoptionAllowed && + exposureExecution && + exposureExecution.status === 'running' && + exposureExecution.parentInvocationId === expectedParentInvocationId && + exposureExecution.threadId === effectiveThreadId && + exposureExecution.userId === principalUserId && + exposureExecution.catId === principalCatId, ); - reply.status(503); - return { error: 'Turn execution ledger unavailable', code: 'TURN_EXECUTION_LEDGER_UNAVAILABLE' }; + if (!adoptionAllowed) { + app.log.warn( + { + invocationId: principal.invocationId, + expectedParentInvocationId, + effectiveThreadId, + principalUserId, + principalCatId, + exposureExecution, + }, + '[turn-execution] queued body adoption no longer matches the active child; omitting queued bodies', + ); + } } - if (!exposureExecution) { - reply.status(409); - return { error: 'Turn execution not found', code: 'TURN_EXECUTION_NOT_FOUND' }; + + const adoptedMessageIds: string[] = []; + if (!adoptionAllowed || !adoptExposedQueuedEntries) { + for (const entry of adoptableQueuedEntries) suppressQueuedBody(entry); + } else { + for (const entry of adoptableQueuedEntries) { + const adoption = await adoptExposedQueuedEntries({ + threadId: effectiveThreadId, + userId: principalUserId, + catId: principalCatId, + invocationId: queuedSeenInvocationId, + entries: [{ entryId: entry.entryId, messageId: entry.messageId }], + seenAt, + }).catch((err) => { + app.log.error( + { err, invocationId: principal.invocationId, threadId: effectiveThreadId, catId: principalCatId }, + '[F254] queued body adoption persistence failed before full-body return', + ); + return { outcome: 'rejected', reason: 'persistence_unavailable' } as const; + }); + if (adoption.outcome === 'adopted') { + adoptedMessageIds.push(entry.messageId); + continue; + } + if (adoption.reason === 'persistence_unavailable') { + reply.status(503); + return { error: 'Queued body adoption unavailable', code: 'QUEUE_ADOPTION_UNAVAILABLE' }; + } + suppressQueuedBody(entry); + } } - if ( - exposureExecution.status !== 'running' || - exposureExecution.parentInvocationId !== expectedParentInvocationId || - exposureExecution.threadId !== effectiveThreadId || - exposureExecution.userId !== principalUserId || - exposureExecution.catId !== principalCatId - ) { - app.log.error( - { - invocationId: principal.invocationId, - expectedParentInvocationId, - effectiveThreadId, - principalUserId, - principalCatId, - exposureExecution, - }, - '[turn-execution] queued body exposure scope/status mismatch', - ); - reply.status(409); - return { error: 'Turn execution scope mismatch', code: 'TURN_EXECUTION_SCOPE_MISMATCH' }; + + const custodyMessageIds = [ + ...seenOnlyQueuedEntries.flatMap((entry) => (typeof entry.messageId === 'string' ? [entry.messageId] : [])), + ...adoptedMessageIds, + ]; + if (opts.redis && custodyMessageIds.length > 0) { + try { + await new FreshnessAttentionEventLog(opts.redis).markProviderNoticesSeen({ + ownerUserId: principalUserId, + invocationId: queuedSeenInvocationId, + catId: principalCatId as CatId, + exactMessageIds: custodyMessageIds, + evidenceKind: 'queue_exact_read', + }); + } catch (err) { + app.log.warn( + { err, invocationId: principal.invocationId, threadId: effectiveThreadId }, + '[F254-D2] provider notice seen projection failed for Queue exact read', + ); + } } } + + if (suppressedQueuedEntryIds.size > 0 || suppressedQueuedMessageIds.size > 0) { + payload = { + ...payload, + messages: payload.messages.filter((message) => { + const messageId = typeof message.id === 'string' ? message.id : undefined; + const queueEntryId = typeof message.queueEntryId === 'string' ? message.queueEntryId : undefined; + return !( + (messageId && suppressedQueuedMessageIds.has(messageId)) || + (queueEntryId && suppressedQueuedEntryIds.has(queueEntryId)) + ); + }), + }; + returnedFiltered = returnedFiltered.filter((message) => !suppressedQueuedMessageIds.has(message.id)); + fullyReturnedFiltered = fullyReturnedFiltered.filter((message) => !suppressedQueuedMessageIds.has(message.id)); + } // F236 AC-A1 (R1/砚砚 P1): emit returnedChars so the eval layer can compute payload shrink (省). const serializedPayload = JSON.stringify(payload); const threadContextReturnedChars = serializedPayload.length; @@ -4615,6 +4725,7 @@ export const callbacksRoutes: FastifyPluginAsync = async returnedBytes: threadContextReturnedBytes, count: payload.messages.length, hasMore: payload.hasMore, + contextScope: payload.contextScope, responseMode: responseMode ?? 'anchor', }, '[F236] anchor returned', @@ -4650,8 +4761,8 @@ export const callbacksRoutes: FastifyPluginAsync = async }); // #1200 Sol R3: visibility-domain seenCursor advancement. // - // F254 AC-A2: seenCursor must advance when cat reads via thread-context. - // Disabling it causes repeated freshness hold/reinvoke — it's a regression. + // seenCursor must advance when a cat reads via thread-context so later + // incremental reads start after the exact visibility-contiguous prefix. // // Safety: time-domain pages from getByThreadBefore are NOT visibility-contiguous. // Late-delivered Q may have older timestamp but higher visibilitySeq. With limit=N, @@ -4661,8 +4772,8 @@ export const callbacksRoutes: FastifyPluginAsync = async // flawed — invisible messages (deleted/briefing/whisper) create permanent gaps. // Fix: do a separate VISIBILITY-DOMAIN read via getByThreadAfter, then walk through // results checking whether each freshness-relevant message was in the cat's page. - // Skip freshness-irrelevant messages (deleted, briefing, invisible whispers) — - // these don't trigger the freshness gate, so advancing past them is safe. + // Skip unread-irrelevant messages (deleted, briefing, invisible whispers), + // so advancing past them cannot hide readable conversation content. // // #1200 Sol R2: seenCursor re-enabled with visibility-contiguous advance. // computeVisibilityContiguousAdvance walks through returned messages in @@ -4723,7 +4834,7 @@ export const callbacksRoutes: FastifyPluginAsync = async for (const msg of visWindow) { if (!isFreshnessRelevant(msg)) { - // Not counted by freshness gate → safe to advance past + // Not counted as unread conversation input → safe to advance past advanceTo = msg; continue; } @@ -4758,8 +4869,8 @@ export const callbacksRoutes: FastifyPluginAsync = async ); } } catch (err) { - // Fail-open: seenCursor advancement is best-effort. If it fails, - // the freshness gate may re-hold but no data is lost. + // Fail-open: seenCursor advancement is best-effort. If it fails, a later + // incremental read may repeat content, but no data is lost. app.log.warn( { err, catId: principalCatId, threadId: effectiveThreadId }, '[#1200] seenCursor visibility-domain advance failed, fail-open', @@ -4788,119 +4899,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ); } } - if (fullyReturnedQueuedEntries.length > 0 && opts.invocationQueue && principal.kind === 'invocation') { - const queuedSeenInvocationId = principal.invocationId; - const seenAt = Date.now(); - let receiptChanged = false; - for (const entry of fullyReturnedQueuedEntries) { - const before = opts.invocationQueue.getEntrySnapshot(effectiveThreadId, principalUserId, entry.entryId); - const newlySeen = opts.invocationQueue.markQueuedSeen( - effectiveThreadId, - principalUserId, - entry.entryId, - principalCatId, - queuedSeenInvocationId, - seenAt, - ); - const persistedEntry = opts.invocationQueue.getEntrySnapshot(effectiveThreadId, principalUserId, entry.entryId); - const evidenceChanged = - before?.queuedSeenInvocationIdByCatId?.[principalCatId] !== - persistedEntry?.queuedSeenInvocationIdByCatId?.[principalCatId]; - const exposureChanged = - !(before?.queuedBodyExposures ?? []).some( - (exposure) => exposure.targetCatId === principalCatId && exposure.invocationId === queuedSeenInvocationId, - ) && - (persistedEntry?.queuedBodyExposures ?? []).some( - (exposure) => exposure.targetCatId === principalCatId && exposure.invocationId === queuedSeenInvocationId, - ); - let reminderSeen = false; - if (persistedEntry && opts.queueCustodyCoordinator) { - await opts.queueCustodyCoordinator.persistEntry(persistedEntry); - reminderSeen = await opts.queueCustodyCoordinator.markReminderSeen( - persistedEntry, - principalCatId, - queuedSeenInvocationId, - ); - } - receiptChanged = newlySeen || evidenceChanged || exposureChanged || reminderSeen || receiptChanged; - if (newlySeen) recordQueuedSeenTelemetry(); - } - if (receiptChanged) { - await emitQueueUpdated( - socketManager, - principalUserId, - effectiveThreadId, - opts.invocationQueue.list(effectiveThreadId, principalUserId), - messageStore, - 'queued_seen', - ); - } - if (opts.redis) { - const exactQueuedMessageIds = fullyReturnedQueuedEntries.flatMap((entry) => [ - ...(entry.messageId ? [entry.messageId] : []), - ...(entry.mergedMessageIds ?? []), - ]); - try { - await new FreshnessAttentionEventLog(opts.redis).markProviderNoticesSeen({ - ownerUserId: principalUserId, - invocationId: queuedSeenInvocationId, - catId: principalCatId as CatId, - exactMessageIds: exactQueuedMessageIds, - evidenceKind: 'queue_exact_read', - }); - } catch (err) { - app.log.warn( - { err, invocationId: principal.invocationId, threadId: effectiveThreadId }, - '[F254-D2] provider notice seen projection failed for Queue exact read', - ); - } - } - const exactQueuedMessageIds = fullyReturnedQueuedEntries.flatMap((entry) => [ - ...(entry.messageId ? [entry.messageId] : []), - ...(entry.mergedMessageIds ?? []), - ]); - if (queueProcessor?.resolvePromptMessageCustodyWakes) { - let adoptedWakes: readonly TurnCustodyWakeProvenance[]; - try { - adoptedWakes = await queueProcessor.resolvePromptMessageCustodyWakes({ - threadId: effectiveThreadId, - catId: principalCatId, - messageIds: exactQueuedMessageIds, - }); - } catch (err) { - app.log.error( - { err, invocationId: principal.invocationId, threadId: effectiveThreadId, catId: principalCatId }, - '[F167] queued custody obligation resolution failed before full-body return', - ); - reply.status(503); - return { error: 'Turn custody adoption unavailable', code: 'TURN_CUSTODY_ADOPTION_UNAVAILABLE' }; - } - if ( - adoptedWakes.length > 0 && - !(await turnCustodyAdoptionRegistry.adopt(principal.invocationId, adoptedWakes)) - ) { - app.log.error( - { invocationId: principal.invocationId, threadId: effectiveThreadId, catId: principalCatId }, - '[F167] active invocation has no turn custody adoption handler', - ); - reply.status(409); - return { error: 'Turn custody adoption unavailable', code: 'TURN_CUSTODY_ADOPTION_UNAVAILABLE' }; - } - if ( - adoptedWakes.length > 0 && - opts.holdBallDeps?.managedHoldDispositionService?.describe && - request.callbackAuth - ) { - // Visibility principals omit the primary trigger. Guidance must use - // the same authenticated source identity as completion. - const managedHoldDisposition = await opts.holdBallDeps.managedHoldDispositionService.describe( - request.callbackAuth, - ); - return { ...payload, managedHoldDisposition }; - } - } - } - return payload; }); @@ -4923,7 +4921,6 @@ export const callbacksRoutes: FastifyPluginAsync = async } const { messageId, contextCount } = parsed.data; - const isFullDrill = (parsed.data.mode ?? 'preview') === 'full'; const message = await messageStore.getById(messageId); if (!message || message.deletedAt) { reply.status(404); @@ -4932,14 +4929,14 @@ export const callbacksRoutes: FastifyPluginAsync = async // #699 P1 (gpt52 intake review): align get-message with isEligibleReplyParent — // system/briefing are internal, non-routable content, never retrievable here (no info leak). - if (isInternalNonQuotableParent(message)) { + const isReadableManagedHold = isAgentReadableManagedHoldMessage(message) && message.userId === principal.userId; + if (isInternalNonQuotableParent(message) && !isReadableManagedHold) { reply.status(404); return { error: 'Message not found' }; } - // Exact reads must preserve the owner-bound managed-hold boundary before the - // generic scheduler/system exemption below. Scheduler provenance is never authority. - if (!passesManagedHoldViewerBoundary(message, principal.userId)) { + // #699 P1-1: Enforce visibility — userId scope, publication status, whisper filtering + if (!isReadableManagedHold && !isTimelinePublished(message)) { reply.status(404); return { error: 'Message not found' }; } @@ -4954,219 +4951,25 @@ export const callbacksRoutes: FastifyPluginAsync = async needsPlayFilter = !!thread && (thread.thinkingMode ?? 'debug') === 'play'; } const viewer: Viewer = needsPlayFilter ? { type: 'cat', catId: principal.catId } : { type: 'user' }; - if (!canViewMessage(message, viewer)) { - reply.status(404); - return { error: 'Message not found' }; - } - - // F236 post-close regression: a full thread-context page may honestly anchor - // an oversized queued delivery body. The exact full drill is the first point - // where those bytes actually cross the callback boundary, so bind the current - // child exposure here — never when merely returning the anchor. - const expectedParentInvocationId = - principal.kind === 'invocation' ? (principal.parentInvocationId ?? principal.invocationId) : undefined; - const queuedDrillEntry = - isFullDrill && - principal.kind === 'invocation' && - message.threadId !== undefined && - message.threadId === principal.threadId && - opts.invocationQueue - ? opts.invocationQueue - .getQueuedBodyMessagesForCat( - message.threadId, - principal.userId, - principal.catId, - expectedParentInvocationId, - ) - .find((entry) => entry.messageId === message.id) - : undefined; - - if (!isDurablyReadableByCat(message, principal.catId) && !queuedDrillEntry) { + if (!isReadableManagedHold && !canViewMessage(message, viewer)) { reply.status(404); return { error: 'Message not found' }; } - - let managedHoldDisposition: unknown; - let queuedDrillContentBlocks: typeof message.contentBlocks; - if (queuedDrillEntry && principal.kind === 'invocation') { - if (!opts.turnExecutionStore || !queueProcessor?.markPromptMessagesSeen) { - reply.status(503); - return { error: 'Queued body drill unavailable', code: 'QUEUED_BODY_DRILL_UNAVAILABLE' }; - } - - let exposureExecution: TurnExecutionRecord | null; - try { - exposureExecution = await opts.turnExecutionStore.get(principal.invocationId); - } catch (err) { - app.log.error( - { err, invocationId: principal.invocationId, threadId: message.threadId, catId: principal.catId }, - '[F236] queued drill turn-execution read failed', - ); - reply.status(503); - return { error: 'Turn execution ledger unavailable', code: 'TURN_EXECUTION_LEDGER_UNAVAILABLE' }; - } - if (!exposureExecution) { - reply.status(409); - return { error: 'Turn execution not found', code: 'TURN_EXECUTION_NOT_FOUND' }; - } - if ( - exposureExecution.status !== 'running' || - exposureExecution.parentInvocationId !== expectedParentInvocationId || - exposureExecution.threadId !== message.threadId || - exposureExecution.userId !== principal.userId || - exposureExecution.catId !== principal.catId - ) { - app.log.error( - { - invocationId: principal.invocationId, - expectedParentInvocationId, - messageId: message.id, - exposureExecution, - }, - '[F236] queued drill turn-execution scope/status mismatch', - ); - reply.status(409); - return { error: 'Turn execution scope mismatch', code: 'TURN_EXECUTION_SCOPE_MISMATCH' }; - } - - const exactQueuedMessageIds = [ - ...(queuedDrillEntry.messageId ? [queuedDrillEntry.messageId] : []), - ...(queuedDrillEntry.mergedMessageIds ?? []), - ]; - try { - const sourceMessages = await readQueueCarrierMessages( - { - id: queuedDrillEntry.entryId, - threadId: message.threadId, - userId: principal.userId, - messageId: queuedDrillEntry.messageId ?? null, - mergedMessageIds: queuedDrillEntry.mergedMessageIds ?? [], - }, - messageStore, - ); - if ( - sourceMessages.some( - (sourceMessage) => - queueSourceTargetState(sourceMessage, queuedDrillEntry.entryId, principal.catId) !== 'pending', - ) - ) { - reply.status(409); - return { error: 'Queued body source changed', code: 'QUEUED_BODY_SOURCE_CHANGED' }; - } - const contentBlocks = sourceMessages.flatMap((sourceMessage) => sourceMessage.contentBlocks ?? []); - queuedDrillContentBlocks = contentBlocks.length > 0 ? contentBlocks : undefined; - } catch (err) { - app.log.error( - { err, invocationId: principal.invocationId, threadId: message.threadId, messageId: message.id }, - '[F236] queued drill source projection failed', - ); - reply.status(503); - return { error: 'Queued body source unavailable', code: 'QUEUED_BODY_SOURCE_UNAVAILABLE' }; - } - - let adoptedWakes: readonly TurnCustodyWakeProvenance[]; - let adoptionOwnerUnavailable = false; - let adoptionPreparationFailed = false; - try { - adoptedWakes = await queueProcessor.markPromptMessagesSeen( - { - threadId: message.threadId, - userId: principal.userId, - catId: principal.catId, - invocationId: principal.invocationId, - messageIds: exactQueuedMessageIds, - seenAt: Date.now(), - }, - { - prepareAdoption: async (wakes) => { - try { - const reservation = await turnCustodyAdoptionRegistry.prepare(principal.invocationId, wakes); - adoptionOwnerUnavailable = reservation === null; - return reservation; - } catch (error) { - adoptionPreparationFailed = true; - throw error; - } - }, - }, - ); - } catch (err) { - app.log.error( - { err, invocationId: principal.invocationId, threadId: message.threadId, messageId: message.id }, - adoptionOwnerUnavailable - ? '[F236] active queued drill has no turn custody adoption handler' - : '[F236] queued drill adoption preparation or exposure persistence failed', - ); - reply.status(adoptionOwnerUnavailable ? 409 : 503); - return adoptionOwnerUnavailable || adoptionPreparationFailed - ? { error: 'Turn custody adoption unavailable', code: 'TURN_CUSTODY_ADOPTION_UNAVAILABLE' } - : { error: 'Queued body exposure unavailable', code: 'QUEUED_BODY_EXPOSURE_UNAVAILABLE' }; - } - - if ( - adoptedWakes.length > 0 && - opts.holdBallDeps?.managedHoldDispositionService?.describe && - request.callbackAuth - ) { - try { - managedHoldDisposition = await opts.holdBallDeps.managedHoldDispositionService.describe(request.callbackAuth); - } catch (err) { - // The body and its adoption are already truthful. Guidance is optional - // projection data; withholding the body here would manufacture a false - // non-response witness in the append-only exposure ledger. - app.log.warn( - { err, invocationId: principal.invocationId, threadId: message.threadId, messageId: message.id }, - '[F236] queued drill disposition guidance unavailable after adoption', - ); - } - } - if (opts.redis) { - try { - await new FreshnessAttentionEventLog(opts.redis).markProviderNoticesSeen({ - ownerUserId: principal.userId, - invocationId: principal.invocationId, - catId: principal.catId as CatId, - exactMessageIds: exactQueuedMessageIds, - evidenceKind: 'queue_exact_read', - }); - } catch (err) { - app.log.warn( - { err, invocationId: principal.invocationId, threadId: message.threadId }, - '[F254-D2] provider notice seen projection failed for queued message drill', - ); - } - } - } - const uploadDir = getDefaultUploadDir(process.env.UPLOAD_DIR); // F236 AC-B1: bounded drill terminal. Default preview truncates content (keeps the `content` // field name for consumer continuity + adds contentLength/truncated); mode=full returns the // complete content + contentBlocks. Image hints stay in both modes. - const projectMsg = ( - m: typeof message, - queuedEntry?: typeof queuedDrillEntry, - queuedContentBlocks?: typeof message.contentBlocks, - ) => { - const projectedContent = queuedEntry?.content ?? m.content; - const projectedContentBlocks = queuedEntry ? queuedContentBlocks : m.contentBlocks; - const imagePaths = extractImagePaths(projectedContentBlocks, uploadDir); - const imageUrls = extractImageUrls(projectedContentBlocks); - const { preview, truncated } = isFullDrill - ? { preview: projectedContent, truncated: false } - : truncateHead(projectedContent); - const projectedSpeaker = queuedEntry - ? queuedEntry.source === 'user' - ? getSenderName(null) - : queuedEntry.callerCatId - ? getSenderName(queuedEntry.callerCatId) - : queuedEntry.source - : getSenderName(m.catId); + const isFullDrill = (parsed.data.mode ?? 'preview') === 'full'; + const projectMsg = (m: typeof message) => { + const imagePaths = extractImagePaths(m.contentBlocks, uploadDir); + const imageUrls = extractImageUrls(m.contentBlocks); + const { preview, truncated } = isFullDrill ? { preview: m.content, truncated: false } : truncateHead(m.content); return { id: m.id, userId: m.userId, catId: m.catId, content: preview, - contentLength: projectedContent.length, + contentLength: m.content.length, truncated, // F236 R1 / 云端 Codex P2: preview-mode truncation carries a one-hop drill pointer to the // full content (consistent with thread-context/pending anchors — caller never left guessing). @@ -5183,30 +4986,18 @@ export const callbacksRoutes: FastifyPluginAsync = async }, } : {}), - ...(isFullDrill && projectedContentBlocks ? { contentBlocks: projectedContentBlocks } : {}), + ...(isFullDrill && m.contentBlocks ? { contentBlocks: m.contentBlocks } : {}), ...(imagePaths.length > 0 ? { imagePaths } : {}), ...(imageUrls.length > 0 ? { imageUrls } : {}), ...(m.replyTo ? { replyTo: m.replyTo } : {}), - ...(queuedEntry - ? { - deliveryStatus: 'queued' as const, - queueEntryId: queuedEntry.entryId, - ...(queuedEntry.mergedMessageIds?.length ? { mergedMessageIds: [...queuedEntry.mergedMessageIds] } : {}), - } - : {}), - speaker: projectedSpeaker, + speaker: getSenderName(m.catId), timestamp: m.timestamp, threadId: m.threadId, }; }; - const result: { - message: ReturnType; - context?: ReturnType[]; - managedHoldDisposition?: unknown; - } = { - message: projectMsg(message, queuedDrillEntry, queuedDrillContentBlocks), - ...(managedHoldDisposition === undefined ? {} : { managedHoldDisposition }), + const result: { message: ReturnType; context?: ReturnType[] } = { + message: projectMsg(message), }; const effectiveContextCount = contextCount ?? 0; @@ -5214,7 +5005,6 @@ export const callbacksRoutes: FastifyPluginAsync = async const principalUserId = principal.userId; const exposureAwareThreadRead = { includeQueuedCatMessages: true, - includeExposedQueuedUserMessagesForCatId: principal.catId, } as const; const before = await messageStore.getByThreadBefore( message.threadId, @@ -5237,15 +5027,15 @@ export const callbacksRoutes: FastifyPluginAsync = async if (m.id === messageId) return false; if (m.deletedAt) return false; // #699 P1 (gpt52 intake review): exclude internal/non-routable (system/briefing) from context too - if (isInternalNonQuotableParent(m)) return false; - if (!passesManagedHoldViewerBoundary(m, principalUserId)) return false; - if (!isDurablyReadableByCat(m, principal.catId)) return false; + const isReadableManagedHoldNeighbor = isAgentReadableManagedHoldMessage(m) && m.userId === principalUserId; + if (isInternalNonQuotableParent(m) && !isReadableManagedHoldNeighbor) return false; + if (!isReadableManagedHoldNeighbor && !isTimelinePublished(m)) return false; if (m.userId !== principalUserId && !isSystemUserMessage(m)) return false; - if (!canViewMessage(m, viewer)) return false; + if (!isReadableManagedHoldNeighbor && !canViewMessage(m, viewer)) return false; return true; }) .sort((a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id)); - result.context = contextMsgs.map((contextMessage) => projectMsg(contextMessage)); + result.context = contextMsgs.map(projectMsg); } const contextMessages = Array.isArray(result.context) ? result.context : []; @@ -5269,9 +5059,9 @@ export const callbacksRoutes: FastifyPluginAsync = async recordAnchorDrillEvent({ tool: 'get-message', itemId: message.id, fullDrillChars }); recordAnchorPreviewEvent({ tool: 'get-message', - itemIds: [message.id, ...(queuedDrillEntry?.mergedMessageIds ?? []), ...contextMessages.map((m) => m.id)], + itemIds: [message.id, ...contextMessages.map((m) => m.id)], returnedChars: result.message.content.length + contextMessages.reduce((sum, m) => sum + m.content.length, 0), - originalChars: result.message.contentLength + contextMessages.reduce((sum, m) => sum + m.contentLength, 0), + originalChars: message.content.length + contextMessages.reduce((sum, m) => sum + m.contentLength, 0), modeResolved: 'full', modeSource: 'legacy_equivalent', catId: principal.catId, @@ -5281,7 +5071,7 @@ export const callbacksRoutes: FastifyPluginAsync = async tool: 'get-message', itemIds: [message.id, ...contextMessages.map((m) => m.id)], returnedChars: result.message.content.length + contextMessages.reduce((sum, m) => sum + m.content.length, 0), - originalChars: result.message.contentLength + contextMessages.reduce((sum, m) => sum + m.contentLength, 0), + originalChars: message.content.length + contextMessages.reduce((sum, m) => sum + m.contentLength, 0), modeResolved: 'anchor', modeSource: 'legacy_equivalent', catId: principal.catId, @@ -5705,9 +5495,7 @@ export const callbacksRoutes: FastifyPluginAsync = async if (!record) return; const waitSourcePromise = captureTypedWaitSource(record, { messageStore, - ...(opts.holdBallDeps?.managedHoldDispositionService - ? { managedHoldDispositionService: opts.holdBallDeps.managedHoldDispositionService } - : {}), + ...(invocationTracker ? { invocationTracker } : {}), }); const deletedThreadGuard = await getDeletedCallbackThreadGuard(threadStore, record.threadId); @@ -6014,9 +5802,7 @@ export const callbacksRoutes: FastifyPluginAsync = async if (!record) return; const waitSourcePromise = captureTypedWaitSource(record, { messageStore, - ...(opts.holdBallDeps?.managedHoldDispositionService - ? { managedHoldDispositionService: opts.holdBallDeps.managedHoldDispositionService } - : {}), + ...(invocationTracker ? { invocationTracker } : {}), }); const deletedThreadGuard = await getDeletedCallbackThreadGuard(threadStore, record.threadId); @@ -6568,24 +6354,66 @@ export const callbacksRoutes: FastifyPluginAsync = async // Send notification message to each voter (so they see the vote request in chat) const notificationContent = buildVoteNotification(question, options); const mentionCatIds = resolvedVoters; + const notificationTimestamp = Date.now(); + const canDispatchVote = Boolean( + router && invocationRecordStore && opts.invocationQueue && queueProcessor?.requestDrain, + ); + const voteRoutingPreflight = canDispatchVote + ? await preflightA2ATargets( + opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}, + { targetCats: mentionCatIds, content: notificationContent, userId: record.userId }, + ) + : undefined; + const voteAdmissionPlan = + canDispatchVote && voteRoutingPreflight + ? planA2AFanoutAdmission( + { invocationQueue: opts.invocationQueue }, + { + targetCats: voteRoutingPreflight.acceptedTargetCats, + requestedTargetCats: voteRoutingPreflight.requestedTargetCats, + content: notificationContent, + userId: record.userId, + ownerAuthProvenance: record.ownerAuthProvenance, + threadId: record.threadId, + createdAt: notificationTimestamp, + callerCatId: record.catId as CatId, + ...(record.parentInvocationId ? { parentInvocationId: record.parentInvocationId } : {}), + }, + ) + : undefined; let notificationMsg: Awaited> | undefined; + let preAdmittedVoteEntries: readonly QueueEntry[] | undefined; + let preAdmittedVoteReplayed = false; try { - notificationMsg = await messageStore.append({ + const notificationInput: AppendMessageInput = { + from: { kind: 'agent', catId: record.catId }, userId: record.userId, - catId: record.catId, content: notificationContent, mentions: mentionCatIds, origin: 'callback', - timestamp: Date.now(), + timestamp: notificationTimestamp, threadId: record.threadId, - }); + }; + const admission = voteAdmissionPlan + ? await appendA2ASourceWithLedgerAdmission( + { messageStore, invocationQueue: opts.invocationQueue }, + notificationInput, + { + plan: voteAdmissionPlan, + ownerAuthProvenance: record.ownerAuthProvenance, + ...(record.parentInvocationId ? { parentInvocationId: record.parentInvocationId } : {}), + callerTraceContext: record.traceContext, + }, + ) + : { message: await messageStore.append(notificationInput) }; + notificationMsg = admission.message; + preAdmittedVoteEntries = admission.preAdmittedEntries; + preAdmittedVoteReplayed = admission.preAdmittedReplayed ?? false; } catch (err) { log.warn({ err }, 'Failed to persist vote notification'); } - // Dispatch voter cats so they receive the notification and can vote. - // Uses enqueueA2ATargets (standard A2A dispatch, NOT multi_mention depth guard). - // If queue overflows (>MAX_QUEUE_DEPTH), falls back to direct dispatch for remaining voters. + // Dispatch voter cats through the canonical A2A Queue so they receive the notification and can vote. if (notificationMsg && router && invocationRecordStore) { const a2aDeps = { router, @@ -6596,7 +6424,6 @@ export const callbacksRoutes: FastifyPluginAsync = async deliveryCursorStore, queueProcessor, invocationQueue: opts.invocationQueue, - ...(opts.ballCustody ? { ballCustody: opts.ballCustody } : {}), ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }; @@ -6609,25 +6436,24 @@ export const callbacksRoutes: FastifyPluginAsync = async callerCatId: record.catId as CatId, ownerAuthProvenance: record.ownerAuthProvenance, callerTraceContext: record.traceContext, + ...(voteAdmissionPlan ? { preplannedAdmission: voteAdmissionPlan } : {}), + ...(voteRoutingPreflight?.decision ? { routingPreflightDecision: voteRoutingPreflight.decision } : {}), + ...(preAdmittedVoteEntries + ? { + preAdmittedEntries: preAdmittedVoteEntries, + preAdmittedReplayed: preAdmittedVoteReplayed, + } + : {}), }; try { - const { enqueued, coalesced, routingPreflight } = await enqueueA2ATargets(a2aDeps, a2aOpts); - // Fallback: voters that hit queue capacity limit → direct dispatch. - // F216 AC-D5: coalesced voters are already handled (content merged into existing entry), - // so they must NOT be counted as missed. Only truly unhandled cats get direct dispatch. + const { enqueued, coalesced } = await enqueueA2ATargets(a2aDeps, a2aOpts); const handled = new Set([...enqueued, ...(coalesced ?? [])]); - const rejected = new Set( - routingPreflight?.targets - .filter((target) => target.disposition === 'rejected') - .map((target) => target.targetCatId) ?? [], - ); - const missed = mentionCatIds.filter((c) => !handled.has(c) && !rejected.has(c)); + const missed = mentionCatIds.filter((c) => !handled.has(c)); if (missed.length > 0) { - app.log.info( + app.log.warn( { threadId: record.threadId, missed, enqueued }, - '[callbacks/start-vote] Queue overflow: falling back to direct dispatch for remaining voters', + '[callbacks/start-vote] canonical Queue did not admit every voter', ); - await triggerA2AInvocation(a2aDeps, { ...a2aOpts, targetCats: missed }); } } catch (err) { app.log.warn(`[callbacks/start-vote] Failed to dispatch voter invocations: ${String(err)}`); @@ -6820,16 +6646,9 @@ export const callbacksRoutes: FastifyPluginAsync = async invocationRecordStore, ...(invocationTracker ? { invocationTracker } : {}), ...(opts.turnExecutionStore ? { turnExecutionStore: opts.turnExecutionStore } : {}), - // F254 AC-A6: pass deliveryCursorStore for freshness gate on multi_mention - ...(deliveryCursorStore ? { deliveryCursorStore } : {}), - // F254 AC-A7: pass event log for recording held/forward decisions (P1 fix gpt52 R1) - ...(opts.redis ? { freshnessEventLog: new FreshnessAttentionEventLog(opts.redis) } : {}), - // F254 AC-C2: pass Redis for carrierTier lookup in descriptor derivation - ...(opts.redis ? { redis: opts.redis } : {}), - // F254 R2: pass threadStore for play-mode visibility filter in freshness gate - ...(threadStore ? { threadStore } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), ...(queueProcessor ? { queueProcessor } : {}), + ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), ...(opts.actionSuccessorAdmissionService ? { actionSuccessorAdmissionService: opts.actionSuccessorAdmissionService } : {}), @@ -6840,197 +6659,6 @@ export const callbacksRoutes: FastifyPluginAsync = async } } - // ── F254 Phase B1: Freshness notice check ───────────────────────── - // Called by MCP server after read-only tool calls (frequency-gated). - // Returns a content-free notice if the cat has unseen messages. - // Only works for invocation-kind principals (need threadId + invocationId). - app.post('/api/callbacks/freshness-notice-check', async (request, reply) => { - const principal = requireCallbackPrincipal(request, reply); - if (!principal) return; - - // B1 only works for invocation principals (have threadId + invocationId) - if (principal.kind !== 'invocation') { - return { notice: null }; - } - - const body = request.body as { toolName?: string; isReadOnly?: boolean } | undefined; - const toolName = body?.toolName ?? 'unknown'; - const isReadOnly = body?.isReadOnly ?? false; - - const { redis } = opts; - if (!redis || !deliveryCursorStore) { - return { notice: null }; - } - - try { - // Build messageFilter (must match Phase A — P0 constraint) - const needsPlayFilter = threadStore - ? await (async () => { - const thread = await threadStore.get(principal.threadId); - return !!thread && (thread.thinkingMode ?? 'debug') === 'play'; - })() - : false; - const freshnessViewer = needsPlayFilter - ? { type: 'cat' as const, catId: createCatId(principal.catId) } - : { type: 'user' as const }; - - const messageFilter = (msg: Record): boolean => { - if (msg.deletedAt) return false; - if (!isDelivered(msg as unknown as Parameters[0])) return false; - // #1200 codex R11 P1: system-generated messages (persisted error badges) - // are display-only — route-helpers.ts:744-745 excludes them from freshness. - if (msg.userId === 'system') return false; - if (msg.origin === 'briefing') return false; - if (needsPlayFilter) { - if (!canViewMessage(msg as unknown as Parameters[0], freshnessViewer)) return false; - } - return true; - }; - - const notice = await checkFreshnessForNotice({ - userId: principal.userId, - catId: principal.catId, - threadId: principal.threadId, - invocationId: principal.invocationId, - toolName, - isReadOnly, - cursorStore: deliveryCursorStore, - messageStore, - redis, - messageFilter, - // F254 queue-aware gate: detect queued messages hidden by isDelivered() - queueChecker: opts.invocationQueue - ? createQueueChecker(opts.invocationQueue, { - parentInvocationId: principal.parentInvocationId ?? principal.invocationId, - }) - : undefined, - // F254 AC-C2/C3: provider for descriptor derivation (reads carrierTier from state store) - provider: resolveFreshnessDescriptorProvider(catRegistry.tryGet(principal.catId)?.config), - }); - - if (notice && opts.invocationQueue) { - const queuedEntries = opts.invocationQueue.getQueuedFreshnessMessagesForCat( - principal.threadId, - principal.userId, - principal.catId, - { parentInvocationId: principal.parentInvocationId ?? principal.invocationId }, - ); - let changed = false; - const reminderInvocationId = principal.parentInvocationId ?? principal.invocationId; - for (const entry of queuedEntries) { - const entryChanged = opts.invocationQueue.markQueuedNotified( - principal.threadId, - principal.userId, - entry.entryId, - principal.catId, - ); - if (entryChanged && opts.queueCustodyCoordinator) { - const persistedEntry = opts.invocationQueue.getEntrySnapshot( - principal.threadId, - principal.userId, - entry.entryId, - ); - if (persistedEntry) await opts.queueCustodyCoordinator.persistEntry(persistedEntry); - } - const persistedEntry = opts.invocationQueue.getEntrySnapshot( - principal.threadId, - principal.userId, - entry.entryId, - ); - const reminderChanged = - persistedEntry && opts.queueCustodyCoordinator - ? await opts.queueCustodyCoordinator.markReminderDelivered( - persistedEntry, - principal.catId, - reminderInvocationId, - ) - : false; - changed = entryChanged || reminderChanged || changed; - } - if (changed) { - await emitQueueUpdated( - socketManager, - principal.userId, - principal.threadId, - opts.invocationQueue.list(principal.threadId, principal.userId), - messageStore, - 'queued_notified', - ); - } - } - - return { notice }; - } catch (err) { - // Fail-open: notice check errors should never block tool execution - app.log.warn({ err, catId: principal.catId, toolName }, '[F254-B1] freshness notice check error, fail-open'); - return { notice: null }; - } - }); - - // ── F254 Phase B2: Freshness hold_ball reminder ──────────────────── - // Called by MCP server after successful hold_ball. Checks for - // unresolved notices (delivered but not acked) and returns a reminder. - app.post('/api/callbacks/freshness-hold-ball-reminder', async (request, reply) => { - const principal = requireCallbackPrincipal(request, reply); - if (!principal) return; - - if (principal.kind !== 'invocation') { - return { reminder: null }; - } - - const { redis } = opts; - if (!redis) { - return { reminder: null }; - } - - try { - const { FreshnessAttentionEventLog } = await import( - '../domains/cats/services/freshness/FreshnessAttentionEventLog.js' - ); - const { FreshnessNoticeService } = await import('../domains/cats/services/freshness/FreshnessNoticeService.js'); - - const eventLog = new FreshnessAttentionEventLog(redis); - // B2 only needs eventLog (for unresolved query + deferred recording). - // stateStore and unseenChecker are unused by checkHoldBallReminder, - // but the constructor requires them — provide no-op stubs. - const noopStateStore = { - get: async () => null, - incrementToolCallCount: async () => 0, - recordNoticeDelivered: async () => {}, - getUnresolvedNotices: async () => [], - }; - const noopUnseenChecker = { checkUnseen: async () => null }; - const service = new FreshnessNoticeService(noopStateStore, eventLog, noopUnseenChecker); - - // P1-2 fix: fetch current seenCursor so notices where - // maxMessageId <= cursor are treated as implicitly resolved - const currentSeenCursor = deliveryCursorStore - ? await deliveryCursorStore.getSeenCursor(principal.userId, principal.catId, principal.threadId) - : undefined; - - // #1200 Sol R7: pass canonicalizeCursor so legacy events (v1 maxMessageId, - // no maxCursor) get resolved via messageStore lookup — same resolver as - // createFreshnessReinvokeCheck. Both consumers now share the same path. - const canonicalize = messageStore.canonicalizeCursor - ? (msgId: string, tid: string) => Promise.resolve(messageStore.canonicalizeCursor!(msgId, tid)) - : undefined; - - const reminder = await service.checkHoldBallReminder({ - ownerUserId: principal.userId, - invocationId: principal.invocationId, - threadId: principal.threadId, - catId: principal.catId, - currentSeenCursor: currentSeenCursor ?? null, - canonicalizeCursor: canonicalize, - }); - - return { reminder }; - } catch (err) { - app.log.warn({ err, catId: principal.catId }, '[F254-B2] hold_ball reminder check error, fail-open'); - return { reminder: null }; - } - }); - // F088 Phase J2: Document generation callback routes registerCallbackDocumentRoutes(app, { registry, socketManager, threadStore }); diff --git a/packages/api/src/routes/cats.ts b/packages/api/src/routes/cats.ts index 66a328bfef..35fe06881c 100644 --- a/packages/api/src/routes/cats.ts +++ b/packages/api/src/routes/cats.ts @@ -6,16 +6,21 @@ import { resolve } from 'node:path'; import { + CAT_CARRIERS, + type CatCarrier, type CatConfig, type CatId, type CliConfig, type ClientId, + catClientSupportsCarrier, catRegistry, + type FreshnessCarrierCapability, getCliEffortOptionsForProvider, getDefaultCliEffortForProvider, normalizeCliEffortForProvider, type RosterEntry, resolveCodexSpeed, + supportsActiveInvocationGuidance, } from '@cat-cafe/shared'; import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; @@ -34,13 +39,13 @@ import { resolveBoundAccountRefForCat } from '../config/cat-account-binding.js'; import { bootstrapCatCatalog } from '../config/cat-catalog-store.js'; import { getAcpConfig, + getDefaultCatId, getRoster, loadCatConfig, loadResolvedCatConfig, toAllCatConfigs, } from '../config/cat-config-loader.js'; import { getCatModel } from '../config/cat-models.js'; -import { resolveCodexCarrierTruth } from '../config/codex-cli.js'; import { configEventBus, createChangeSetId } from '../config/config-event-bus.js'; import { getConfiguredMemberWindowSetting, resolveContextCapacity } from '../config/context-capacity.js'; import { inferOpenCodeProviderFromModelName } from '../config/opencode-model.js'; @@ -65,9 +70,8 @@ const cliSchema = z.object({ effort: cliEffortSchema.nullable().optional(), /** F291: null clears the member default and restores Codex user-config inheritance. */ serviceTier: z.enum(['standard', 'fast']).nullable().optional(), - /** F254 D2: Codex carrier override (openai only). null clears the per-cat override. */ - carrier: z.enum(['exec_json', 'app_server']).nullable().optional(), }); +const catCarrierSchema = z.enum(CAT_CARRIERS); const clientSchema = z.enum(['anthropic', 'openai', 'google', 'kimi', 'antigravity', 'opencode', 'catagent', 'acp']); @@ -122,6 +126,14 @@ function resolveGenericAcpMcpSupportForPatch( return isClientSwitchToGenericAcp ? true : undefined; } +function resolveRequestedCarrier(clientId: ClientId, requested?: CatCarrier): CatCarrier { + const carrier = clientId === 'acp' ? 'acp' : (requested ?? 'cli'); + if (!catClientSupportsCarrier(clientId, carrier)) { + throw new Error(`carrier "${carrier}" is not supported by clientId "${clientId}"`); + } + return carrier; +} + const catIdSchema = z .string() .min(1) @@ -160,6 +172,7 @@ const baseCatSchema = z.object({ caution: z.string().nullable().optional(), strengths: z.array(z.string().min(1)).optional(), sessionChain: z.boolean().optional(), + carrier: catCarrierSchema.optional(), voiceConfig: voiceConfigSchema.optional(), }); @@ -221,6 +234,7 @@ const updateCatSchema = z.object({ sessionChain: z.boolean().optional(), available: z.boolean().optional(), clientId: clientSchema.optional(), + carrier: catCarrierSchema.optional(), defaultModel: modelSchema.optional(), mcpSupport: z.boolean().optional(), // F247 KD-17: nullable to allow removing cli (cloud-only Remote MCP cats skip local dispatch). @@ -318,12 +332,6 @@ function buildResolvedCliConfig( throw new Error(`client "${client}" does not support cli.effort`); } - const carrierTouched = patch ? Object.hasOwn(patch, 'carrier') : false; - const nextCarrier = carrierTouched ? patch?.carrier : baseCli.carrier; - if (nextCarrier !== undefined && nextCarrier !== null && client !== 'openai') { - throw new Error(`client "${client}" does not support cli.carrier (codex-only)`); - } - const serviceTierTouched = patch ? Object.hasOwn(patch, 'serviceTier') : false; const nextServiceTier = serviceTierTouched ? patch?.serviceTier : baseCli.serviceTier; @@ -334,7 +342,6 @@ function buildResolvedCliConfig( outputFormat: patch?.outputFormat ?? baseCli.outputFormat, ...(defaultArgs ? { defaultArgs } : {}), ...(nextEffort !== undefined && nextEffort !== null ? { effort: nextEffort } : {}), - ...(nextCarrier !== undefined && nextCarrier !== null ? { carrier: nextCarrier } : {}), ...(nextServiceTier !== undefined && nextServiceTier !== null ? { serviceTier: nextServiceTier } : {}), }; } @@ -360,8 +367,6 @@ function buildCliForModelSwitch(client: ClientId, defaultModel: string, currentC outputFormat: currentCli.outputFormat, ...(currentCli.defaultArgs?.length ? { defaultArgs: [...currentCli.defaultArgs] } : {}), ...(normalizedEffort ? { effort: normalizedEffort } : {}), - // Carrier is model-independent — preserve the per-cat override across model switches. - ...(currentCli.carrier ? { carrier: currentCli.carrier } : {}), // Requested service tier is also model-independent intent. Runtime compatibility // decides whether it is active for the selected model/account. ...(currentCli.serviceTier ? { serviceTier: currentCli.serviceTier } : {}), @@ -508,11 +513,13 @@ async function toCatResponse( metadata: CatResponseMetadata, resolveEffectiveAccountRef: (cat: CatConfig) => Promise, resolveContextCapacitySnapshot?: (catId: CatId) => InvocationCapacitySnapshot | undefined, + resolveCarrierCapability?: (catId: CatId) => FreshnessCarrierCapability | undefined, ) { const acpConfig = getAcpConfig(cat.id as string, projectRoot); const contextSnapshot = resolveContextCapacitySnapshot?.(cat.id); const contextCapability = contextSnapshot?.capability; const effectiveAccountRef = await resolveEffectiveAccountRef(cat); + const carrierCapability = resolveCarrierCapability?.(cat.id); return { id: cat.id, name: cat.name, @@ -524,14 +531,14 @@ async function toCatResponse( accountRef: effectiveAccountRef, clientId: cat.clientId, defaultModel: cat.defaultModel, + isDefaultResponder: cat.id === getDefaultCatId(), + messageDeliveryCapabilities: { + guideReply: supportsActiveInvocationGuidance(carrierCapability), + }, cli: cat.cli, - // F254 D2: effective carrier truth — only for cats that actually dispatch - // through the local Codex CLI. Generic ACP (getAcpConfig wins in the - // assembly) and cloud-only cats (cli removed, F247 KD-17) never reach the - // Codex carrier, so exposing one would be a lie. - ...(cat.clientId === 'openai' && !acpConfig && cat.cli != null - ? { codexCarrier: resolveCodexCarrierTruth(cat.cli.carrier) } - : {}), + // Canonical access-mode truth resolved at the catalog boundary. The Hub + // receives the same carrier value that provider registration consumes. + carrier: cat.carrier, contextWindow: getConfiguredMemberWindowSetting(cat), // #1208 Items 4+6: resolved context window info + client capability for Hub display. resolvedContext: (() => { @@ -587,8 +594,6 @@ async function toCatResponse( evaluation: metadata.roster.evaluation, } : null, - // F161: adapterMode is now provider-agnostic — any clientId can have ACP config - adapterMode: acpConfig ? 'acp' : 'cli', }; } @@ -672,6 +677,7 @@ async function cleanupBlockedMcpForAllProjects(projectRoot: string, deletedCatId interface CatsRoutesOptions { onCatalogChanged?: (cats: Record) => Promise | void; resolveContextCapacitySnapshot?: (catId: CatId) => InvocationCapacitySnapshot | undefined; + resolveCarrierCapability?: (catId: CatId) => FreshnessCarrierCapability | undefined; } export const catsRoutes: FastifyPluginAsync = async (app, opts) => { @@ -733,6 +739,7 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt resolveMetadata(cat.id), resolveEffectiveAccountRef, opts.resolveContextCapacitySnapshot, + opts.resolveCarrierCapability, ), ), ), @@ -759,6 +766,16 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt const projectRoot = resolveProjectRoot(); const managedIdsBefore = getManagedCatalogIds(projectRoot); const body = parsed.data; + let requestedCarrier: CatCarrier; + try { + requestedCarrier = resolveRequestedCarrier(body.clientId, body.carrier); + if (requestedCarrier === 'acp' && !('acp' in body && body.acp)) { + throw new Error('carrier "acp" requires an acp config (command + startupArgs)'); + } + } catch (err) { + reply.status(400); + return { error: err instanceof Error ? err.message : String(err) }; + } // Validate alias uniqueness across all existing members if (body.mentionPatterns?.length) { @@ -820,6 +837,7 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt caution: body.caution, strengths: body.strengths, clientId: 'antigravity', + carrier: requestedCarrier, defaultModel: body.defaultModel, mcpSupport: body.mcpSupport ?? true, cli: { @@ -848,6 +866,7 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt caution: body.caution, strengths: body.strengths, clientId: 'acp', + carrier: 'acp', defaultModel: body.defaultModel, mcpSupport: resolveGenericAcpMcpSupport(body.mcpSupport, body.acp) ?? false, cli: defaultCliForClient('acp'), @@ -860,7 +879,7 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt // Caller signals cloud-only via provider="openai-chatgpt-pro" (future: more cloud markers). const explicitProviderForCloud = 'provider' in body ? body.provider : undefined; const isCloudOnlyProvider = explicitProviderForCloud === 'openai-chatgpt-pro'; - const usesAcpTransport = body.acp != null; + const usesAcpTransport = requestedCarrier === 'acp'; const resolvedCli = isCloudOnlyProvider ? undefined : (() => { @@ -889,6 +908,7 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt caution: body.caution, strengths: body.strengths, clientId: body.clientId, + carrier: requestedCarrier, defaultModel: body.defaultModel, mcpSupport: body.mcpSupport ?? @@ -985,6 +1005,16 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt return { error: `Cat "${request.params.id}" not found` }; } const effectiveClient = body.clientId ?? currentCat.clientId; + let effectiveCarrier: CatCarrier; + try { + effectiveCarrier = resolveRequestedCarrier( + effectiveClient, + body.carrier ?? (body.clientId !== undefined ? undefined : currentCat.carrier), + ); + } catch (err) { + reply.status(400); + return { error: err instanceof Error ? err.message : String(err) }; + } const currentEffectiveAccountRef = await resolveEffectiveAccountRef(currentCat); let targetAccountRef = resolveAccountRef(body); let effectiveAccountRef = @@ -1072,11 +1102,12 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt } } - const shouldClearAcpOnClientSwitch = - isClientSwitch && effectiveClient !== 'acp' && body.acp === undefined && currentAcpConfig !== undefined; - const effectiveAcpConfig = - body.acp !== undefined ? body.acp : shouldClearAcpOnClientSwitch ? null : currentAcpConfig; - const usesAcpTransport = effectiveClient === 'acp' || effectiveAcpConfig != null; + const effectiveAcpConfig = body.acp !== undefined ? body.acp : currentAcpConfig; + const usesAcpTransport = effectiveCarrier === 'acp'; + if (usesAcpTransport && !effectiveAcpConfig) { + reply.status(400); + return { error: 'carrier "acp" requires an acp config (command + startupArgs)' }; + } const managedIdsBefore = getManagedCatalogIds(projectRoot); try { @@ -1096,6 +1127,8 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt : body.mcpSupport; // #1208 P2: canonicalize legacy cli.contextWindow to top-level on every save. const canonicalContextWindow = canonicalizeContextWindow(currentCat, body.contextWindow); + const leavesGenericAcpClient = + body.acp === undefined && currentCat.clientId === 'acp' && effectiveClient !== 'acp'; // #1208 Item 5 fix: force-strip legacy cli.contextWindow / cli.autoCompactTokenLimit // on every save. When nextCli is undefined (no cli patch), the old cli object persists @@ -1132,6 +1165,9 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt ...(body.caution !== undefined ? { caution: body.caution } : {}), ...(body.strengths !== undefined ? { strengths: body.strengths } : {}), ...(body.clientId !== undefined ? { clientId: body.clientId } : {}), + // Always write the resolved value so any legacy transport/cli.carrier + // selection converges to the canonical field on the next edit. + carrier: effectiveCarrier, ...(body.defaultModel !== undefined ? { defaultModel: body.defaultModel } : {}), ...(nextGenericAcpMcpSupport !== undefined ? { mcpSupport: nextGenericAcpMcpSupport } : {}), ...(hasCommandArgsPatch @@ -1164,12 +1200,12 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt ? { voiceConfig: null } : { voiceConfig: body.voiceConfig } : {}), - ...(body.acp !== undefined - ? body.acp === null - ? { acp: null } - : { acp: body.acp } - : shouldClearAcpOnClientSwitch - ? { acp: null } + ...(leavesGenericAcpClient + ? { acp: null } + : body.acp !== undefined + ? body.acp === null + ? { acp: null } + : { acp: body.acp } : {}), }); const resolved = await reconcileCatRegistry(projectRoot, managedIdsBefore); @@ -1263,7 +1299,7 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt return { error: 'Cat not found' }; } - // Cat status is currently tracked via WebSocket events (ThinkingIndicator/ParallelStatusBar). + // Cat status is currently tracked via WebSocket events (message bubbles / status panel). // This endpoint returns placeholder data; Redis-backed polling status is a future enhancement. // See: InvocationTracker for per-thread tracking, not per-cat. return { diff --git a/packages/api/src/routes/composer-draft-recall-handler.ts b/packages/api/src/routes/composer-draft-recall-handler.ts index 1f9c47f931..4cb3b54efa 100644 --- a/packages/api/src/routes/composer-draft-recall-handler.ts +++ b/packages/api/src/routes/composer-draft-recall-handler.ts @@ -1,21 +1,16 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import type { InvocationQueue, QueueEntry } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { - QueuedMessageCustodyCoordinator, - RecallMessageToComposerDraftCoordinatorResult, -} from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; -import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; +import type { + IMessageStore, + RecallMessageToComposerDraftResult, + StoredMessage, +} from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { IIndexBuilder, MessageRecallSuppressionLease } from '../domains/memory/interfaces.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; import { enrichQueueEntries } from '../utils/queue-enrichment.js'; -import { - projectRecallMessage, - queueEntryMessageIds, - rebuildCarrierWithout, - recallBodySchema, -} from './composer-draft-recall-helpers.js'; +import { projectRecallMessage, recallBodySchema } from './composer-draft-recall-helpers.js'; import { buildRecallSuccessResponse, emitRecallProjection } from './composer-draft-recall-projection.js'; import { type DerivedTitleSuppression, @@ -30,8 +25,10 @@ export interface RecallMessageRouteOptions { threadStore: Pick; socketManager: SocketManager; invocationQueue: InvocationQueue; - queueCustodyCoordinator: Pick; - queueProcessor?: Pick; + queueProcessor?: Pick< + QueueProcessor, + 'unregisterEntryCompleteHook' | 'finalizeRemovedEntry' | 'registerCallerDispatchQueueWithdrawal' + >; indexBuilder: Pick< IIndexBuilder, 'suppressMessagePassage' | 'releaseMessagePassageSuppression' | 'finalizeMessagePassageSuppression' @@ -50,12 +47,11 @@ interface RecallContext { } interface CarrierContext { - entryId: string; - current: QueueEntry | null; - nextEntryId?: string; - storedMembers: ReadonlyMap; + entries: QueueEntry[]; } +type RecallCommitResult = RecallMessageToComposerDraftResult | { kind: 'carrier_changed' }; + interface RecallPreparation { indexLease: MessageRecallSuppressionLease; titleSuppression: DerivedTitleSuppression | null; @@ -95,36 +91,21 @@ async function resolveRecallContext( } async function resolveCarrier(context: RecallContext, reply: FastifyReply): Promise> { - const entryId = context.target.queueCustody?.entryId; - if (!entryId) return reject(reply, 409, { error: 'Message is not recallable', code: 'MESSAGE_NOT_RECALLABLE' }); - const entries = context.opts.invocationQueue.list(context.threadId, context.ownerUserId); - const entryIndex = entries.findIndex((entry) => entry.id === entryId); - const current = - entryIndex >= 0 - ? context.opts.invocationQueue.getEntrySnapshot(context.threadId, context.ownerUserId, entryId) - : null; - if (current && !queueEntryMessageIds(current).includes(context.messageId)) { - return reject(reply, 409, { error: 'Message carrier mismatch', code: 'QUEUE_CARRIER_MISMATCH' }); + const claim = await context.opts.invocationQueue.claimMessageEntriesForWithdrawal( + context.threadId, + context.ownerUserId, + context.messageId, + ); + if (claim.outcome === 'processing') { + return reject(reply, 409, { error: 'Message is already processing', code: 'ENTRY_PROCESSING' }); } - const members = current - ? await Promise.all(queueEntryMessageIds(current).map((messageId) => context.opts.messageStore.getById(messageId))) - : []; - if (members.some((message) => !message)) { - return reject(reply, 503, { error: 'Queue carrier is incomplete', code: 'QUEUE_CARRIER_INCOMPLETE' }); + if (claim.outcome === 'not_found') { + return reject(reply, 409, { error: 'Message is not recallable', code: 'MESSAGE_NOT_RECALLABLE' }); } - return { - ok: true, - value: { - entryId, - current, - nextEntryId: entryIndex >= 0 ? entries[entryIndex + 1]?.id : undefined, - storedMembers: new Map( - members - .filter((message): message is StoredMessage => message !== null) - .map(({ id, content }) => [id, { id, content }]), - ), - }, - }; + if (claim.outcome !== 'claimed') { + return reject(reply, 409, { error: 'Queue carrier changed concurrently', code: 'QUEUE_CARRIER_CHANGED' }); + } + return { ok: true, value: { entries: claim.entries } }; } async function releasePreparation( @@ -205,31 +186,36 @@ async function commitRecall( context: RecallContext, carrier: CarrierContext, preparation: RecallPreparation, -): Promise { - const restoreCurrent = () => { - if (carrier.current) { - context.opts.invocationQueue.restoreDurableEntry(carrier.current, { beforeEntryId: carrier.nextEntryId }); - } - }; +): Promise { + const entryIds = carrier.entries.map((entry) => entry.id); try { - return await context.opts.queueCustodyCoordinator.recallMessageToComposerDraft( - carrier.entryId, - context.messageId, - { - ownerUserId: context.ownerUserId, - threadId: context.threadId, - expectedDraftRevision: context.expectedDraftRevision, - merge: context.merge, - recalledAt: Date.now(), - }, - carrier.current - ? { - freeze: () => context.opts.invocationQueue.removeEntrySnapshotIfUnchanged(carrier.current as QueueEntry), - restore: restoreCurrent, - } - : undefined, - ); + const result = await context.opts.messageStore.recallMessageToComposerDraft(context.messageId, { + ownerUserId: context.ownerUserId, + threadId: context.threadId, + expectedDraftRevision: context.expectedDraftRevision, + merge: context.merge, + recalledAt: Date.now(), + }); + if (result.kind === 'recalled' || result.kind === 'already_recalled') { + const withdrawalCommitted = await context.opts.invocationQueue.commitClaimedMessageWithdrawal( + context.threadId, + entryIds, + ); + if (!withdrawalCommitted) { + context.request.log.error( + { threadId: context.threadId, messageId: context.messageId, entryIds }, + 'true recall committed; claimed Queue withdrawal deferred to restart hydration', + ); + } else { + for (const entry of carrier.entries) context.opts.queueProcessor?.registerCallerDispatchQueueWithdrawal(entry); + } + return result; + } + return (await context.opts.invocationQueue.restoreClaimedEntries(context.threadId, entryIds)) + ? result + : { kind: 'carrier_changed' }; } catch (error) { + await context.opts.invocationQueue.restoreClaimedEntries(context.threadId, entryIds); await releasePreparation(context, preparation, 'canonical_recall_rejected'); throw error; } @@ -239,7 +225,7 @@ async function projectRecallFailure( context: RecallContext, preparation: RecallPreparation, reply: FastifyReply, - result: Exclude, + result: Exclude, ): Promise> { await releasePreparation(context, preparation, 'canonical_recall_rejected'); if (result.kind === 'draft_revision_mismatch') { @@ -258,22 +244,16 @@ async function projectRecallFailure( } async function finalizeCarrier(context: RecallContext, carrier: CarrierContext): Promise { - const replacement = carrier.current - ? rebuildCarrierWithout(carrier.current, context.messageId, carrier.storedMembers) - : null; - if (replacement) { - context.opts.invocationQueue.restoreDurableEntry(replacement, { beforeEntryId: carrier.nextEntryId }); - return; - } - if (!carrier.current) return; - context.opts.queueProcessor?.unregisterEntryCompleteHook(carrier.entryId); - try { - await context.opts.queueProcessor?.finalizeRemovedEntry(carrier.current, 'user_cancel'); - } catch (error) { - context.request.log.error( - { error, entryId: carrier.entryId }, - 'true recall committed; Queue record finalization deferred to recovery', - ); + for (const entry of carrier.entries) { + context.opts.queueProcessor?.unregisterEntryCompleteHook(entry.id); + try { + await context.opts.queueProcessor?.finalizeRemovedEntry(entry, 'user_cancel'); + } catch (error) { + context.request.log.error( + { error, entryId: entry.id }, + 'true recall committed; Queue record finalization deferred to recovery', + ); + } } } @@ -282,7 +262,7 @@ async function finalizeRecallResult( carrier: CarrierContext, preparation: RecallPreparation, reply: FastifyReply, - result: RecallMessageToComposerDraftCoordinatorResult, + result: RecallCommitResult, ) { if (result.kind === 'already_recalled') { return buildAlreadyRecalledResponse(context, result.message, preparation.indexLease); @@ -326,11 +306,14 @@ export function createRecallMessageHandler( if (!resolved.ok) return resolved.body; const context = resolved.value; if (context.target.recall) return buildAlreadyRecalledResponse(context, context.target); - const carrier = await resolveCarrier(context, reply); - if (!carrier.ok) return carrier.body; const prepared = await prepareSuppression(context, reply); if (!prepared.ok) return prepared.body; const preparation = prepared.value; + const carrier = await resolveCarrier(context, reply); + if (!carrier.ok) { + await releasePreparation(context, preparation, 'queue_claim_rejected'); + return carrier.body; + } const result = await commitRecall(context, carrier.value, preparation); return finalizeRecallResult(context, carrier.value, preparation, reply, result); }; diff --git a/packages/api/src/routes/composer-draft-recall-helpers.ts b/packages/api/src/routes/composer-draft-recall-helpers.ts index ceda6b7003..4d4e560bde 100644 --- a/packages/api/src/routes/composer-draft-recall-helpers.ts +++ b/packages/api/src/routes/composer-draft-recall-helpers.ts @@ -4,7 +4,6 @@ import { ContextAttachmentsSchema, } from '@cat-cafe/shared'; import { z } from 'zod'; -import type { QueueEntry } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; const composerDraftImageBlockSchema = z @@ -67,30 +66,6 @@ export function projectRecallMessage(message: Awaited): string[] { - return [entry.messageId ?? '', ...entry.mergedMessageIds].filter(Boolean); -} - -export function rebuildCarrierWithout( - removed: QueueEntry, - recalledMessageId: string, - storedMembers: ReadonlyMap, -): QueueEntry | null { - const remaining = queueEntryMessageIds(removed) - .filter((messageId) => messageId !== recalledMessageId) - .map((messageId) => storedMembers.get(messageId)) - .filter((message): message is { id: string; content: string } => Boolean(message)); - const primary = remaining[0]; - if (!primary) return null; - return { - ...removed, - content: remaining.map((message) => message.content).join('\n\n'), - messageId: primary.id, - mergedMessageIds: remaining.slice(1).map((message) => message.id), - }; -} diff --git a/packages/api/src/routes/composer-draft-recall-projection.ts b/packages/api/src/routes/composer-draft-recall-projection.ts index d1a52cddea..4563c63d62 100644 --- a/packages/api/src/routes/composer-draft-recall-projection.ts +++ b/packages/api/src/routes/composer-draft-recall-projection.ts @@ -1,12 +1,14 @@ import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { RecallMessageToComposerDraftCoordinatorResult } from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; +import type { + IMessageStore, + RecallMessageToComposerDraftResult, +} from '../domains/cats/services/stores/ports/MessageStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; import { emitQueueUpdated, enrichQueueEntries } from '../utils/queue-enrichment.js'; import { projectRecallMessage } from './composer-draft-recall-helpers.js'; import type { DerivedTitleSuppression } from './composer-draft-recall-title.js'; -type RecalledResult = Extract; +type RecalledResult = Extract; interface RecallProjectionContext { socketManager: SocketManager; diff --git a/packages/api/src/routes/composer-draft-recall.ts b/packages/api/src/routes/composer-draft-recall.ts index 293a910117..3bacb1909f 100644 --- a/packages/api/src/routes/composer-draft-recall.ts +++ b/packages/api/src/routes/composer-draft-recall.ts @@ -1,6 +1,5 @@ import type { FastifyPluginAsync } from 'fastify'; import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { QueuedMessageCustodyCoordinator } from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; @@ -15,8 +14,10 @@ export interface ComposerDraftRecallRoutesOptions { threadStore?: Pick; socketManager: SocketManager; invocationQueue?: InvocationQueue; - queueCustodyCoordinator?: Pick; - queueProcessor?: Pick; + queueProcessor?: Pick< + QueueProcessor, + 'unregisterEntryCompleteHook' | 'finalizeRemovedEntry' | 'registerCallerDispatchQueueWithdrawal' + >; indexBuilder?: Pick< IIndexBuilder, 'suppressMessagePassage' | 'releaseMessagePassageSuppression' | 'finalizeMessagePassageSuppression' @@ -81,14 +82,13 @@ export const composerDraftRecallRoutes: FastifyPluginAsync( '/api/messages/:id/recall', createRecallMessageHandler( { ...opts, invocationQueue: opts.invocationQueue, - queueCustodyCoordinator: opts.queueCustodyCoordinator, indexBuilder: opts.indexBuilder, threadStore: opts.threadStore, }, diff --git a/packages/api/src/routes/concierge.ts b/packages/api/src/routes/concierge.ts index bd0da11ee5..4c494bfad8 100644 --- a/packages/api/src/routes/concierge.ts +++ b/packages/api/src/routes/concierge.ts @@ -491,7 +491,6 @@ export const conciergeRoutes: FastifyPluginAsync = async content: relayContent, threadId: targetThreadId, mentions: targetCats, - deliveryMode: 'immediate', idempotencyKey: clientMessageId, }, headers: { @@ -554,7 +553,6 @@ export const conciergeRoutes: FastifyPluginAsync = async content: relayContent, threadId: receipt.targetThreadId, mentions: receipt.targetCats, - deliveryMode: 'immediate', idempotencyKey: receipt.clientMessageId, }, headers: { diff --git a/packages/api/src/routes/config-message-disposition.ts b/packages/api/src/routes/config-message-disposition.ts index 8088c7692c..ab17814342 100644 --- a/packages/api/src/routes/config-message-disposition.ts +++ b/packages/api/src/routes/config-message-disposition.ts @@ -18,7 +18,6 @@ const querySchema = z.object({ threadId: z.string().min(1).optional() }); const putSchema = z.discriminatedUnion('scope', [ z.object({ scope: z.literal('global'), disposition: dispositionSchema.nullable() }), z.object({ scope: z.literal('thread'), threadId: z.string().min(1), disposition: dispositionSchema.nullable() }), - z.object({ scope: z.literal('onboarding'), seen: z.literal(true) }), ]); export async function configMessageDispositionRoutes( diff --git a/packages/api/src/routes/cross-thread-coordination.ts b/packages/api/src/routes/cross-thread-coordination.ts index fe6f1d2735..d416dee21f 100644 --- a/packages/api/src/routes/cross-thread-coordination.ts +++ b/packages/api/src/routes/cross-thread-coordination.ts @@ -35,6 +35,8 @@ interface ResolveInput { explicit?: CrossThreadCoordinationInput; incoming?: IncomingCrossThreadCoordination; targetThreadId: string; + /** Any authored routing credential is an instruction, never courtesy prose. */ + hasExplicitRoutingIntent?: boolean; mintId?: () => string; } @@ -137,6 +139,27 @@ export function resolveCrossThreadCoordination(input: ResolveInput): ResolvedCro return resolveExplicitActive(input, mintId); } + // Routing intent wins over a stale terminal label. A terminal recipient can + // send unaddressed courtesy prose as an ACK, but line-start mentions and + // structured targetCats both name new work. Mint a fresh active generation + // rather than inheriting or re-closing the already terminal lineage. + if ( + input.hasExplicitRoutingIntent && + incomingCoordination?.phase === 'terminal' && + input.targetThreadId === input.incoming?.sourceThreadId + ) { + return resolveExplicitActive( + { + ...input, + explicit: { + phase: 'active', + ...(input.explicit?.subjectRef ? { subjectRef: input.explicit.subjectRef } : {}), + }, + }, + mintId, + ); + } + if (input.explicit?.phase === 'terminal') { return resolveExplicitTerminal(input, mintId); } diff --git a/packages/api/src/routes/export.ts b/packages/api/src/routes/export.ts index be196b3f4e..700bbdea7a 100644 --- a/packages/api/src/routes/export.ts +++ b/packages/api/src/routes/export.ts @@ -129,7 +129,18 @@ export function formatThreadAsText(thread: Thread, messages: StoredMessage[]): s } function selectionSender(item: ResolvedMessageSelectionItem): string { - return item.author.kind === 'user' ? 'co-creator' : getSenderName(item.author.catId); + switch (item.from.kind) { + case 'user': + return 'co-creator'; + case 'agent': + return getSenderName(item.from.catId); + case 'external': + return item.from.sender?.name ?? item.from.sender?.id ?? item.from.connectorId; + case 'plugin': + return item.from.instanceId; + case 'system': + return item.from.service; + } } export function formatSelectionAsMarkdown( diff --git a/packages/api/src/routes/hold-ball-terminal-visibility.ts b/packages/api/src/routes/hold-ball-terminal-visibility.ts new file mode 100644 index 0000000000..9369b2440b --- /dev/null +++ b/packages/api/src/routes/hold-ball-terminal-visibility.ts @@ -0,0 +1,75 @@ +import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; +import type { SocketManager } from '../infrastructure/websocket/index.js'; +import { HOLD_BALL_SOURCE } from './hold-ball-source.js'; + +export interface HoldTerminalVisibilityInput { + readonly taskId: string; + readonly threadId: string; + readonly userId: string; + readonly catId: string; + readonly outcome: string; + readonly content: string; + readonly idempotencySuffix?: string; +} + +/** + * Persist one owner-visible hold terminal fact and recover a commit whose append + * acknowledgement was lost. Socket delivery is only a live projection; History + * remains the durable truth and therefore succeeds independently of broadcast. + */ +export async function persistHoldTerminalVisibility( + deps: { messageStore: IMessageStore; socketManager: SocketManager }, + input: HoldTerminalVisibilityInput, +): Promise { + const idempotencyKey = `hold-ball-terminal:${input.taskId}:${input.idempotencySuffix ?? input.outcome}`; + const source = { + ...HOLD_BALL_SOURCE, + meta: { + managedHold: true, + phase: 'terminal', + taskId: input.taskId, + threadId: input.threadId, + catId: input.catId, + outcome: input.outcome, + }, + } as const; + + let stored: StoredMessage; + try { + stored = await deps.messageStore.append({ + from: { kind: 'system', service: 'hold-ball' }, + userId: input.userId, + content: input.content, + mentions: [], + timestamp: Date.now(), + threadId: input.threadId, + idempotencyKey, + source, + }); + } catch (appendError) { + try { + const committed = await deps.messageStore.getByIdempotencyKey(input.userId, input.threadId, idempotencyKey); + if (!committed) throw appendError; + stored = committed; + } catch (lookupError) { + if (lookupError === appendError) throw appendError; + throw new AggregateError([appendError, lookupError], 'hold terminal History outcome is unknown'); + } + } + + try { + deps.socketManager.broadcastToRoom(`thread:${input.threadId}`, 'connector_message', { + threadId: input.threadId, + message: { + id: stored.id, + type: 'connector', + content: stored.content, + source: stored.source, + timestamp: stored.timestamp, + }, + }); + } catch { + // Durable History already owns the fact. A later history read repairs live UI. + } + return stored; +} diff --git a/packages/api/src/routes/invocations.ts b/packages/api/src/routes/invocations.ts index 5914203057..c132c8ed3c 100644 --- a/packages/api/src/routes/invocations.ts +++ b/packages/api/src/routes/invocations.ts @@ -1,60 +1,23 @@ /** - * Invocations API Routes - * GET /api/invocations/:id — 查询 InvocationRecord 状态 - * POST /api/invocations/:id/retry — 重试 failed/queued invocation + * InvocationRecord read projections. * - * ADR-008 S1: InvocationRecord 查询 + 重试端点桩 - * ADR-008 S2: retry 端点接通实际执行 + * Failed-target retry belongs to the exact Queue-attempt endpoint on the source + * message receipt. This route must never revive an old InvocationRecord or + * launch an Agent Client outside Queue admission. */ -import type { CatId } from '@cat-cafe/shared'; import type { FastifyPluginAsync } from 'fastify'; -import { getDefaultCatId } from '../config/cat-config-loader.js'; -import { createModuleLogger } from '../infrastructure/logger.js'; - -const log = createModuleLogger('routes/invocations'); - -import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - isTerminalDispositionEvent, - PerCatTerminalDispositionCollector, -} from '../domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.js'; -import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; -import { requireInvocationRecordUpdate } from '../domains/cats/services/agents/invocation/require-invocation-record-update.js'; -import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; -import type { AgentRouter } from '../domains/cats/services/agents/routing/AgentRouter.js'; -import { - createA2ASlotTrackingBridge, - type PersistenceContext, -} from '../domains/cats/services/agents/routing/route-helpers.js'; -import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; -import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { ITurnExecutionStore } from '../domains/cats/services/stores/ports/TurnExecutionStore.js'; -import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { resolveStrictUserId } from '../utils/request-identity.js'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; export interface InvocationsRoutesOptions { invocationRecordStore: IInvocationRecordStore; turnExecutionStore?: Pick; - messageStore: IMessageStore; - socketManager: SocketManager; - router: AgentRouter; - invocationTracker: InvocationTracker; - uploadDir?: string; - /** F39: Queue processor for auto-dequeue on retry complete */ - queueProcessor?: QueueProcessor; } export const invocationsRoutes: FastifyPluginAsync = async (app, opts) => { - const uploadDir = getDefaultUploadDir(opts.uploadDir ?? process.env.UPLOAD_DIR); - - // GET /api/invocations/:id — query InvocationRecord state app.get<{ Params: { id: string } }>('/api/invocations/:id', async (request, reply) => { - const { id } = request.params; - const record = await opts.invocationRecordStore.get(id); - + const record = await opts.invocationRecordStore.get(request.params.id); if (!record) { reply.status(404); return { error: 'Invocation not found', code: 'INVOCATION_NOT_FOUND' }; @@ -92,361 +55,4 @@ export const invocationsRoutes: FastifyPluginAsync = a executions, }; }); - - // POST /api/invocations/:id/retry — retry failed/queued invocation (ADR-008 S2) - app.post<{ Params: { id: string } }>('/api/invocations/:id/retry', async (request, reply) => { - const { id } = request.params; - const record = await opts.invocationRecordStore.get(id); - - // ① Not found - if (!record) { - reply.status(404); - return { error: 'Invocation not found', code: 'INVOCATION_NOT_FOUND' }; - } - const ownerAuthProvenance = resolveStrictUserId(request) === record.userId ? 'strict' : 'compatibility_fallback'; - - // Snapshot status before any await yields (in-memory get() returns a live reference) - const snapshotStatus = record.status; - const snapshotError = record.error ?? ''; - - // ② Only failed and queued are retryable - if (snapshotStatus !== 'failed' && snapshotStatus !== 'queued') { - reply.status(409); - return { - error: `Cannot retry invocation with status '${snapshotStatus}'`, - code: 'INVOCATION_NOT_RETRYABLE', - currentStatus: snapshotStatus, - }; - } - - // ③ Need the original user message to re-execute - if (record.userMessageId === null) { - reply.status(400); - return { - error: '原始消息未保存,请重新发送', - code: 'USER_MESSAGE_NOT_SAVED', - }; - } - - const storedMessage = await opts.messageStore.getById(record.userMessageId); - if (!storedMessage) { - reply.status(400); - return { - error: '原始消息已过期或被删除,请重新发送', - code: 'USER_MESSAGE_EXPIRED', - }; - } - - const previouslySuccessfulCatIds = [...(record.successfulCatIds ?? [])]; - const retryTargetCats = record.targetCats.filter((catId) => !previouslySuccessfulCatIds.includes(catId)); - if (retryTargetCats.length === 0) { - reply.status(409); - return { error: 'All original targets have already completed', code: 'INVOCATION_NOT_RETRYABLE' }; - } - - // ④ Preserve the original body; retry only targets without a success witness. - const intent = parseIntent(storedMessage.content, retryTargetCats.length); - - // ⑤ Delete guard check - if (opts.invocationTracker.isDeleting(record.threadId)) { - reply.status(409); - return { - error: '对话正在删除中', - detail: '请稍后重试,或新建一个对话继续', - code: 'THREAD_DELETING', - }; - } - - // ⑥ Claim retry before touching tracker/reservation ownership. - // expectedStatus ensures only one concurrent request wins; a loser must not retire - // an unrelated queued pre-start reservation through replacement acquisition. - const claimed = await opts.invocationRecordStore.update(id, { - status: 'running', - error: '', - expectedStatus: snapshotStatus, - }); - if (!claimed) { - reply.status(409); - return { - error: `Cannot retry invocation with status '${snapshotStatus}'`, - code: 'INVOCATION_NOT_RETRYABLE', - currentStatus: snapshotStatus, - }; - } - - // ⑦ Start invocation tracking for ALL target cats (multi-cat F5 recovery) - const primaryCat = retryTargetCats[0] ?? 'unknown'; - const jointAcquire = opts.queueProcessor?.acquireExternalExecution?.bind(opts.queueProcessor); - const controller = jointAcquire - ? await jointAcquire(record.threadId, retryTargetCats, record.userId, { - mode: 'replacement', - executionId: id, - }) - : opts.invocationTracker.startAll(record.threadId, retryTargetCats, record.userId, id); - if (!controller) { - // running -> queued is not a legal lifecycle transition. A retry of an already-queued - // record that loses ownership therefore terminalizes as failed; an originally failed - // record restores its prior terminal state and error. - const refusalStatus = snapshotStatus === 'queued' ? 'failed' : snapshotStatus; - const refusalError = snapshotStatus === 'queued' ? 'invocation_owner_changed_before_retry_start' : snapshotError; - let refused = null; - for (let attempt = 0; attempt < 3; attempt++) { - if (attempt > 0) { - const current = await opts.invocationRecordStore.get(id); - if (current?.status !== 'running') break; - } - refused = await opts.invocationRecordStore.update(id, { - status: refusalStatus, - error: refusalError, - expectedStatus: 'running', - }); - if (refused) break; - } - if (!refused) { - const current = await opts.invocationRecordStore.get(id); - const context = { invocationId: id, threadId: record.threadId, snapshotStatus, currentStatus: current?.status }; - if (current?.status === 'running') { - log.error(context, 'Retry ownership refusal left a durable claim running after terminalization retries'); - } else { - log.warn(context, 'Retry ownership refusal stopped terminalization because the durable status changed'); - } - } - reply.status(409); - return { - error: 'Invocation owner changed before retry could start', - code: 'INVOCATION_OWNER_CHANGED', - }; - } - if (controller.signal.aborted) { - await opts.invocationRecordStore.update(id, { status: 'canceled' }); - reply.status(409); - return { - error: '对话正在删除中', - detail: '请稍后重试,或新建一个对话继续', - code: 'THREAD_DELETING', - }; - } - - // F254 D1.2b: retry reuses the existing InvocationRecord id as the callback parent id. - // Clear stale read evidence from earlier failed/canceled attempts before this attempt can - // record fresh queued_seen via get_thread_context(responseMode=full). - opts.queueProcessor?.clearQueuedSeenInvocationForCats?.(record.threadId, record.targetCats, id); - - // ⑧ Reply 202 immediately - reply.status(202); - reply.send({ - status: 'retrying', - invocationId: id, - }); - - // ⑨ Background: routeExecution() → succeeded/failed - void (async () => { - const HEARTBEAT_INTERVAL_MS = 30_000; - const heartbeatInterval = setInterval(() => { - opts.socketManager.broadcastToRoom(`thread:${record.threadId}`, 'heartbeat', { - threadId: record.threadId, - timestamp: Date.now(), - }); - }, HEARTBEAT_INTERVAL_MS); - - // F39: Track final status for queue auto-dequeue - let finalStatus: 'succeeded' | 'failed' | 'canceled' = 'failed'; - const terminalDispositions = new PerCatTerminalDispositionCollector({ - targetCatIds: retryTargetCats, - isCanceled: (catId) => opts.invocationTracker.getSlotState?.(record.threadId, catId) === 'canceled', - }); - const successfulCatIds = () => - [...new Set([...previouslySuccessfulCatIds, ...terminalDispositions.getSuccessfulCatIds()])] as CatId[]; - const successWitness = () => { - const completed = successfulCatIds(); - return completed.length > 0 ? { successfulCatIds: completed } : {}; - }; - - try { - opts.socketManager.broadcastToRoom(`thread:${record.threadId}`, 'intent_mode', { - threadId: record.threadId, - mode: intent.intent, - targetCats: retryTargetCats, - }); - - // ADR-008 S3: collect cursor boundaries; ack only after succeeded - const cursorBoundaries = new Map(); - // P1-2: track persistence failures across generator boundary - const persistenceContext: PersistenceContext = { failed: false, errors: [] }; - // F070: track governance block errorCode (mirror messages.ts) - let governanceErrorCode: string | undefined; - - for await (const msg of opts.router.routeExecution( - record.userId, - storedMessage.content, - record.threadId, - storedMessage.id, - retryTargetCats, - intent, - { - ownerAuthProvenance, - humanDispositionInvocationOrigin: 'direct_owner', - ...(storedMessage.contentBlocks ? { contentBlocks: storedMessage.contentBlocks } : {}), - uploadDir, - signal: controller.signal, - // F-parallel-cancel (cloud #7): the retry endpoint is a startAll caller too. After the - // batchController split, controller.signal is the INDEPENDENT batch gate — a single-cat - // cancel aborts only that cat's slot controller, not the batch gate. Pass signalForCat so - // the route observes the per-cat signal and a Stop on one cat of a retried multi-cat - // invocation is honored immediately (not ignored until cancel-all). Mirrors messages.ts / - // QueueProcessor. - signalForCat: (catId: string) => opts.invocationTracker.getController?.(record.threadId, catId)?.signal, - ...createA2ASlotTrackingBridge(opts.invocationTracker, controller, id), - ...(opts.queueProcessor - ? { - queueHasQueuedMessages: (tid: string) => - opts.queueProcessor?.hasQueuedNonAgentForThread(tid) ?? false, - getQueuedFreshnessMessagesForCat: ( - tid: string, - uid: string, - catId: string, - parentInvocationId?: string, - ) => opts.queueProcessor?.getQueuedFreshnessMessagesForCat(tid, uid, catId, parentInvocationId) ?? [], - hasQueuedOrActiveAgentForCat: (tid: string, catId: string) => - opts.queueProcessor?.hasActiveOrQueuedAgentForCat(tid, catId) ?? false, - hasPendingForCat: (tid: string, uid: string, catId: string) => - opts.queueProcessor?.hasPendingForCat(tid, uid, catId) ?? false, - deferA2AEnqueue: (e) => opts.queueProcessor?.enqueueRaw({ ...e, ownerAuthProvenance }), - // F254 B3: freshness re-invoke enqueue for retry endpoint. - // Without this, freshnessReinvoke metadata from invoke-single-cat is - // silently dropped — same class as the messages.ts immediate-path fix. - // Matches QueueProcessor + messages.ts pattern: strip freshnessContext. - freshnessReinvokeEnqueue: (e: any) => { - const { freshnessContext: _ctx, ...queueFields } = e; - return opts.queueProcessor?.enqueueRaw({ ...queueFields, ownerAuthProvenance }); - }, - } - : {}), - cursorBoundaries, - persistenceContext, - parentInvocationId: id, - onPromptMessagesExposed: (input) => opts.queueProcessor?.markPromptMessagesSeen(input) ?? Promise.resolve(), - // F222 P1: User-initiated retry → eligible for frustration detection - frustrationAutoIssueEligible: true, - }, - )) { - if (msg.type === 'done' && msg.errorCode) { - governanceErrorCode = msg.errorCode; - } - terminalDispositions.observe(msg); - if (isTerminalDispositionEvent(msg) && msg.catId) { - opts.invocationTracker.completeSlot(record.threadId, msg.catId, controller); - } - // F194 Phase Z9 (砚砚 R1 P1-2): use stampVisibleTurn helper. After route-serial / - // route-parallel stamp ownInvocationId on yielded events (R1 P1-1), msg.invocationId - // is always defined for assistant turns; helper falls back to parent only as defense - // in depth for non-assistant or pre-system_info events. - opts.socketManager.broadcastAgentMessage( - { ...msg, ...stampVisibleTurn(id, msg.invocationId) }, - record.threadId, - ); - } - - // P1-2: mark failed if any message persistence failed - if (persistenceContext.failed) { - const errorDetail = persistenceContext.errors.map((e) => `${e.catId}: ${e.error}`).join('; '); - await opts.invocationRecordStore.update(id, { - status: 'failed', - error: `Message delivered but persistence failed: ${errorDetail}`, - ...successWitness(), - }); - opts.socketManager.broadcastAgentMessage( - { - type: 'error', - catId: getDefaultCatId(), - error: '消息已发送但未能保存,刷新后可能丢失。可点击重试。', - timestamp: Date.now(), - }, - record.threadId, - ); - } else if (governanceErrorCode) { - await opts.invocationRecordStore.update(id, { - status: 'failed', - error: governanceErrorCode, - ...successWitness(), - }); - } else { - // F-parallel-cancel (cloud #7): AGGREGATE finalStatus — a single-cat cancel no longer - // aborts the batch gate, so raw controller.signal.aborted only covers whole-invocation - // abort. Resolve canceled-by-user from per-cat slot tombstones (completeAll runs in the - // finally below, AFTER this, so tombstones are still visible). Mirrors QueueProcessor. - const batchReason = controller.signal.reason; - const aggStatus = opts.invocationTracker.resolveFinalStatus - ? opts.invocationTracker.resolveFinalStatus(record.threadId, retryTargetCats, { - aborted: controller.signal.aborted, - reason: batchReason as string | undefined, - }) - : controller.signal.aborted - ? 'canceled' - : 'succeeded'; - if (aggStatus === 'canceled_by_user' || aggStatus === 'canceled') { - await opts.invocationRecordStore.update(id, { status: 'canceled' }); - finalStatus = 'canceled'; - } else { - // ADR-008 S3: ack cursors before marking succeeded so that if ack - // throws, the catch block sees running→failed (valid transition). - await opts.router.ackCollectedCursors(record.userId, record.threadId, cursorBoundaries); - const error = terminalDispositions.getPrimaryTerminalError(); - const completed = successfulCatIds(); - const status = error ? ('failed' as const) : ('succeeded' as const); - await requireInvocationRecordUpdate({ - store: opts.invocationRecordStore, - invocationId: id, - update: { - status, - ...(completed.length > 0 || status === 'succeeded' ? { successfulCatIds: completed } : {}), - ...(error ? { error } : {}), - }, - writer: 'invocation retry route', - }); - finalStatus = status; - } - } - } catch (err) { - log.error({ err }, 'Retry execution error'); - const errorMsg = err instanceof Error ? err.message : 'Unknown error'; - await opts.invocationRecordStore.update(id, { - status: 'failed', - error: errorMsg, - ...successWitness(), - }); - opts.socketManager.broadcastAgentMessage( - { - type: 'error', - catId: getDefaultCatId(), - error: errorMsg, - isFinal: true, - timestamp: Date.now(), - }, - record.threadId, - ); - } finally { - clearInterval(heartbeatInterval); - opts.invocationTracker.completeAll(record.threadId, retryTargetCats, controller); - // F39: Notify queue processor for auto-dequeue chain - opts.queueProcessor - ?.onInvocationComplete( - record.threadId, - primaryCat, - finalStatus, - // F254 D1.2b: stale evidence was cleared when this retry was claimed, so `id` - // now represents only queued_seen tokens freshly recorded during this attempt. - id, - finalStatus === 'succeeded' ? terminalDispositions.getSuccessfulCatIds() : [], - false, - terminalDispositions.getTerminalInvocationIdByCatId(), - [], - terminalDispositions.getTerminalConsumptionByInvocationId(), - ) - .catch(() => { - /* best-effort */ - }); - } - })(); - }); }; diff --git a/packages/api/src/routes/message-bundles.ts b/packages/api/src/routes/message-bundles.ts index d7065cf883..531db23eb6 100644 --- a/packages/api/src/routes/message-bundles.ts +++ b/packages/api/src/routes/message-bundles.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from 'fastify'; import { MessageSelectionResolver } from '../domains/cats/services/context/MessageSelectionResolver.js'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { resolveStrictUserId } from '../utils/request-identity.js'; @@ -31,7 +32,7 @@ export const messageBundleRoutes: FastifyPluginAsync targetMessage.deletedAt !== undefined || targetMessage._tombstone === true || targetMessage.deliveryStatus === 'canceled' || - targetMessage.catId !== null || + messageFrom(targetMessage).kind !== 'user' || !targetMessage.extra?.messageBundle ) { reply.status(404); diff --git a/packages/api/src/routes/message-disposition-admission.ts b/packages/api/src/routes/message-disposition-admission.ts index af98ed4615..785e077c07 100644 --- a/packages/api/src/routes/message-disposition-admission.ts +++ b/packages/api/src/routes/message-disposition-admission.ts @@ -1,7 +1,16 @@ /** F264: resolve scoped author preference and bind current-work intent to an exact live parent. */ -import type { CatId, FreshnessCarrierCapability, MessageWorkDisposition, QueueAuthorIntent } from '@cat-cafe/shared'; -import { resolveMessageDispositionPreference } from '../config/user-preferences-store.js'; +import { + type CatId, + type FreshnessCarrierCapability, + type MessageWorkDisposition, + type QueueAuthorIntent, + supportsActiveInvocationGuidance, +} from '@cat-cafe/shared'; +import { + MESSAGE_DISPOSITION_PRODUCT_DEFAULT, + resolveMessageDispositionPreference, +} from '../config/user-preferences-store.js'; import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; type ExactParentTracker = Pick; @@ -10,6 +19,7 @@ const UNDECLARED_CARRIER_CAPABILITY: FreshnessCarrierCapability = { provider: 'other', carrier: 'other', deliverySemantics: 'undeclared', + activeInvocationGuidance: 'undeclared', }; type FreshnessCapabilityOwner = { @@ -31,7 +41,7 @@ export function resolveMessageDispositionForAdmission(input: { threadId: string; }): MessageWorkDisposition { if (input.explicit) return input.explicit; - if (!input.projectRoot) return 'next_work'; + if (!input.projectRoot) return MESSAGE_DISPOSITION_PRODUCT_DEFAULT; return resolveMessageDispositionPreference(input.projectRoot, input.threadId).effective; } @@ -51,7 +61,7 @@ export function resolveQueueAuthorIntentByCatId(input: { if (input.requested === 'next_work') { return [catId, { requested: 'next_work', carrierCapability } satisfies QueueAuthorIntent]; } - if (carrierCapability.deliverySemantics !== 'exact_active_turn') { + if (!supportsActiveInvocationGuidance(carrierCapability)) { return [ catId, { @@ -59,7 +69,7 @@ export function resolveQueueAuthorIntentByCatId(input: { carrierCapability, fallbackAt: now, fallbackReason: - carrierCapability.deliverySemantics === 'undeclared' + carrierCapability.activeInvocationGuidance === 'undeclared' ? 'carrier_capability_undeclared' : 'unsupported_carrier', } satisfies QueueAuthorIntent, diff --git a/packages/api/src/routes/messages.schema.ts b/packages/api/src/routes/messages.schema.ts index 7f4c680ea3..1a037148b9 100644 --- a/packages/api/src/routes/messages.schema.ts +++ b/packages/api/src/routes/messages.schema.ts @@ -114,6 +114,7 @@ export const sendMessageSchema = z contextAttachments: requestContextAttachmentsSchema, /** Legacy fallback only; preferred identity source is X-Cat-Cafe-User header. */ userId: z.string().min(1).max(100).optional(), + /** Explicit structured targets (for example the composer Steer member picker). */ mentions: z.array(catIdSchema()).optional(), threadId: z.string().min(1).max(100).optional(), /** Client-provided idempotency key (UUID). Optional — server generates one if absent. */ @@ -122,15 +123,14 @@ export const sendMessageSchema = z visibility: z.enum(['public', 'whisper']).optional(), /** F35: Whisper recipients. Required when visibility='whisper'. */ whisperTo: z.array(catIdSchema()).optional(), - /** F39: Delivery mode. undefined = smart default (queue when active, immediate otherwise). */ - deliveryMode: z.enum(['immediate', 'queue', 'force']).optional(), - /** F264: author-declared work disposition. Missing is server-default next_work. */ + /** F264: author-declared work disposition. Missing uses the server product default. */ messageDisposition: z.enum(['continue_current', 'next_work']).optional(), /** #699: ID of message being replied to (quote). */ replyTo: z.string().min(1).max(100).optional(), /** F294: transient selection plus exact cats; server persists only the canonical refs-only carrier. */ messageBundle: messageBundleForwardSchema.optional(), }) + .strict() .superRefine((data, ctx) => { if ( data.content.trim().length === 0 && diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 98677f6649..12e1f0f010 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -13,65 +13,29 @@ * POST 流程: 原子创建 InvocationRecord → 写入用户消息 → 回填 → reply 202 → background 执行 */ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { type CatId, - type CatRoutingError, catRegistry, + isCloudBridgeRecoveryV1, isCrossThreadProvenance, - type MessageBundleCarrierV1, type MessageContent, type MessageWorkDisposition, - type OutputCommitDecision, } from '@cat-cafe/shared'; -import type { SessionStore } from '@cat-cafe/shared/utils'; import multipart from '@fastify/multipart'; import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; -import { getDefaultCatId } from '../config/cat-config-loader.js'; -import { resolveFrontendBaseUrl } from '../config/frontend-origin.js'; -import type { WaitContinuationRetryCommitter } from '../domains/ball-custody/WaitContinuationRetryCommitter.js'; -import type { WaitContinuationRetryPreflight } from '../domains/ball-custody/WaitContinuationRetryPreflight.js'; -import { - type CollaborationContinuityCapsuleV1, - extractContinuityCapsuleFromAgentMessage, -} from '../domains/cats/services/agents/invocation/CollaborationContinuityCapsule.js'; -import { - ensureTerminalStatus, - RouteChainCompletionTracker, -} from '../domains/cats/services/agents/invocation/ensureTerminalStatus.js'; import { getThreadLiveInvocations } from '../domains/cats/services/agents/invocation/getThreadLiveInvocations.js'; -import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; +import { + type InvocationQueue, + queueEntryTargetCats, +} from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - isTerminalDispositionEvent, - PerCatTerminalDispositionCollector, -} from '../domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.js'; -import { createInitialQueuedMessageCustody } from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import type { - QueueProcessor, - SessionContinuationCoordinatorLike, -} from '../domains/cats/services/agents/invocation/QueueProcessor.js'; -import { requireInvocationRecordUpdate } from '../domains/cats/services/agents/invocation/require-invocation-record-update.js'; -import type { ConsumedContinuationToken } from '../domains/cats/services/agents/invocation/SessionContinuationCoordinator.js'; -import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; -import { - createA2ASlotTrackingBridge, - type PersistenceContext, - type RouteOptions, -} from '../domains/cats/services/agents/routing/route-helpers.js'; +import type { OwnerAuthProvenance } from '../domains/cats/services/agents/invocation/owner-auth-provenance.js'; +import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; import { resetStreak } from '../domains/cats/services/agents/routing/WorklistRegistry.js'; -import { - accumulateTextParts, - flattenTextParts, - flattenTurnTextParts, -} from '../domains/cats/services/agents/text-aggregation.js'; import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; -import { - MessageBundlePromptUnavailableError, - resolveMessageBundlePrompt, -} from '../domains/cats/services/context/MessageBundlePromptResolver.js'; import { type MessageSelectionAdmissionResult, MessageSelectionResolver, @@ -86,15 +50,12 @@ import type { GameDriver } from '../domains/cats/services/game/GameDriver.js'; import { GameOrchestrator } from '../domains/cats/services/game/GameOrchestrator.js'; import { WerewolfLobby } from '../domains/cats/services/game/werewolf/WerewolfLobby.js'; import type { AgentRouter } from '../domains/cats/services/index.js'; -import { getPushNotificationService } from '../domains/cats/services/push/PushNotificationService.js'; -import type { DeliveryCursorStore } from '../domains/cats/services/stores/ports/DeliveryCursorStore.js'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IDraftStore } from '../domains/cats/services/stores/ports/DraftStore.js'; import type { IGameStore } from '../domains/cats/services/stores/ports/GameStore.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; import { isTimelinePublished } from '../domains/cats/services/stores/ports/MessageStore.js'; -import { projectQueueReceipt } from '../domains/cats/services/stores/ports/queued-message-receipt.js'; -import type { ISummaryStore } from '../domains/cats/services/stores/ports/SummaryStore.js'; import { deriveAutoThreadTitle, type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { type ITurnExecutionStore, @@ -103,31 +64,14 @@ import { import { getTimelineOrderTime, isInternalNonQuotableParent, - isSystemUserMessage, resolveVisibleReplyParent, } from '../domains/cats/services/stores/visibility.js'; -import { mergeTokenUsage, type TokenUsage } from '../domains/cats/services/types.js'; -import { buildThreadDeepLink } from '../infrastructure/connectors/connector-command-helpers.js'; import { createModuleLogger } from '../infrastructure/logger.js'; -import { buildCancelMessages, type SocketManager } from '../infrastructure/websocket/index.js'; +import type { SocketManager } from '../infrastructure/websocket/index.js'; import { normalizeJsonUnicode } from '../utils/json-unicode.js'; import { getDefaultUploadDir } from '../utils/upload-paths.js'; -import { persistA2ARoutingMessage } from './a2a-routing-projection.js'; import { admitThreadParticipants } from './thread-participant-admission.js'; -/** F088 ISSUE-15: Minimal outbound delivery interface — avoids importing full OutboundDeliveryHook. */ -interface OutboundDeliveryHookLike { - deliver( - threadId: string, - content: string, - catId?: string, - richBlocks?: unknown[], - threadMeta?: { threadShortId: string; threadTitle?: string; deepLinkUrl?: string }, - origin?: string, - triggerMessageId?: string, - ): Promise; -} - type StoredRecovery = NonNullable['recovery']; /** Keep transcript paths and content hashes in the durable audit record, never in the browser DTO. */ @@ -140,38 +84,12 @@ function projectRecoveryForHistory(recovery: StoredRecovery) { }; } -function isConnectorDeliverable(decision: OutputCommitDecision | undefined): boolean { - return ( - decision === undefined || - decision.kind === 'committed_fresh' || - decision.kind === 'committed_degraded_unknown' || - decision.kind === 'published_with_unseen' - ); -} - -/** F088 ISSUE-15: Minimal streaming hook interface. */ -interface StreamingHookLike { - onStreamStart( - threadId: string, - catId?: string, - invocationId?: string, - senderHint?: { id: string; name?: string }, - ): Promise; - onStreamChunk(threadId: string, accumulatedText: string, invocationId?: string): Promise; - onStreamEnd(threadId: string, finalText: string, invocationId?: string): Promise; - onClosureCatchingUp?(threadId: string, catId: CatId, invocationId?: string): Promise; - onClosureBlocked?(threadId: string, catId: CatId, reason: string, invocationId?: string): Promise; - cleanupPlaceholders?(threadId: string, invocationId?: string): Promise; - /** F151: Signal adapters that an invocation's delivery batch is complete. */ - notifyDeliveryBatchDone?(threadId: string, chainDone: boolean): Promise; -} - -import { normalizeErrorMessage } from '../utils/normalize-error.js'; import { emitQueueUpdated, enrichQueueEntries } from '../utils/queue-enrichment.js'; import { resolveStrictUserId, resolveUserId } from '../utils/request-identity.js'; import { buildGameSeats, parseGameCommand, sanitizeCatIds } from './game-command-interceptor.js'; import type { HoldBallCancelDeps } from './hold-ball-cancel.js'; import { cancelPendingHoldsForThread } from './hold-ball-cancel.js'; +import { persistHoldTerminalVisibility } from './hold-ball-terminal-visibility.js'; import { resolveFreshnessCarrierCapabilityOrUndeclared, resolveMessageDispositionForAdmission, @@ -180,21 +98,6 @@ import { import { buildMessageContentBlocks, type SendMessageInput, sendMessageSchema } from './messages.schema.js'; import { parseMultipart } from './parse-multipart.js'; -const STREAM_START_TIMEOUT_MS = 5_000; -const INVOCATION_STARTUP_WATCHDOG_MS = 180_000; -const QUEUE_COMPLETION_WATCHDOG_MS = 5_000; - -function currentRetryableAttemptId(message: StoredMessage, targetCatId: string): string | undefined { - const target = message.queueCustody - ? projectQueueReceipt(message.queueCustody).targets.find((candidate) => candidate.catId === targetCatId) - : undefined; - const latest = target?.attempts?.at(-1); - if (target?.state !== 'failed' || target.retryable === false || !latest) return undefined; - return latest.state === 'failed' || (latest.state === 'cancelled' && latest.terminalReason === 'invocation_cancelled') - ? latest.id - : undefined; -} - type ResolvedBundleAdmission = Extract; function buildMessageBundleSummary(admission: ResolvedBundleAdmission): string { @@ -239,11 +142,6 @@ function bundleAdmissionErrorMessage(reason: MessageBundleAdmissionFailureReason * Dependencies injected via Fastify plugin options. * socketManager is injected to avoid circular import from index.ts. */ -/** F194 Phase Z3 (KD-23): process-singleton in-memory map tracking routeExecution chain - * completion signals for parent recordStore invocations. Producer sets pending/succeeded/failed; - * background async finally reads this to terminalize records that escaped explicit terminal write. */ -const routeChainTracker = new RouteChainCompletionTracker(); - export interface MessagesRoutesOptions { /** Shared owner-preference root. Optional test harnesses retain product-default behavior. */ projectRoot?: string; @@ -251,8 +149,6 @@ export interface MessagesRoutesOptions { messageStore: IMessageStore; socketManager: SocketManager; router: AgentRouter; - sessionStore?: SessionStore; - deliveryCursorStore?: DeliveryCursorStore; threadStore?: IThreadStore; uploadDir?: string; invocationTracker?: InvocationTracker; @@ -260,36 +156,23 @@ export interface MessagesRoutesOptions { /** Durable per-child lifecycle truth used to bridge tracker/draft handoff gaps. */ turnExecutionStore?: Pick; - summaryStore?: ISummaryStore; /** #80: Streaming draft store for F5 recovery */ draftStore?: IDraftStore; - /** F39: Message queue for delivery-mode routing */ + /** Canonical durable ingress for every normal user message. */ invocationQueue?: InvocationQueue; - /** F39: Queue processor for auto-dequeue on invocation complete */ + /** Single event-driven admission and execution coordinator. */ queueProcessor?: QueueProcessor; - /** Gate 5: read-only canonical authority check before any retry mutation. */ - retryAuthorityPreflight?: Pick; - /** Gate 5: atomically bind current canonical authority to the custody attempt commit. */ - retryAuthorityCommitter?: Pick; /** ADR-042: canonical supplement truth used to hydrate original-bubble status on F5/history reads. */ freshnessClosureStore?: Pick; - /** F224: Shared continuation lifecycle coordinator for direct immediate invocations. */ - sessionContinuationCoordinator?: SessionContinuationCoordinatorLike; - /** Test/diagnostic override for releasing invocations that never produce a provider/session event. */ - invocationStartupWatchdogMs?: number; - /** Bounded fallback for queue drain notification when terminal bookkeeping stalls. */ - queueCompletionWatchdogMs?: number; /** F101: Game store for /game command interception */ gameStore?: IGameStore; /** F101: Injectable auto-player for lifecycle-safe teardown in tests/routes */ autoPlayer?: Pick; - /** F233 PR3: ball-custody event sink for zombie reconciliation side effects. */ - /** F088 ISSUE-15: Outbound delivery hook for connector platforms (late-bound after gateway bootstrap) */ - outboundHook?: OutboundDeliveryHookLike; - /** F088 ISSUE-15: Streaming hook for connector platforms (late-bound after gateway bootstrap) */ - streamingHook?: StreamingHookLike; /** F167 Phase J: deps for auto-cancelling pending hold-ball tasks on user message */ - holdBallCancelDeps?: HoldBallCancelDeps; + holdBallCancelDeps?: HoldBallCancelDeps & { + messageStore?: IMessageStore; + socketManager?: SocketManager; + }; /** F192 Phase G AC-G12 / F227 归一: callback when magic words detected in a user * message. messageId is the stored user-message id — the Event Memory teleport * coordinate. */ @@ -305,61 +188,10 @@ export interface MessagesRoutesOptions { const log = createModuleLogger('routes/messages'); -async function acquireRouteExecutionOwner( - opts: Pick, - threadId: string, - targetCats: string[], - userId: string, - options: { - mode: 'non_preemptive' | 'replacement'; - executionId?: string; - onOwnershipValidated?: () => void; - }, -): Promise { - const coordinated = await opts.queueProcessor?.acquireExternalExecution?.(threadId, targetCats, userId, options); - if (coordinated !== undefined) return coordinated; - const tracker = opts.invocationTracker; - if (!tracker) return undefined; - if (options.mode === 'non_preemptive') { - return tracker.tryStartThreadAll(threadId, targetCats, userId, options.executionId); - } - const getUserId = tracker.getUserId?.bind(tracker); - if (getUserId && targetCats.some((catId) => tracker.has(threadId, catId) && getUserId(threadId, catId) !== userId)) { - return null; - } - options.onOwnershipValidated?.(); - return tracker.startAll(threadId, targetCats, userId, options.executionId); -} - -async function shouldEnqueueDirectContinuation( - capsule: CollaborationContinuityCapsuleV1, - userId: string, - coordinator?: SessionContinuationCoordinatorLike, -): Promise { - if (!coordinator?.resolveSessionStrategy) return true; - try { - const strategy = await coordinator.resolveSessionStrategy(capsule.threadId, capsule.catId, userId); - if (strategy === 'reborn') { - log.info( - { threadId: capsule.threadId, catId: capsule.catId }, - '[messages] F224: reborn session — skipping continuation enqueue', - ); - return false; - } - return true; - } catch (err) { - log.warn( - { err, threadId: capsule.threadId, catId: capsule.catId }, - '[messages] F224: resolveSessionStrategy failed for continuation enqueue, defaulting to enqueue', - ); - return true; - } -} - /** * F192 Phase G AC-G12: detect magic words in user message content. * Best-effort, fire-and-forget — failures are silently swallowed. - * Called from both queued and immediate message paths. + * Called only after the durable Queue source record exists. */ async function tryDetectMagicWords( content: string | null | undefined, @@ -395,34 +227,39 @@ async function tryDetectMagicWords( } } -/** - * F-invocation-stale-recovery P1-2: Format routing_warnings for user-visible system_info broadcast. - * Mirrors the pattern in callbacks.ts buildPostMessageRoutingMessage. - */ -function formatRoutingWarnings(warnings: CatRoutingError[]): string { - const parts: string[] = []; - for (const w of warnings) { - if (w.kind === 'cat_disabled') { - const alts = w.alternatives - .slice(0, 2) - .map((a) => a.mention) - .join('、'); - parts.push(`@${w.catId} 已停用,已跳过${alts ? `(可用替代:${alts})` : ''}。`); - } else if (w.kind === 'target_not_in_thread') { - parts.push(`@${w.catId} 不在目标 thread (${w.threadId}) 的参与者列表中,请确认 threadId 是否正确。`); - } else if (w.kind === 'suppressed_by_terminal_ack') { - parts.push(`${w.droppedMentions.map((id) => `@${id}`).join('、')} 因 terminal ACK 已记录但未触发新 invocation。`); - } else { - parts.push(`${w.mention} 不存在,已跳过。`); - } - } - return parts.join(' '); -} - -export function tryAutoCancelPendingHolds(threadId: string, deps: HoldBallCancelDeps | undefined): void { +export async function tryAutoCancelPendingHolds( + threadId: string, + deps: + | (HoldBallCancelDeps & { + messageStore?: IMessageStore; + socketManager?: SocketManager; + }) + | undefined, +): Promise { if (!deps) return; try { const cancelled = cancelPendingHoldsForThread(threadId, deps); + if (cancelled.length > 0 && deps.messageStore && deps.socketManager) { + for (const task of cancelled) { + const userId = typeof task.params.triggerUserId === 'string' ? task.params.triggerUserId : null; + const catId = task.createdBy.startsWith('hold-ball:') ? task.createdBy.slice('hold-ball:'.length) : null; + if (!userId || !catId) { + log.error({ threadId, taskId: task.id }, 'auto-retired hold lacks canonical owner identity'); + continue; + } + await persistHoldTerminalVisibility( + { messageStore: deps.messageStore, socketManager: deps.socketManager }, + { + taskId: task.id, + threadId, + userId, + catId, + outcome: 'retired_by_user_message', + content: `🏓 ${catId} 持球已因新的用户消息结束`, + }, + ); + } + } if (cancelled.length > 0) { log.info( { threadId, cancelledCount: cancelled.length, taskIds: cancelled.map((t) => t.id) }, @@ -441,25 +278,36 @@ const getMessagesSchema = z.object({ threadId: z.string().min(1).max(100).optional(), }); -const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB -const MAX_FILES = 5; +const cloudDeliveryRetrySchema = z.object({ + attemptId: z.string().min(1).max(512), +}); -const DECISION_NOTIFICATION_RE = /\b(review|lgtm|merge|pr)\b/i; - -export function shouldMarkDecisionNotification(content: string): boolean { - const lower = content.toLowerCase(); - return ( - DECISION_NOTIFICATION_RE.test(content) || - content.includes('合入') || - content.includes('审批') || - content.includes('批准') || - content.includes('决策') || - content.includes('请确认') || - content.includes('是否允许') || - lower.includes('can merge') - ); +function cloudDeliveryRetryIdempotencyKey(sourceMessageId: string, targetCatId: string, attemptId: string): string { + const digest = createHash('sha256').update(`${sourceMessageId}\0${targetCatId}\0${attemptId}`).digest('hex'); + return `cloud-delivery-retry:v1:${digest}`; +} + +function hasExactCloudDeliveryRecoveryNotice( + messages: readonly StoredMessage[], + sourceMessageId: string, + targetCatId: string, + attemptId: string, +): boolean { + return messages.some((message) => { + if (message.replyTo !== sourceMessageId || message.source?.connector !== 'cloud-bridge-status') return false; + const recovery = message.source.meta?.cloudBridgeRecovery; + return ( + isCloudBridgeRecoveryV1(recovery) && + recovery.sourceMessageId === sourceMessageId && + recovery.targetCatId === targetCatId && + recovery.dispatchInvocationId === attemptId + ); + }); } +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_FILES = 5; + export const messagesRoutes: FastifyPluginAsync = async (app, opts) => { const uploadDir = getDefaultUploadDir(opts.uploadDir ?? process.env.UPLOAD_DIR); const turnExecutionStore = opts.turnExecutionStore; @@ -496,151 +344,6 @@ export const messagesRoutes: FastifyPluginAsync = async ( }); } - /** - * F247: hydrate the optimistic-concurrency fence after the connector notice - * arrives. The read is owner-scoped and runs the same transient authority - * preflight as the mutation; it never creates or advances Queue custody. - */ - app.get<{ Params: { messageId: string; targetCatId: string } }>( - '/api/messages/:messageId/queue-targets/:targetCatId/retry-authority', - async (request, reply) => { - const userId = resolveUserId(request, { defaultUserId: 'default-user' }); - if (!userId) { - reply.status(401); - return { error: 'Identity required (session cookie or X-Cat-Cafe-User header)' }; - } - const retryAuthorityPreflight = opts.retryAuthorityPreflight; - if (!retryAuthorityPreflight) { - reply.status(503); - return { error: 'Queue retry is temporarily unavailable', code: 'QUEUE_RETRY_UNAVAILABLE' }; - } - const message = await opts.messageStore.getById(request.params.messageId); - if (!message || message.userId !== userId || !message.queueCustody) { - reply.status(404); - return { error: 'Queued message was not found', code: 'QUEUE_MESSAGE_NOT_FOUND' }; - } - const authority = await retryAuthorityPreflight.preflight({ - message, - requestingUserId: userId, - targetCatId: request.params.targetCatId, - }); - if (!authority.ok) { - reply.status(409); - return { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: authority.reason, - }; - } - const attemptId = currentRetryableAttemptId(message, request.params.targetCatId); - if (!attemptId) { - reply.status(409); - const target = projectQueueReceipt(message.queueCustody).targets.find( - (candidate) => candidate.catId === request.params.targetCatId, - ); - return { - error: 'This target is no longer retryable', - code: 'QUEUE_TARGET_NOT_RETRYABLE', - targetState: target?.attempts?.at(-1)?.state, - }; - } - return { attemptId }; - }, - ); - - /** - * F1308: retry one visible failed target without cloning or re-sending the - * authored message. `attemptId` is the optimistic-concurrency fence: once a - * retry is accepted, a second click still naming the old failed attempt gets - * a conflict instead of a second execution. - */ - app.post<{ Params: { messageId: string; targetCatId: string }; Body: { attemptId?: unknown } }>( - '/api/messages/:messageId/queue-targets/:targetCatId/retry', - async (request, reply) => { - const userId = resolveUserId(request, { defaultUserId: 'default-user' }); - if (!userId) { - reply.status(401); - return { error: 'Identity required (session cookie or X-Cat-Cafe-User header)' }; - } - const attemptId = request.body?.attemptId; - if (typeof attemptId !== 'string' || attemptId.length === 0) { - reply.status(400); - return { error: 'attemptId is required' }; - } - const retryAuthorityPreflight = opts.retryAuthorityPreflight; - const retryAuthorityCommitter = opts.retryAuthorityCommitter; - if (!opts.invocationQueue || !opts.queueProcessor || !retryAuthorityPreflight || !retryAuthorityCommitter) { - reply.status(503); - return { error: 'Queue retry is temporarily unavailable', code: 'QUEUE_RETRY_UNAVAILABLE' }; - } - const message = await opts.messageStore.getById(request.params.messageId); - if (!message || message.userId !== userId || !message.queueCustody) { - reply.status(404); - return { error: 'Queued message was not found', code: 'QUEUE_MESSAGE_NOT_FOUND' }; - } - const authority = await retryAuthorityPreflight.preflight({ - message, - requestingUserId: userId, - targetCatId: request.params.targetCatId, - }); - if (!authority.ok) { - reply.status(409); - return { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: authority.reason, - }; - } - const targetCarrier = message.queueCustody.carrierByTargetCatId?.[request.params.targetCatId]; - const carrierEntryId = targetCarrier?.entryId ?? message.queueCustody.entryId; - const carrier = targetCarrier - ? opts.invocationQueue.getEntrySnapshotForUserById(userId, carrierEntryId) - : undefined; - if (targetCarrier && !carrier) { - reply.status(409); - return { error: 'This target no longer has a retryable delivery carrier', code: 'QUEUE_TARGET_NOT_RETRYABLE' }; - } - const carrierThreadId = carrier?.threadId ?? message.threadId; - const result = await opts.queueProcessor.retryFailedTarget( - carrierThreadId, - userId, - carrierEntryId, - request.params.targetCatId, - attemptId, - (transitions) => - retryAuthorityCommitter.commit({ - authorityMessageId: request.params.messageId, - requestingUserId: userId, - targetCatId: request.params.targetCatId, - transitions, - }), - ); - if (result.outcome === 'unavailable') { - reply.status(503); - return { error: 'Queue retry is temporarily unavailable', code: 'QUEUE_RETRY_UNAVAILABLE' }; - } - if (result.outcome === 'authority_stale') { - reply.status(409); - return { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: result.reason, - }; - } - if (result.outcome !== 'retried') { - reply.status(409); - return { error: 'This target is no longer retryable', code: 'QUEUE_TARGET_NOT_RETRYABLE' }; - } - reply.status(202); - return { - status: 'retry_queued', - entryId: carrierEntryId, - targetCatId: request.params.targetCatId, - attemptId: result.attemptId, - }; - }, - ); - // POST /api/messages - 发送消息(WebSocket 广播) app.post('/api/messages', async (request, reply) => { let content: string; @@ -652,9 +355,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( let whisperVisibility: 'whisper' | undefined; let whisperRecipients: readonly CatId[] | undefined; - // F39: Delivery mode - let deliveryMode: 'immediate' | 'queue' | 'force' | undefined; let messageDisposition: MessageWorkDisposition | undefined; + let explicitMentionTargetCats: readonly CatId[] | undefined; // #699: Reply-to (quote) reference let replyTo: string | undefined; @@ -676,11 +378,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( whisperVisibility = 'whisper'; whisperRecipients = parsed.whisperTo as CatId[]; } - // F39: Extract deliveryMode from multipart - if (parsed.deliveryMode) { - deliveryMode = parsed.deliveryMode; - } messageDisposition = parsed.messageDisposition; + explicitMentionTargetCats = parsed.mentions?.length ? (parsed.mentions as CatId[]) : undefined; // #699: Extract replyTo from multipart if (parsed.replyTo) { replyTo = parsed.replyTo; @@ -696,8 +395,10 @@ export const messagesRoutes: FastifyPluginAsync = async ( if (parseResult.data.contextAttachments?.length) { contentBlocks = buildMessageContentBlocks(content, parseResult.data.contextAttachments); } - deliveryMode = parseResult.data.deliveryMode; messageDisposition = parseResult.data.messageDisposition; + explicitMentionTargetCats = parseResult.data.mentions?.length + ? (parseResult.data.mentions as CatId[]) + : undefined; // F35: Extract whisper fields from parsed body if (parseResult.data.visibility === 'whisper') { whisperVisibility = 'whisper'; @@ -716,10 +417,24 @@ export const messagesRoutes: FastifyPluginAsync = async ( reply.status(401); return { error: 'Identity required (session cookie or X-Cat-Cafe-User header)' }; } - const ownerAuthProvenance = resolveStrictUserId(request) === userId ? 'strict' : 'compatibility_fallback'; + const ownerAuthProvenance: OwnerAuthProvenance = + resolveStrictUserId(request) === userId ? 'strict' : 'compatibility_fallback'; // Default to 'default' thread for lobby (prevents global broadcast) const resolvedThreadId = threadId ?? 'default'; + // A retried no-mention send must retain the target that won the original + // atomic admission even if a newer reply has since changed the fallback. + // Queue remains the sole owner of this immutable admission fact; History is + // consulted only to locate the exact source record named by the idempotency key. + const replayedSource = + idempotencyKey && opts.invocationQueue + ? await opts.messageStore.getByIdempotencyKey(userId, resolvedThreadId, idempotencyKey) + : null; + const replayedQueueEntry = replayedSource + ? opts.invocationQueue + ?.list(resolvedThreadId, userId) + .find((entry) => entry.payload.sourceRecordId === replayedSource.id) + : undefined; let admittedMessageBundle: ResolvedBundleAdmission | undefined; let explicitBundleTargetCats: CatId[] | undefined; @@ -886,8 +601,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( // Store user message in the game thread const userMessage = await opts.messageStore.append({ + from: { kind: 'user', userId }, userId, - catId: null, content, mentions: [], timestamp: Date.now(), @@ -943,28 +658,48 @@ export const messagesRoutes: FastifyPluginAsync = async ( // ADR-008 S1: Pre-resolve targets + intent, persisting @mentions as participants log.debug({ threadId: resolvedThreadId, contentLen: content.length }, 'Resolving targets and intent'); const bundleRoutingTargetCats = explicitBundleTargetCats ?? []; - const routingResult: Awaited> = admittedMessageBundle + const explicitRoutingTargetCats = explicitMentionTargetCats + ? await router.resolveExplicitTargets(explicitMentionTargetCats, resolvedThreadId, { persist: false }) + : undefined; + if (explicitMentionTargetCats && explicitRoutingTargetCats?.length !== explicitMentionTargetCats.length) { + reply.status(400); + return { error: 'One or more selected members are unavailable', code: 'INVALID_EXPLICIT_TARGETS' }; + } + const structuredTargetCats = admittedMessageBundle ? bundleRoutingTargetCats : explicitRoutingTargetCats; + const routingResult: Awaited> = structuredTargetCats ? { - targetCats: bundleRoutingTargetCats, - intent: parseIntent('', bundleRoutingTargetCats.length), + targetCats: structuredTargetCats, + intent: parseIntent(admittedMessageBundle ? '' : content, structuredTargetCats.length), hasMentions: true, routing_warnings: [], } : await router.resolveTargetsAndIntent(content, resolvedThreadId, { persist: true, + allowFallback: false, }); - const { targetCats: resolvedTargetCats, intent, hasMentions, routing_warnings } = routingResult; + const { targetCats: resolvedTargetCats, intent, routing_warnings } = routingResult; // F35: When sending a whisper, override routing targets to only whisperTo recipients. // This prevents non-recipient cats from being invoked and seeing whisper content. - const targetCats = + const sourceTargetCats = whisperVisibility === 'whisper' && whisperRecipients?.length ? [...new Set(whisperRecipients)] : [...resolvedTargetCats]; - if (targetCats.length === 0) { - reply.status(400); - return { error: '没有可用的猫猫成员,请先在设置中添加一只猫猫', code: 'NO_TARGETS' }; - } - const primaryCat = targetCats[0] ?? 'unknown'; + // A user-visible source remains honest about whether the author wrote an @mention, + // while Queue custody must never defer the ordinary no-@ default to a later UI or + // execution guess. Resolve the canonical conversation fallback before the atomic + // source + Queue admission, then persist that member as the pending delivery target. + // Message Bundles and whispers already carry an exact caller-owned target set. + const targetCats: CatId[] = + !admittedMessageBundle && + whisperVisibility !== 'whisper' && + sourceTargetCats.length === 0 && + (routing_warnings?.length ?? 0) === 0 + ? replayedQueueEntry + ? (queueEntryTargetCats(replayedQueueEntry) as CatId[]) + : await router.resolveConversationTargetsAtAdmission([], resolvedThreadId) + : sourceTargetCats; + const visibleRoutingWarnings = + whisperVisibility !== 'whisper' && routing_warnings?.length ? [...routing_warnings] : []; // Sidebar participant presence is canonical ThreadStore truth, not a // side-effect of the first CLI event. Persist every resolved target (the @@ -972,12 +707,13 @@ export const messagesRoutes: FastifyPluginAsync = async ( // intentionally idempotent) and publish the existing `thread_updated` // event through the user's always-joined room. This makes an unopened // thread update immediately without inventing another event or room. - const publishSidebarParticipants = async () => { + const publishSidebarParticipants = async (participantCats: readonly CatId[] = targetCats) => { + if (participantCats.length === 0) return; if (opts.threadStore?.addParticipants) { await admitThreadParticipants({ userId, threadId: resolvedThreadId, - targetCats, + targetCats: participantCats, threadStore: opts.threadStore, socketManager: opts.socketManager, emitPolicy: 'always', @@ -986,7 +722,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( } opts.socketManager.emitToUser(userId, 'thread_updated', { threadId: resolvedThreadId, - participants: [...targetCats], + participants: [...participantCats], }); }; const publishAdmittedBundleParticipants = async () => { @@ -1004,108 +740,70 @@ export const messagesRoutes: FastifyPluginAsync = async ( // succeeds, so validation/queue-capacity failures leave no false sidebar state. if (!admittedMessageBundle) await publishSidebarParticipants(); - // F-invocation-stale-recovery P1-2: Surface routing_warnings when user's explicit @mention - // silently fell back (e.g., @kimi → cat_not_found → default cat, user sees no feedback). - // Non-whisper only (whisper targets are overridden above; warning is not meaningful there). - if (routing_warnings && routing_warnings.length > 0 && whisperVisibility !== 'whisper') { - const warningMsg = formatRoutingWarnings(routing_warnings); - opts.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: primaryCat, - // Use 'warning' type — recognized by system-info-visible.ts (reads parsed.message) - content: JSON.stringify({ - type: 'warning', - message: warningMsg, - }), - timestamp: Date.now(), - }, - resolvedThreadId, - ); - } - // Server-generated idempotency key if client didn't provide one const resolvedIdempotencyKey = idempotencyKey ?? randomUUID(); - const messageBundleWrite: { extra: { messageBundle: MessageBundleCarrierV1 } } | Record = - admittedMessageBundle ? { extra: { messageBundle: admittedMessageBundle.carrier } } : {}; - - // F39+F108B: Slot-aware delivery mode routing - // Whisper → check target cat's slot (side-dispatch to idle cat) - // Broadcast with explicit @mention → any target busy = queue (P1 review fix) - // Broadcast without @mention → thread-level check (any active → queue) - // #555: Cover the pre-start gap with QueueProcessor's live slot reservation. - // Queued leftovers are not an execution owner and cannot justify admitting - // another message behind a trigger that no longer exists. - const hasActive = (() => { - if (!opts.invocationTracker) { - return opts.queueProcessor?.hasActiveExecution?.(resolvedThreadId) ?? false; - } - if (whisperVisibility === 'whisper' && primaryCat !== 'unknown') { - return ( - opts.queueProcessor?.hasActiveExecutionForCat?.(resolvedThreadId, primaryCat) ?? - opts.invocationTracker.has(resolvedThreadId, primaryCat) - ); - } - if (hasMentions) { - return targetCats.some( - (cat) => - cat !== 'unknown' && - (opts.queueProcessor?.hasActiveExecutionForCat?.(resolvedThreadId, cat) ?? - opts.invocationTracker!.has(resolvedThreadId, cat)), - ); - } - return ( - opts.invocationTracker.has(resolvedThreadId) || - (opts.queueProcessor?.hasActiveExecution?.(resolvedThreadId) ?? false) - ); - })(); - const mode = deliveryMode ?? (hasActive ? 'queue' : 'immediate'); - const durableCloudAdmission = - mode !== 'force' && - targetCats.some((catId) => catRegistry.tryGet(catId)?.config.provider === 'openai-chatgpt-pro'); - log.debug({ threadId: resolvedThreadId, targetCats, intent: intent.intent, mode, hasActive }, 'Dispatch decision'); - - if (durableCloudAdmission) { - if (!opts.invocationQueue || !opts.queueProcessor) { - reply.status(503); - return { error: 'Cloud message recovery is temporarily unavailable', code: 'QUEUE_RETRY_UNAVAILABLE' }; - } - const existing = await opts.messageStore.getByIdempotencyKey(userId, resolvedThreadId, resolvedIdempotencyKey); - if (existing) { - reply.status(202); - return { status: 'duplicate', userMessageId: existing.id }; - } - } + const sourcePayloadExtra: NonNullable = { + ...(admittedMessageBundle ? { messageBundle: admittedMessageBundle.carrier } : {}), + ...(visibleRoutingWarnings.length > 0 ? { routingWarnings: visibleRoutingWarnings } : {}), + }; + const sourcePayloadWrite: { extra: NonNullable } | Record = + Object.keys(sourcePayloadExtra).length > 0 ? { extra: sourcePayloadExtra } : {}; - if (admittedMessageBundle && mode !== 'queue' && !opts.invocationRecordStore) { - reply.status(503); - return { error: 'Message Bundle immediate routing is unavailable', code: 'MESSAGE_BUNDLE_UNAVAILABLE' }; - } + log.debug({ threadId: resolvedThreadId, targetCats, intent: intent.intent }, 'Queue ingress accepted'); - if (((mode === 'queue' && hasActive) || durableCloudAdmission) && opts.invocationQueue) { - // ① Enqueue first (sync, capacity gatekeeper) — messageId is null at this point - const enqueueResult = opts.invocationQueue.enqueue({ + if (opts.invocationQueue) { + const requestedDisposition = resolveMessageDispositionForAdmission({ + explicit: messageDisposition, + projectRoot: opts.projectRoot, + threadId: resolvedThreadId, + }); + const queueInput = { + from: { kind: 'user' as const, userId }, threadId: resolvedThreadId, userId, + kind: 'conversation_input' as const, ownerAuthProvenance, idempotencyKey: resolvedIdempotencyKey, content, - source: 'user', targetCats, - authorIntentByCatId: resolveQueueAuthorIntentByCatId({ - targetCats, - requested: resolveMessageDispositionForAdmission({ - explicit: messageDisposition, - projectRoot: opts.projectRoot, + ...(replayedQueueEntry?.payload.routingWarnings + ? { routingWarnings: [...replayedQueueEntry.payload.routingWarnings] } + : visibleRoutingWarnings.length > 0 + ? { routingWarnings: visibleRoutingWarnings } + : {}), + authorIntentByCatId: + replayedQueueEntry?.delivery.authorIntentByTarget ?? + resolveQueueAuthorIntentByCatId({ + targetCats, + requested: requestedDisposition, threadId: resolvedThreadId, + userId, + invocationTracker: opts.invocationTracker, + resolveCarrierCapability: (catId) => resolveFreshnessCarrierCapabilityOrUndeclared(opts.router, catId), }), - threadId: resolvedThreadId, - userId, - invocationTracker: opts.invocationTracker, - resolveCarrierCapability: (catId) => resolveFreshnessCarrierCapabilityOrUndeclared(opts.router, catId), - }), intent: intent.intent, - }); + }; + const enqueueResult = await opts.invocationQueue.appendAndEnqueueDurable( + opts.messageStore, + { + from: queueInput.from, + userId, + content, + // Routing fallback is server-owned metadata, not an @mention the user wrote. + mentions: sourceTargetCats, + timestamp: Date.now(), + threadId: resolvedThreadId, + idempotencyKey: resolvedIdempotencyKey, + deliveryStatus: 'queued', + ...(contentBlocks ? { contentBlocks } : {}), + ...(whisperVisibility && whisperRecipients + ? { visibility: whisperVisibility, whisperTo: whisperRecipients } + : {}), + ...(replyTo ? { replyTo } : {}), + ...sourcePayloadWrite, + }, + queueInput, + ); // Queue full → 429, no message written (no ghost message) if (enqueueResult.outcome === 'full') { @@ -1127,1230 +825,210 @@ export const messagesRoutes: FastifyPluginAsync = async ( }; } - let storedUserMessageId: string | null = enqueueResult.entry?.messageId ?? null; - - // ② Persist queued user work. F264 publishes it to the owner's timeline; - // deliveryStatus still keeps it out of cat context/mentions until dequeue. - // If enqueue returned a deduped active entry, reuse existing messageId and skip append. - if (!enqueueResult.deduped) { - try { - if (!enqueueResult.entry) throw new Error('successful queue admission is missing its entry'); - const userMessage = await opts.messageStore.append({ - userId, - catId: null, - content, - mentions: targetCats, - timestamp: Date.now(), - threadId: resolvedThreadId, - idempotencyKey: resolvedIdempotencyKey, - deliveryStatus: 'queued', // Browser-visible, but not cat-context delivered. - queueCustody: createInitialQueuedMessageCustody(enqueueResult.entry), - ...(contentBlocks ? { contentBlocks } : {}), - ...(whisperVisibility && whisperRecipients - ? { visibility: whisperVisibility, whisperTo: whisperRecipients } - : {}), - ...(replyTo ? { replyTo } : {}), - ...messageBundleWrite, - }); - storedUserMessageId = userMessage.id; + const storedUserMessageId = enqueueResult.message?.id ?? null; + if (!enqueueResult.deduped && storedUserMessageId) { + // F192 Phase G AC-G12 / F227: detect magic words after the atomic admission commits. + void tryDetectMagicWords( + content, + resolvedThreadId, + targetCats, + storedUserMessageId, + userId, + opts.onMagicWordDetected, + ); + } - // F192 Phase G AC-G12 / F227: detect magic words → Event Memory (queued path) - void tryDetectMagicWords( - content, - resolvedThreadId, - targetCats, - storedUserMessageId, - userId, - opts.onMagicWordDetected, - ); + if (admittedMessageBundle) await publishAdmittedBundleParticipants(); - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.backfillMessageId(resolvedThreadId, userId, queueEntryId, userMessage.id); - } - } catch (err) { - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.rollbackEnqueue(resolvedThreadId, userId, queueEntryId); + const admittedEntries = enqueueResult.entries ?? (enqueueResult.entry ? [enqueueResult.entry] : []); + if (requestedDisposition === 'continue_current' && opts.queueProcessor?.tryAutoAppendExactEntry) { + for (const admittedEntry of admittedEntries) { + if (admittedEntry.targets.length === 0) continue; + for (const targetCatId of admittedEntry.targets) { + const append = await opts.queueProcessor.tryAutoAppendExactEntry({ + threadId: resolvedThreadId, + userId, + entryId: admittedEntry.id, + targetCatId, + }); + if (append.outcome === 'rejected') { + await opts.invocationQueue.fallbackQueuedAuthorIntentDurable( + resolvedThreadId, + userId, + admittedEntry.id, + targetCatId, + 'parent_terminal_before_exposure', + ); + } } - throw err; } } - - if (admittedMessageBundle) await publishAdmittedBundleParticipants(); + const admittedInvocationQueue = opts.invocationQueue; + const admittedEntryStillQueued = + admittedEntries.length === 0 || + admittedEntries.some( + (admittedEntry) => + admittedInvocationQueue.getEntrySnapshot(resolvedThreadId, userId, admittedEntry.id) !== null, + ); // Emit queue update to this user only (privacy: scopeKey isolation) - await emitQueueUpdated( - opts.socketManager, - userId, - resolvedThreadId, - opts.invocationQueue.list(resolvedThreadId, userId), - opts.messageStore, - enqueueResult.outcome, - ); - - tryAutoCancelPendingHolds(resolvedThreadId, opts.holdBallCancelDeps); - - // The admitting request owns kickoff only after its source + custody commit. - // A concurrent replay may observe an entry whose messageId is still null. - if (durableCloudAdmission && !hasActive && !enqueueResult.deduped && storedUserMessageId && enqueueResult.entry) { - void opts.queueProcessor?.progressOwnedCarrier(enqueueResult.entry, primaryCat).catch((err) => { - log.error({ err, threadId: resolvedThreadId }, 'Durable cloud message remains queued after dispatch failure'); - }); + // appendExactEntry owns its own committed projection. Keep the generic + // enqueue event only when custody remains in Queue. + if (admittedEntryStillQueued) { + await emitQueueUpdated( + opts.socketManager, + userId, + resolvedThreadId, + opts.invocationQueue.list(resolvedThreadId, userId), + opts.messageStore, + enqueueResult.outcome, + ); } + await tryAutoCancelPendingHolds(resolvedThreadId, opts.holdBallCancelDeps); + void opts.queueProcessor?.requestDrain(resolvedThreadId); + reply.status(202); return { status: 'queued', queuePosition: enqueueResult.queuePosition, entryId: enqueueResult.entry?.id, + entries: admittedEntries.flatMap((entry) => + queueEntryTargetCats(entry).map((targetCatId) => ({ entryId: entry.id, targetCatId })), + ), merged: false, ...(storedUserMessageId ? { userMessageId: storedUserMessageId } : {}), ...(admittedMessageBundle && storedUserMessageId ? { messageBundleId: storedUserMessageId } : {}), }; } + }); - let cancelledCatIds: string[] = []; - const cancelValidatedForceOwner = () => { - if (mode !== 'force') return; - // F-parallel-cancel: once the joint owner fence has accepted the complete target set, - // preempt only the invocation batch(es) anchored by those targets. Keeping this inside - // the synchronous acquisition transaction prevents a mixed-user rejection from canceling - // the requester's subset before another target fails ownership validation. This must not - // depend on the pre-create hasActive snapshot: a same-user batch can arrive during create(). - cancelledCatIds = - opts.invocationTracker?.cancelInvocation?.(resolvedThreadId, targetCats, userId, 'preempted') ?? []; - }; + // Retry is a fresh source/attempt. The failed History delivery remains immutable; + // Queue receives only the new pending work and never reopens a terminal row. + app.post<{ Params: { sourceMessageId: string; targetCatId: string } }>( + '/api/messages/:sourceMessageId/delivery-targets/:targetCatId/retry', + async (request, reply) => { + const parsed = cloudDeliveryRetrySchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Retry 请求格式无效', code: 'INVALID_DELIVERY_RETRY_REQUEST' }; + } + if (!opts.invocationQueue || !opts.queueProcessor) { + reply.status(503); + return { error: '消息投递暂不可用', code: 'DELIVERY_RETRY_UNAVAILABLE' }; + } - const publishValidatedForceCancellation = () => { - if (cancelledCatIds.length === 0) return; - for (const m of buildCancelMessages({ cancelled: true, catIds: cancelledCatIds })) { - opts.socketManager.broadcastAgentMessage(m, resolvedThreadId); + const userId = resolveUserId(request, { defaultUserId: 'default-user' }); + if (!userId) { + reply.status(401); + return { error: 'Identity required', code: 'IDENTITY_REQUIRED' }; } - // QueueProcessor already supersedes pause state for requested targets during successful - // acquisition. Clear any additional same-batch siblings returned by cancelInvocation too. - for (const c of cancelledCatIds) opts.queueProcessor?.clearPause(resolvedThreadId, c); - }; - // ① F122 A.1: Occupy tracker slot BEFORE creating InvocationRecord to close TOCTOU window. - // Non-force paths use tryStartThread (non-preemptive). Force validates the complete target set, - // cancels the accepted old owner, and installs the replacement in one synchronous acquisition below. - if (opts.invocationRecordStore) { - let controller: AbortController | undefined; - - if (mode !== 'force' && opts.invocationTracker) { - // F122 AC-A8: Atomic thread-level busy gate + slot registration. - // If thread became busy since initial has() check at line 306, degrade to queue. - const tryResult = await acquireRouteExecutionOwner(opts, resolvedThreadId, targetCats, userId, { - mode: 'non_preemptive', - }); - if (tryResult === null) { - // TOCTOU: thread became busy between has() and here — degrade to queue - if (opts.invocationQueue) { - const enqueueResult = opts.invocationQueue.enqueue({ - threadId: resolvedThreadId, - userId, - ownerAuthProvenance, - idempotencyKey: resolvedIdempotencyKey, - content, - source: 'user', - targetCats, - authorIntentByCatId: resolveQueueAuthorIntentByCatId({ - targetCats, - requested: resolveMessageDispositionForAdmission({ - explicit: messageDisposition, - projectRoot: opts.projectRoot, - threadId: resolvedThreadId, - }), - threadId: resolvedThreadId, - userId, - invocationTracker: opts.invocationTracker, - resolveCarrierCapability: (catId) => resolveFreshnessCarrierCapabilityOrUndeclared(opts.router, catId), - }), - intent: intent.intent, - }); - if (enqueueResult.outcome === 'full') { - const toctouFullQueue = await enrichQueueEntries( - opts.invocationQueue.list(resolvedThreadId, userId), - opts.messageStore, - ); - opts.socketManager.emitToUser(userId, 'queue_full_warning', { - threadId: resolvedThreadId, - source: 'user', - queueSize: opts.invocationQueue.size(resolvedThreadId, userId), - queue: toctouFullQueue, - }); - reply.status(429); - return { error: '消息队列已满', code: 'QUEUE_FULL' }; - } - // F122 R1-gpt52 P1-1: Wrap append+backfill in try/catch with rollback, - // matching original queue path (lines 340-374) to prevent ghost queue entries. - let toctouUserMessageId: string | null = enqueueResult.entry?.messageId ?? null; - if (!enqueueResult.deduped) { - try { - if (!enqueueResult.entry) throw new Error('successful queue admission is missing its entry'); - const toctouUserMessage = await opts.messageStore.append({ - userId, - catId: null, - content, - mentions: targetCats, - timestamp: Date.now(), - threadId: resolvedThreadId, - idempotencyKey: resolvedIdempotencyKey, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(enqueueResult.entry), - ...(contentBlocks ? { contentBlocks } : {}), - ...(whisperVisibility && whisperRecipients - ? { visibility: whisperVisibility, whisperTo: whisperRecipients } - : {}), - ...(replyTo ? { replyTo } : {}), - ...messageBundleWrite, - }); - toctouUserMessageId = toctouUserMessage.id; - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.backfillMessageId(resolvedThreadId, userId, queueEntryId, toctouUserMessage.id); - } - } catch (err) { - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.rollbackEnqueue(resolvedThreadId, userId, queueEntryId); - } - throw err; - } - } - if (admittedMessageBundle) await publishAdmittedBundleParticipants(); - await emitQueueUpdated( - opts.socketManager, - userId, - resolvedThreadId, - opts.invocationQueue.list(resolvedThreadId, userId), - opts.messageStore, - enqueueResult.outcome, - ); - tryAutoCancelPendingHolds(resolvedThreadId, opts.holdBallCancelDeps); - reply.status(202); - return { - status: 'queued', - queuePosition: enqueueResult.queuePosition, - entryId: enqueueResult.entry?.id, - merged: false, - ...(toctouUserMessageId ? { userMessageId: toctouUserMessageId } : {}), - ...(admittedMessageBundle && toctouUserMessageId ? { messageBundleId: toctouUserMessageId } : {}), - }; - } - // No queue available — thread is busy but we can't queue. Reject. - reply.status(409); - return { error: '猫猫正在忙', code: 'THREAD_BUSY' }; - } - controller = tryResult; + const { sourceMessageId, targetCatId } = request.params; + const source = await opts.messageStore.getById(sourceMessageId); + const sender = source ? messageFrom(source) : undefined; + if ( + !source || + source.deletedAt || + sender?.kind !== 'user' || + sender.userId !== userId || + !isTimelinePublished(source) + ) { + reply.status(404); + return { error: '原消息不存在或不可重试', code: 'DELIVERY_RETRY_SOURCE_NOT_FOUND' }; } - // F122 R1 P1: Wrap create/update/append in try/catch to release slot on error. - // The background coroutine has its own finally for normal completion, but if we - // throw before entering it, the slot would leak (thread stuck as "busy"). - let createResult: { outcome: string; invocationId: string }; - try { - createResult = await opts.invocationRecordStore.create({ - threadId: resolvedThreadId, - userId, - targetCats, - intent: intent.intent, - idempotencyKey: resolvedIdempotencyKey, - actionLeaseCarrier: { kind: 'none' }, - }); - } catch (createErr) { - // Release slots occupied by tryStartThreadAll — prevent "假忙" leak - if (controller) { - opts.invocationTracker?.completeAll(resolvedThreadId, targetCats, controller); - } - throw createErr; - } - - if (createResult.outcome === 'duplicate') { - // AC-A11: tryStartThreadAll succeeded but create returned duplicate — release slots - if (controller) { - opts.invocationTracker?.completeAll(resolvedThreadId, targetCats, controller); - } - const existingRecord = await opts.invocationRecordStore.get(createResult.invocationId); - reply.status(200); - return { - status: 'duplicate', - invocationId: createResult.invocationId, - ...(existingRecord?.userMessageId ? { userMessageId: existingRecord.userMessageId } : {}), - ...(admittedMessageBundle && existingRecord?.userMessageId - ? { messageBundleId: existingRecord.userMessageId } - : {}), - }; + const resolvedTargets = await router.resolveExplicitTargets([targetCatId], source.threadId, { persist: false }); + if (resolvedTargets.length !== 1 || resolvedTargets[0] !== targetCatId) { + reply.status(409); + return { error: '目标成员当前不可用', code: 'DELIVERY_RETRY_AUTHORITY_STALE' }; } - // Force path: the joint acquisition owns validation, scoped cancellation, and replacement install. - if (!controller) { - const acquiredController = await acquireRouteExecutionOwner(opts, resolvedThreadId, targetCats, userId, { - mode: 'replacement', - executionId: createResult.invocationId, - onOwnershipValidated: cancelValidatedForceOwner, - }); - if (acquiredController === null) { - await opts.invocationRecordStore.update(createResult.invocationId, { - status: 'canceled', - error: 'invocation_owner_changed_before_route_start', - }); - reply.status(409); - return { - error: 'Invocation owner changed before message routing could start', - code: 'INVOCATION_OWNER_CHANGED', - }; - } - controller = acquiredController; - publishValidatedForceCancellation(); - } else { - opts.invocationTracker?.bindExecutionId?.(resolvedThreadId, targetCats, controller, createResult.invocationId); + const threadMessages = await opts.messageStore.getByThread(source.threadId, 10_000, userId); + if (!hasExactCloudDeliveryRecoveryNotice(threadMessages, sourceMessageId, targetCatId, parsed.data.attemptId)) { + reply.status(409); + return { error: '原发送记录已经变化', code: 'DELIVERY_RETRY_AUTHORITY_STALE' }; } - // Race: thread entered deleting between isDeleting() and start() - if (controller?.signal.aborted) { - await opts.invocationRecordStore.update(createResult.invocationId, { - status: 'canceled', - }); + const idempotencyKey = cloudDeliveryRetryIdempotencyKey(sourceMessageId, targetCatId, parsed.data.attemptId); + const existingRetry = await opts.messageStore.getByIdempotencyKey(userId, source.threadId, idempotencyKey); + if (existingRetry) { reply.status(409); return { - error: '对话正在删除中', - detail: '请稍后重试,或新建一个对话继续', - code: 'THREAD_DELETING', + error: '这次发送已经重试过', + code: 'DELIVERY_RETRY_AUTHORITY_STALE', + retryMessageId: existingRetry.id, }; } - // F122 R1 P1 cont: wrap message write + update before background coroutine. - // If any of these throw, release the slot to prevent "假忙" leak. - let storedUserMessage: { id: string }; - try { - // F39: only publish force-cleared after the replacement owner is installed. An explicit - // acquisition refusal must have zero queue/UI side effects. - if (mode === 'force' && cancelledCatIds.length > 0 && opts.invocationQueue) { - await emitQueueUpdated( - opts.socketManager, - userId, - resolvedThreadId, - opts.invocationQueue.list(resolvedThreadId, userId), - opts.messageStore, - 'force_cleared', - ); - } - - // ② Write user message (decoupled from cat execution) - storedUserMessage = await opts.messageStore.append({ + const target = resolvedTargets[0]!; + const queueInput = { + from: { kind: 'user' as const, userId }, + threadId: source.threadId, + userId, + kind: 'conversation_input' as const, + ownerAuthProvenance: 'strict' as const, + idempotencyKey, + content: source.content, + targetCats: [target], + authorIntentByCatId: resolveQueueAuthorIntentByCatId({ + targetCats: [target], + requested: 'next_work', + threadId: source.threadId, userId, - catId: null, - content, - mentions: targetCats, - timestamp: Date.now(), - threadId: resolvedThreadId, - ...(contentBlocks ? { contentBlocks } : {}), - ...(whisperVisibility && whisperRecipients - ? { visibility: whisperVisibility, whisperTo: whisperRecipients } - : {}), - ...(replyTo ? { replyTo } : {}), - ...messageBundleWrite, - }); - - // ③ Backfill InvocationRecord.userMessageId - await opts.invocationRecordStore.update(createResult.invocationId, { - userMessageId: storedUserMessage.id, - }); - if (admittedMessageBundle) await publishAdmittedBundleParticipants(); - - // F192 Phase G AC-G12 / F227: detect magic words → Event Memory (immediate path) - void tryDetectMagicWords( - content, - resolvedThreadId, - targetCats, - storedUserMessage.id, + invocationTracker: opts.invocationTracker, + resolveCarrierCapability: (catId) => resolveFreshnessCarrierCapabilityOrUndeclared(opts.router, catId), + }), + intent: 'cloud_delivery_retry', + }; + const admitted = await opts.invocationQueue.appendAndEnqueueDurable( + opts.messageStore, + { + from: queueInput.from, userId, - opts.onMagicWordDetected, - ); - } catch (preExecErr) { - // Release slots — we haven't entered background coroutine yet - opts.invocationTracker?.completeAll(resolvedThreadId, targetCats, controller); - // Mark record as failed if it was created - try { - await opts.invocationRecordStore?.update(createResult.invocationId, { status: 'failed' }); - } catch { - /* best-effort cleanup */ - } - throw preExecErr; - } - - // ④ Reply with invocationId - reply.send({ - status: 'processing', - invocationId: createResult.invocationId, - userMessageId: storedUserMessage.id, - ...(admittedMessageBundle ? { messageBundleId: storedUserMessage.id } : {}), - timestamp: Date.now(), - }); - - tryAutoCancelPendingHolds(resolvedThreadId, opts.holdBallCancelDeps); - - // ⑤ Background: execute cat invocation via routeExecution - void (async () => { - const HEARTBEAT_INTERVAL_MS = 30_000; - const heartbeatInterval = setInterval(() => { - opts.socketManager.broadcastToRoom(`thread:${resolvedThreadId}`, 'heartbeat', { - threadId: resolvedThreadId, - timestamp: Date.now(), - }); - }, HEARTBEAT_INTERVAL_MS); - - // F39: Track final status for queue auto-dequeue - let finalStatus: 'succeeded' | 'failed' | 'canceled' | 'canceled_by_user' = 'failed'; - const terminalDispositions = new PerCatTerminalDispositionCollector({ - targetCatIds: targetCats, - isCanceled: (catId) => opts.invocationTracker?.getSlotState?.(resolvedThreadId, catId) === 'canceled', - }); - - // F088 ISSUE-15: Hoisted so catch/abort branches can clean up streaming sessions - let streamStartPromise: Promise | undefined; - let firstRouteEventSeen = false; - let startupWatchdogFired = false; - let startupTimeoutFailureRecorded = false; - let queueCompletionNotified = false; - - const notifyQueueCompletion = (status: 'succeeded' | 'failed' | 'canceled' | 'canceled_by_user') => { - if (queueCompletionNotified) return; - queueCompletionNotified = true; - const completedCatIds = status === 'succeeded' ? terminalDispositions.getSuccessfulCatIds() : []; - opts.queueProcessor - ?.onInvocationComplete( - resolvedThreadId, - primaryCat, - status, - createResult.invocationId, - completedCatIds, - false, - terminalDispositions.getTerminalInvocationIdByCatId(), - [], - terminalDispositions.getTerminalConsumptionByInvocationId(), - ) - .catch((err) => { - log.error( - { err, threadId: resolvedThreadId, catId: primaryCat, finalStatus: status }, - '[messages] onInvocationComplete failed — queued messages may be stuck (#595)', - ); - }); - }; - - // F148 fix: Hoisted so abort/catch branches can ack completed cats' cursors - const cursorBoundaries = new Map(); - const continuationCapsules = new Map(); - let consumedContinuation: ConsumedContinuationToken | undefined; - - // F194 Phase Z3 (AC-Z3): mark chain start for finally fallback. routeExecution may - // hang / silently exit / swallow exceptions and never reach explicit terminal write - // (root cause of bubble-still-split symptom). Without this signal, finally would - // fallback failed even on success. start→succeeded/failed → finally CAS terminal. - routeChainTracker.start(createResult.invocationId); - - const markStartupTimeoutFailed = async () => { - if (startupTimeoutFailureRecorded) return; - startupTimeoutFailureRecorded = true; - finalStatus = 'failed'; - routeChainTracker.fail(createResult.invocationId); - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'failed', - error: 'Invocation startup timed out before provider/session initialized', - }); - opts.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: targetCats[0] ?? getDefaultCatId(), - content: JSON.stringify({ - type: 'invocation_startup_timeout', - message: '猫猫启动超时,已释放卡住的调用。', - invocationId: createResult.invocationId, - }), - timestamp: Date.now(), - }, - resolvedThreadId, - ); - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - }; - - const startupWatchdogMs = opts.invocationStartupWatchdogMs ?? INVOCATION_STARTUP_WATCHDOG_MS; - const startupWatchdog: ReturnType | undefined = - startupWatchdogMs > 0 - ? setTimeout(() => { - if (firstRouteEventSeen || controller?.signal.aborted) return; - startupWatchdogFired = true; - controller?.abort('startup_timeout'); - opts.invocationTracker?.completeAll(resolvedThreadId, targetCats, controller); - notifyQueueCompletion('failed'); - void markStartupTimeoutFailed().catch((err) => { - log.warn( - { err, invocationId: createResult.invocationId }, - '[messages] startup watchdog failed to mark invocation failed', - ); - }); - }, startupWatchdogMs) - : undefined; - - const clearStartupWatchdog = () => { - if (startupWatchdog) clearTimeout(startupWatchdog); - }; - - try { - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'running', - }); - - // #768: intent_mode deferred to first CLI event (avoid "replying" when CLI never starts) - let intentModeBroadcast = false; - // P1-2: track persistence failures across generator boundary - const persistenceContext: PersistenceContext = { failed: false, errors: [] }; - // F8: collect per-cat token usage from done events - const collectedUsage = new Map(); - // F070: track governance block errorCode for recoverable failure marking - let governanceErrorCode: string | undefined; - - // F088 ISSUE-15: Collect per-turn content for outbound delivery to connector platforms - const outboundTurns: Array<{ - catId: string; - textParts: string[]; - richBlocks?: unknown[]; - }> = []; - let currentTurnCatId: string | undefined; - const collectedTextParts: string[] = []; - - if (admittedMessageBundle) { - if (!opts.threadStore) throw new MessageBundlePromptUnavailableError('thread_store_unavailable'); - const bundlePrompt = await resolveMessageBundlePrompt({ - bundleMessageId: storedUserMessage.id, - forwarderUserId: userId, - carrier: admittedMessageBundle.carrier, - messageStore: opts.messageStore, - threadStore: opts.threadStore, - }); - if (bundlePrompt.status !== 'ready') { - throw new MessageBundlePromptUnavailableError(bundlePrompt.reason); - } - content = bundlePrompt.content; - } - - // F088 ISSUE-15: Start streaming placeholder on external platforms - if (opts.streamingHook) { - streamStartPromise = opts.streamingHook - .onStreamStart(resolvedThreadId, primaryCat, createResult.invocationId) - .catch((err) => { - log.warn({ err, threadId: resolvedThreadId }, '[messages] StreamingHook.onStreamStart failed'); - }); - } - - // User stop can win the race before CLI produces the first event. - // Do not re-arm frontend state with spawn_started/intent_mode after abort. - if (controller?.signal.aborted) { - finalStatus = - controller.signal.reason === 'user_cancel' || controller.signal.reason === 'cancel_all' - ? 'canceled_by_user' - : 'canceled'; - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'canceled', - }); - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - return; - } - - // F224: direct immediate invocations must consume the same pending continuation - // as QueueProcessor. Only single-cat content is safe to rewrite with a cat-specific prompt. - if (opts.sessionContinuationCoordinator && targetCats.length === 1) { - const singleCatId = targetCats[0]!; - try { - const prepared = await opts.sessionContinuationCoordinator.prepareInvocationContext({ - threadId: resolvedThreadId, - catId: singleCatId, - userId, - content, - }); - content = prepared.content; - consumedContinuation = prepared.consumedContinuation; - if (prepared.sessionPolicy === 'reborn') { - log.info( - { threadId: resolvedThreadId, catId: singleCatId }, - '[messages] F224: reborn session — coordinator skipped continuation consume', - ); - } else if (prepared.consumedContinuation) { - log.info( - { threadId: resolvedThreadId, catId: singleCatId }, - '[messages] F224: consumed pending continuation for direct invocation', - ); - } - } catch (err) { - log.warn( - { err, threadId: resolvedThreadId, catId: singleCatId }, - '[messages] F224: prepareInvocationContext failed, proceeding without continuation context', - ); - } - } - - // F118 D2: Broadcast spawn_started immediately — fills the intent_mode blind spot. - // intent_mode only fires after the first CLI NDJSON event (0–2 min delay). - // spawn_started fires here, before routeExecution, so the UI can show - // per-cat "spawning" indicators without waiting for CLI to come alive. - opts.socketManager.broadcastToRoom(`thread:${resolvedThreadId}`, 'spawn_started', { - threadId: resolvedThreadId, - targetCats, - invocationId: createResult.invocationId, - }); - - for await (const msg of router.routeExecution( - userId, - content, - resolvedThreadId, - storedUserMessage.id, - targetCats, - intent, - { - ownerAuthProvenance, - humanDispositionInvocationOrigin: 'direct_owner', - turnCustodyWake: { kind: 'unstructured', source: 'user_chat' }, - ...(contentBlocks ? { contentBlocks } : {}), - uploadDir, - ...(controller?.signal ? { signal: controller.signal } : {}), - // F-parallel-cancel: per-cat signal so a single-cat cancel on this direct execution - // path aborts only that cat — not the shared batch gate. Without this, route layer - // falls back to the batch controller and single-cat cancel never reaches the cat. - signalForCat: (catId: string) => opts.invocationTracker?.getController?.(resolvedThreadId, catId)?.signal, - ...(opts.invocationQueue - ? { - queueHasQueuedMessages: (tid: string) => - opts.invocationQueue?.hasQueuedNonAgentForThread(tid) ?? false, - getQueuedFreshnessMessagesForCat: ( - tid: string, - uid: string, - catId: string, - parentInvocationId?: string, - ) => - opts.invocationQueue?.getQueuedFreshnessMessagesForCat(tid, uid, catId, { - parentInvocationId, - }) ?? [], - deferA2AEnqueue: (e) => opts.invocationQueue?.enqueue({ ...e, ownerAuthProvenance }), - // F254 B3: freshness re-invoke enqueue for immediate (foreground) invocations. - // Without this, freshnessReinvoke metadata from invoke-single-cat is silently - // dropped in the immediate path — re-invoke only fires for queue-driven entries. - // Matches QueueProcessor pattern: strip freshnessContext before enqueue. - freshnessReinvokeEnqueue: (e: any) => { - const { freshnessContext: _ctx, ...queueFields } = e; - return opts.invocationQueue?.enqueue({ ...queueFields, ownerAuthProvenance }); - }, - hasQueuedOrActiveAgentForCat: (tid: string, catId: string) => - opts.invocationQueue?.hasActiveOrQueuedAgentForCat(tid, catId) ?? false, - hasPendingForCat: (tid: string, uid: string, catId: string) => - opts.invocationQueue?.hasPendingForCat(tid, catId, { userId: uid }) ?? false, - } - : {}), - ...createA2ASlotTrackingBridge( - opts.invocationTracker, - controller ?? new AbortController(), - createResult.invocationId, - ), - cursorBoundaries, - persistenceContext, - parentInvocationId: createResult.invocationId, - ...(admittedMessageBundle - ? { - persistedPromptMessageIds: [storedUserMessage.id], - persistedPromptMessages: [ - { - messageId: storedUserMessage.id, - content, - forceExplicitProjection: true, - }, - ], - } - : {}), - onPromptMessagesExposed: (input) => - opts.queueProcessor?.markPromptMessagesSeen(input) ?? Promise.resolve(), - // F222 P1: user direct entry → eligible for frustration auto-issue - frustrationAutoIssueEligible: true, + content: source.content, + mentions: [target], + timestamp: Date.now(), + threadId: source.threadId, + idempotencyKey, + deliveryStatus: 'queued', + ...(source.contentBlocks ? { contentBlocks: source.contentBlocks } : {}), + ...(source.visibility ? { visibility: source.visibility } : {}), + ...(source.whisperTo ? { whisperTo: source.whisperTo } : {}), + ...(source.replyTo ? { replyTo: source.replyTo } : {}), + extra: { + cloudBridgeRetry: { + v: 1, + sourceMessageId, + targetCatId, + priorDispatchInvocationId: parsed.data.attemptId, }, - )) { - if (!firstRouteEventSeen) { - firstRouteEventSeen = true; - clearStartupWatchdog(); - } - if (controller?.signal.aborted) { - break; - } - // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. - if (!intentModeBroadcast) { - opts.socketManager.broadcastToRoom(`thread:${resolvedThreadId}`, 'intent_mode', { - threadId: resolvedThreadId, - mode: intent.intent, - targetCats, - invocationId: createResult.invocationId, - }); - intentModeBroadcast = true; - } - // F39 bugfix: stop broadcasting after cancel (drain pipe buffer silently) - if (controller?.signal.aborted) break; - const continuationCapsule = extractContinuityCapsuleFromAgentMessage(msg); - if (continuationCapsule) { - continuationCapsules.set(continuationCapsule.catId, continuationCapsule); - } - if (msg.type === 'done' && msg.catId && msg.metadata?.usage) { - collectedUsage.set(msg.catId, mergeTokenUsage(collectedUsage.get(msg.catId), msg.metadata.usage)); - } - if (msg.type === 'done' && msg.errorCode) { - governanceErrorCode = msg.errorCode; - } - terminalDispositions.observe(msg); - if (isTerminalDispositionEvent(msg) && msg.catId) { - opts.invocationTracker?.completeSlot?.(resolvedThreadId, msg.catId, controller); - } - - // F088 ISSUE-15: Collect outbound turns (same pattern as QueueProcessor) - if (msg.type === 'done' && msg.catId) { - if (persistenceContext.richBlocks) { - const turn = outboundTurns[outboundTurns.length - 1]; - if (turn && turn.catId === msg.catId && currentTurnCatId === msg.catId) { - turn.richBlocks = [...persistenceContext.richBlocks]; - } else { - outboundTurns.push({ - catId: msg.catId, - textParts: [], - richBlocks: [...persistenceContext.richBlocks], - }); - } - persistenceContext.richBlocks = undefined; - } - currentTurnCatId = undefined; - } - if (msg.type === 'text' && typeof (msg as unknown as Record).content === 'string') { - const textContent = (msg as unknown as Record).content as string; - const textMode = (msg as { textMode?: 'append' | 'replace' }).textMode; - accumulateTextParts(collectedTextParts, textContent, textMode); - if (msg.catId) { - if (msg.catId !== currentTurnCatId) { - outboundTurns.push({ catId: msg.catId, textParts: [] }); - currentTurnCatId = msg.catId; - } - const turn = outboundTurns[outboundTurns.length - 1]; - accumulateTextParts(turn.textParts, textContent, textMode); - } - // F088 ISSUE-15: Forward streaming chunks to external platforms - if (opts.streamingHook) { - const accumulated = - outboundTurns.length > 0 ? flattenTurnTextParts(outboundTurns) : flattenTextParts(collectedTextParts); - opts.streamingHook - .onStreamChunk(resolvedThreadId, accumulated, createResult.invocationId) - .catch((streamErr) => { - log.warn( - { err: streamErr, threadId: resolvedThreadId }, - '[messages] StreamingHook.onStreamChunk failed', - ); - }); - } - } - - // F194 Phase Z9 (砚砚 R1 P1-2): unified visible turn stamp via helper. - // Route layer (R1 P1-1 fix) now stamps msg.invocationId = ownInvocationId - // for assistant yielded events, so helper receives a defined turn id - // (no parent fallback firing in practice). - const broadcastPayload = { - ...msg, - ...stampVisibleTurn(createResult.invocationId, msg.invocationId), - }; - - if (msg.type === 'a2a_handoff') { - const storedId = await persistA2ARoutingMessage( - opts.messageStore, - broadcastPayload, - resolvedThreadId, - log, - ); - if (storedId) broadcastPayload.messageId = storedId; - } - - opts.socketManager.broadcastAgentMessage(broadcastPayload, resolvedThreadId); - } - - // F39 P1 fix (砚砚 R1): abort guard after loop — when signal is aborted - // and the generator ends normally (no throw), the break exits the loop but - // post-loop code would still run ack+succeeded. Guard explicitly. - // F-parallel-cancel: use AGGREGATE finalStatus — batch gate abort (whole invocation) OR - // every target cat singly cancelled → canceled. A single-cat cancel no longer aborts the - // batch gate, so raw controller.signal.aborted only covers the whole-invocation case. - // (completeAll runs in finally, AFTER this, so cancel tombstones are still visible here.) - const aggFinalStatus = startupWatchdogFired - ? 'failed' - : opts.invocationTracker?.resolveFinalStatus - ? opts.invocationTracker.resolveFinalStatus(resolvedThreadId, targetCats, { - aborted: controller?.signal.aborted ?? false, - reason: controller?.signal.reason as string | undefined, - }) - : controller?.signal.aborted - ? // Fallback (tracker without resolveFinalStatus): whole-invocation abort → reason - // decides canceled_by_user vs canceled (matches resolveFinalStatus semantics). - controller.signal.reason === 'user_cancel' || controller.signal.reason === 'cancel_all' - ? 'canceled_by_user' - : 'canceled' - : 'succeeded'; - const primaryTerminalError = terminalDispositions.getPrimaryTerminalError(); - const successfulCatIds = terminalDispositions.getSuccessfulCatIds() as CatId[]; - if (aggFinalStatus === 'failed') { - await markStartupTimeoutFailed(); - } else if (aggFinalStatus !== 'succeeded') { - finalStatus = aggFinalStatus; - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'canceled', - }); - // Bugfix: silent-exit P2 — only broadcast diagnostic when preempted by - // a newer invocation (reason='preempted'). User-initiated cancel already - // broadcasts its own messages via buildCancelMessages; adding another here - // would cause a duplicate with misleading text. - if (controller?.signal.reason === 'preempted') { - opts.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: targetCats[0] ?? getDefaultCatId(), - content: JSON.stringify({ - type: 'invocation_preempted', - detail: 'This response was superseded by a newer request.', - invocationId: createResult.invocationId, - }), - timestamp: Date.now(), - }, - resolvedThreadId, - ); - } - // F148 fix: ack cursors for cats that completed before abort (monotonic CAS, safe to call) - if (cursorBoundaries.size > 0) { - await router.ackCollectedCursors(userId, resolvedThreadId, cursorBoundaries); - } - // P1 fix: finalize streaming session on abort so external placeholders are cleaned up - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - } else if ( - primaryTerminalError && - (successfulCatIds.length === 0 || terminalDispositions.getPreflightRejectedCatIds().length > 0) - ) { - finalStatus = 'failed'; - routeChainTracker.fail(createResult.invocationId); - if (cursorBoundaries.size > 0) { - await router.ackCollectedCursors(userId, resolvedThreadId, cursorBoundaries); - } - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'failed', - error: primaryTerminalError, - ...(successfulCatIds.length > 0 ? { successfulCatIds } : {}), - }); - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - } else if (persistenceContext.failed) { - const errorDetail = persistenceContext.errors.map((e) => `${e.catId}: ${e.error}`).join('; '); - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'failed', - error: `Message delivered but persistence failed: ${errorDetail}`, - }); - opts.socketManager.broadcastAgentMessage( - { - type: 'error', - catId: getDefaultCatId(), - error: '消息已发送但未能保存,刷新后可能丢失。可点击重试。', - timestamp: Date.now(), - }, - resolvedThreadId, - ); - - const pushSvcErr = getPushNotificationService(); - if (pushSvcErr) { - pushSvcErr - .notifyUser(userId, { - title: '猫猫消息保存失败', - body: '消息已发送但未能保存,请检查', - tag: `cat-error-${resolvedThreadId}`, - data: { threadId: resolvedThreadId, url: `/?thread=${resolvedThreadId}` }, - }) - .catch(() => {}); - } - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - } else if (governanceErrorCode) { - // F070: Governance gate blocked — mark as failed with errorCode for retry - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'failed', - error: governanceErrorCode, - }); - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - } else { - // ADR-008 S3: ack cursors before marking succeeded so that if ack - // throws, the catch block sees running→failed (valid transition). - await router.ackCollectedCursors(userId, resolvedThreadId, cursorBoundaries); - - if (!opts.invocationRecordStore) { - throw new Error('Invocation record store disappeared before terminal update'); - } - await requireInvocationRecordUpdate({ - store: opts.invocationRecordStore, - invocationId: createResult.invocationId, - update: { - status: 'succeeded', - successfulCatIds, - ...(collectedUsage.size > 0 - ? { - usageByCat: Object.fromEntries(collectedUsage), - } - : {}), - }, - writer: 'messages route', - }); - finalStatus = 'succeeded'; - // F194 Phase Z3: chain succeeded — signal for finally fallback - routeChainTracker.succeed(createResult.invocationId); - - for (const continuationCapsule of continuationCapsules.values()) { - if ( - !(await shouldEnqueueDirectContinuation( - continuationCapsule, - userId, - opts.sessionContinuationCoordinator, - )) - ) { - continue; - } - await opts.queueProcessor - ?.enqueueContinuation({ - threadId: resolvedThreadId, - userId, - ownerAuthProvenance, - catId: continuationCapsule.catId, - capsule: continuationCapsule, - }) - .catch((err) => { - log.warn({ err, threadId: resolvedThreadId }, 'enqueueContinuation failed (best-effort)'); - return undefined; - }); - } - - // Push notification: cat(s) finished responding - const pushSvc = getPushNotificationService(); - if (pushSvc) { - const catNames = targetCats.join(', '); - const pushTurns = outboundTurns.filter((turn) => - isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[turn.catId]), - ); - const assistantText = ( - outboundTurns.length > 0 - ? flattenTurnTextParts(pushTurns) - : isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[primaryCat]) - ? flattenTextParts(collectedTextParts) - : '' - ).trim(); - const hasKnownUndeliverableOutput = Object.values(persistenceContext.outputCommitDecisions ?? {}).some( - (decision) => !isConnectorDeliverable(decision), - ); - if (!hasKnownUndeliverableOutput || assistantText.length > 0) { - const needsDecision = assistantText.length > 0 ? shouldMarkDecisionNotification(assistantText) : false; - const pushBodySource = assistantText || '猫猫已处理,请打开会话查看详情'; - pushSvc - .notifyUser(userId, { - title: needsDecision ? `${catNames} 需要你决策` : `${catNames} 回复了`, - body: pushBodySource.slice(0, 80), - icon: targetCats.length === 1 ? `/avatars/${targetCats[0]}.png` : '/icons/icon-192x192.png', - tag: `${needsDecision ? 'cat-decision' : 'cat-reply'}-${resolvedThreadId}`, - data: { - threadId: resolvedThreadId, - url: `/?thread=${resolvedThreadId}`, - ...(needsDecision ? { requiresDecision: true } : {}), - }, - }) - .catch(() => { - /* best-effort */ - }); - } - } - - // F088 ISSUE-15: Outbound delivery to connector platforms (Feishu/Telegram) - // P2 fix: fire-and-forget so delivery latency doesn't block invocationTracker.complete() - deliverOutboundFromWeb( - resolvedThreadId, - primaryCat, - createResult.invocationId, - collectedTextParts, - outboundTurns, - persistenceContext, - streamStartPromise, - opts, - log, - ).catch((deliverErr) => { - log.error({ err: deliverErr, threadId: resolvedThreadId }, '[messages] deliverOutboundFromWeb failed'); - }); - } - } catch (err) { - // F39 bugfix: detect abort (cancel/force) vs real failure - if (startupWatchdogFired) { - await markStartupTimeoutFailed(); - } else if (controller?.signal.aborted) { - finalStatus = - controller.signal.reason === 'user_cancel' || controller.signal.reason === 'cancel_all' - ? 'canceled_by_user' - : 'canceled'; - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'canceled', - }); - // F148 fix: ack cursors for cats that completed before the exception - if (cursorBoundaries.size > 0) { - try { - await router.ackCollectedCursors(userId, resolvedThreadId, cursorBoundaries); - } catch { - /* best-effort — don't mask the original error */ - } - } - // Don't broadcast error for intentional cancel - // P1-A fix: clean up streaming placeholder even on abort/cancel - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - } else { - // F148 fix: ack cursors for cats that completed before the exception - if (cursorBoundaries.size > 0) { - try { - await router.ackCollectedCursors(userId, resolvedThreadId, cursorBoundaries); - } catch { - /* best-effort — don't mask the original error */ - } - } - log.error({ err, invocationId: createResult.invocationId }, 'Background processing error'); - const errorMsg = normalizeErrorMessage(err); - await opts.invocationRecordStore?.update(createResult.invocationId, { - status: 'failed', - error: errorMsg, - }); - // F194 Phase Z3: chain failed — diagnostic signal for finally fallback - routeChainTracker.fail(createResult.invocationId); - opts.socketManager.broadcastAgentMessage( - { - type: 'error', - catId: getDefaultCatId(), - error: errorMsg, - isFinal: true, - timestamp: Date.now(), - }, - resolvedThreadId, - ); - - const pushSvcCatch = getPushNotificationService(); - if (pushSvcCatch) { - pushSvcCatch - .notifyUser(userId, { - title: '猫猫出错了', - body: errorMsg.slice(0, 100), - tag: `cat-error-${resolvedThreadId}`, - data: { threadId: resolvedThreadId, url: `/?thread=${resolvedThreadId}` }, - }) - .catch(() => {}); - } - await cleanupStreamingOnFailure(resolvedThreadId, createResult.invocationId, streamStartPromise, opts, log); - } // end else (non-abort error) - } finally { - clearStartupWatchdog(); - clearInterval(heartbeatInterval); - opts.invocationTracker?.completeAll(resolvedThreadId, targetCats, controller); - // Preserve the normal ordering (terminal truth + continuation commit before the next - // Queue turn), but never let an unbounded bookkeeping await erase the only drain signal. - // The callback is idempotent per route, so the watchdog and normal finally converge. - const queueCompletionWatchdogMs = opts.queueCompletionWatchdogMs ?? QUEUE_COMPLETION_WATCHDOG_MS; - const queueCompletionWatchdog = - queueCompletionWatchdogMs >= 0 - ? setTimeout(() => { - log.warn( - { - threadId: resolvedThreadId, - invocationId: createResult.invocationId, - queueCompletionWatchdogMs, - }, - '[messages] terminal bookkeeping exceeded queue completion deadline; notifying drain', - ); - notifyQueueCompletion(finalStatus); - }, queueCompletionWatchdogMs) - : undefined; - if (queueCompletionWatchdog && typeof queueCompletionWatchdog === 'object') { - queueCompletionWatchdog.unref(); - } - try { - // F194 Phase Z3 (AC-Z3): defensive terminal write. If routeExecution silently exited - // without writing terminal (the runtime split symptom root cause), CAS expectedStatus= - // running guarded write based on chainCompletion signal. Skips if status already terminal. - // Reads chainTracker → succeeded/failed/missing → CAS update or fallback. (砚砚 R1 P1-3) - if (opts.invocationRecordStore) { - try { - await ensureTerminalStatus( - createResult.invocationId, - { - invocationRecordStore: opts.invocationRecordStore, - chainCompletion: routeChainTracker, - log, - }, - { - reqId: request.id, - successfulCatIds: terminalDispositions.getSuccessfulCatIds() as CatId[], - }, - ); - } catch (err) { - log.warn( - { err, invocationId: createResult.invocationId, feature: 'F194' }, - 'F194 Z3 ensureTerminalStatus failed (background)', - ); - } - } - routeChainTracker.release(createResult.invocationId); - if (opts.sessionContinuationCoordinator) { - try { - await opts.sessionContinuationCoordinator.commitInvocationOutcome({ - finalStatus, - threadId: resolvedThreadId, - catId: primaryCat, - userId, - consumedContinuation, - producedCapsules: [...continuationCapsules.values()], - }); - } catch (err) { - log.warn( - { err, threadId: resolvedThreadId, targetCats }, - '[messages] F224: commitInvocationOutcome failed', - ); - } - } - } finally { - if (queueCompletionWatchdog) clearTimeout(queueCompletionWatchdog); - // F39: Notify queue processor for auto-dequeue chain. - notifyQueueCompletion(finalStatus); - } - } - })(); - } else { - // Fallback: no invocationRecordStore (legacy path, uses route()) - // F122 A.1: Try non-preemptive first. The legacy root admission still cannot - // queue the incoming user message, so it falls back to replacement ownership. - // Its nested A2A handoffs do use InvocationQueue when available; an occupied - // target without queue custody now fails closed instead of invoking inline. - // TODO(F122 Phase B): Remove this legacy root-admission path. - let acquiredController: AbortController | null | undefined; - if (mode !== 'force' && opts.invocationTracker) { - acquiredController = await acquireRouteExecutionOwner(opts, resolvedThreadId, targetCats, userId, { - mode: 'non_preemptive', - }); - if (acquiredController == null) { - acquiredController = await acquireRouteExecutionOwner(opts, resolvedThreadId, targetCats, userId, { - mode: 'replacement', - }); - } - } else { - acquiredController = await acquireRouteExecutionOwner(opts, resolvedThreadId, targetCats, userId, { - mode: 'replacement', - onOwnershipValidated: cancelValidatedForceOwner, - }); - } - if (acquiredController === null) { - reply.status(409); - return { - error: 'Invocation owner changed before message routing could start', - code: 'INVOCATION_OWNER_CHANGED', - }; - } - const controller = acquiredController; - publishValidatedForceCancellation(); - if (controller?.signal.aborted) { - reply.status(409); - return { - error: '对话正在删除中', - detail: '请稍后重试,或新建一个对话继续', - code: 'THREAD_DELETING', - }; - } - - if (mode === 'force' && cancelledCatIds.length > 0 && opts.invocationQueue) { - try { - await emitQueueUpdated( - opts.socketManager, - userId, - resolvedThreadId, - opts.invocationQueue.list(resolvedThreadId, userId), - opts.messageStore, - 'force_cleared', - ); - } catch (err) { - opts.invocationTracker?.completeAll(resolvedThreadId, targetCats, controller); - throw err; - } + }, + }, + queueInput, + ); + if (admitted.outcome === 'full') { + reply.status(429); + return { error: '消息队列已满', code: 'QUEUE_FULL' }; } - reply.send({ status: 'processing', timestamp: Date.now() }); - - void (async () => { - const HEARTBEAT_INTERVAL_MS = 30_000; - const heartbeatInterval = setInterval(() => { - opts.socketManager.broadcastToRoom(`thread:${resolvedThreadId}`, 'heartbeat', { - threadId: resolvedThreadId, - timestamp: Date.now(), - }); - }, HEARTBEAT_INTERVAL_MS); - - try { - // #768: intent_mode deferred to first CLI event (legacy path) - let intentModeBroadcast = false; - - for await (const msg of router.route( - userId, - content, - resolvedThreadId, - contentBlocks, - uploadDir, - controller?.signal, - { - ownerAuthProvenance, - ...createA2ASlotTrackingBridge(opts.invocationTracker, controller ?? new AbortController()), - ...(opts.invocationQueue - ? { - deferA2AEnqueue: (entry: Parameters>[0]) => - opts.invocationQueue?.enqueue({ ...entry, ownerAuthProvenance }), - } - : {}), - }, - )) { - // #768: Broadcast intent_mode on first CLI event (legacy path) - if (!intentModeBroadcast) { - opts.socketManager.broadcastToRoom(`thread:${resolvedThreadId}`, 'intent_mode', { - threadId: resolvedThreadId, - mode: intent.intent, - targetCats, - // Legacy path: no invocationId (no InvocationRecord). Frontend falls back gracefully. - }); - intentModeBroadcast = true; - } - if (isTerminalDispositionEvent(msg) && msg.catId) { - opts.invocationTracker?.completeSlot?.(resolvedThreadId, msg.catId, controller); - } - const legacyPayload = { ...msg }; - if (msg.type === 'a2a_handoff') { - const storedId = await persistA2ARoutingMessage(opts.messageStore, msg, resolvedThreadId, log); - if (storedId) legacyPayload.messageId = storedId; - } - opts.socketManager.broadcastAgentMessage(legacyPayload, resolvedThreadId); - } - } catch (err) { - log.error({ err }, 'Background processing error'); - opts.socketManager.broadcastAgentMessage( - { - type: 'error', - catId: getDefaultCatId(), - error: normalizeErrorMessage(err), - isFinal: true, - timestamp: Date.now(), - }, - resolvedThreadId, - ); - } finally { - clearInterval(heartbeatInterval); - opts.invocationTracker?.completeAll(resolvedThreadId, targetCats, controller); - } - })(); - } - }); + await emitQueueUpdated( + opts.socketManager, + userId, + source.threadId, + opts.invocationQueue.list(source.threadId, userId), + opts.messageStore, + admitted.outcome, + ); + void opts.queueProcessor.requestDrain(source.threadId); + reply.status(202); + return { + status: 'queued', + retryMessageId: admitted.message.id, + entryId: admitted.entry?.id, + }; + }, + ); // GET /api/messages - 获取历史消息 app.get('/api/messages', async (request) => { @@ -2401,7 +1079,6 @@ export const messagesRoutes: FastifyPluginAsync = async ( const needed = limit + 1; const browserTimelineRead = { includeQueuedCatMessages: true, - includeQueuedUserMessages: true, includeRecalledUserMessages: true, } as const; @@ -2431,6 +1108,9 @@ export const messagesRoutes: FastifyPluginAsync = async ( // Filter only internal route-guard diagnostics. F148 ContextBriefing is a // contracted user-visible transparency card; its non-routing guarantee is // enforced by incremental-context assembly, not timeline hydration. + // `routing-guard-failure` is a retired producer. Keep this read-boundary + // exclusion only so rows written by older releases never reappear after + // upgrade; current runtime code must not append or broadcast new rows. const batchVisible = rawBatch.filter( (m) => m.source?.connector !== 'routing-guard-failure' && !isLeakedSupplementDecline(m), ); @@ -2490,95 +1170,99 @@ export const messagesRoutes: FastifyPluginAsync = async ( summary?: { id: string; topic: string; conclusions: string[]; openQuestions: string[]; createdBy: string }; [key: string]: unknown; }; - const chatItems: TimelineItem[] = page.map((m) => ({ - id: m.id, - type: (m.catId - ? isSystemUserMessage(m) - ? 'system' - : 'assistant' - : m.source - ? 'connector' - : isSystemUserMessage(m) - ? 'system' - : 'user') as TimelineItem['type'], - catId: m.catId, - content: m.content, - ...(m.contentBlocks ? { contentBlocks: m.contentBlocks } : {}), - ...(m.toolEvents ? { toolEvents: m.toolEvents } : {}), - ...(m.metadata ? { metadata: m.metadata } : {}), - ...(m.origin ? { origin: m.origin } : {}), - ...(m.thinking ? { thinking: m.thinking } : {}), - ...(m.extra?.semanticEvent || - m.extra?.rich || - isCrossThreadProvenance(m.extra?.crossPost?.sourceThreadId, m.threadId) || - m.extra?.coordination || - m.extra?.isExplicitPost || - m.extra?.stream || - m.extra?.targetCats || - m.extra?.messageBundle || - m.extra?.scheduler || - m.extra?.systemKind || - m.extra?.systemInfo || - m.extra?.a2aRouting || - m.extra?.freshness || - m.extra?.supplement || - m.extra?.causal || - m.extra?.turnExecution || - m.extra?.auxiliaryTurnExecutions || - supplementProjectionByOriginal.has(m.id) || - m.queueCustody || - m.recall || - m.extra?.recovery - ? { - extra: { - ...(m.extra?.semanticEvent ? { semanticEvent: m.extra.semanticEvent } : {}), - ...(m.extra?.rich ? { rich: m.extra.rich } : {}), - ...(isCrossThreadProvenance(m.extra?.crossPost?.sourceThreadId, m.threadId) - ? { crossPost: m.extra!.crossPost! } - : {}), - ...(m.extra?.coordination ? { coordination: m.extra.coordination } : {}), - ...(m.extra?.isExplicitPost ? { isExplicitPost: true } : {}), - ...(m.extra?.stream ? { stream: m.extra.stream } : {}), - ...(m.extra?.targetCats ? { targetCats: m.extra.targetCats } : {}), - ...(m.extra?.messageBundle ? { messageBundle: m.extra.messageBundle } : {}), - ...(m.extra?.scheduler ? { scheduler: m.extra.scheduler } : {}), - ...(m.extra?.systemKind ? { systemKind: m.extra.systemKind } : {}), - ...(m.extra?.systemInfo ? { systemInfo: m.extra.systemInfo } : {}), - ...(m.extra?.a2aRouting ? { a2aRouting: m.extra.a2aRouting } : {}), - ...(m.extra?.freshness ? { freshness: m.extra.freshness } : {}), - ...(m.extra?.supplement ? { supplement: m.extra.supplement } : {}), - ...(m.extra?.causal ? { causal: m.extra.causal } : {}), - ...(m.extra?.turnExecution ? { turnExecution: m.extra.turnExecution } : {}), - ...(m.extra?.auxiliaryTurnExecutions ? { auxiliaryTurnExecutions: m.extra.auxiliaryTurnExecutions } : {}), - ...(supplementProjectionByOriginal.has(m.id) - ? { freshnessSupplement: supplementProjectionByOriginal.get(m.id) } - : {}), - ...(m.queueCustody ? { queueReceipt: projectQueueReceipt(m.queueCustody) } : {}), - ...(m.recall ? { recall: m.recall } : {}), - ...(m.extra?.recovery ? { recovery: projectRecoveryForHistory(m.extra.recovery) } : {}), - }, - } - : {}), - ...(m.visibility ? { visibility: m.visibility } : {}), - ...(m.whisperTo ? { whisperTo: m.whisperTo } : {}), - ...(m.revealedAt ? { revealedAt: m.revealedAt } : {}), - ...(m.deliveredAt ? { deliveredAt: m.deliveredAt } : {}), - ...(m.timelineOrderAt !== undefined ? { timelineOrderAt: m.timelineOrderAt } : {}), - ...(m.source - ? { - source: { - connector: m.source.connector, - label: m.source.label, - icon: m.source.icon, - ...(m.source.url ? { url: m.source.url } : {}), - ...(m.source.meta ? { meta: m.source.meta } : {}), - ...(m.source.sender ? { sender: m.source.sender } : {}), - }, - } - : {}), - ...(m.replyTo ? { replyTo: m.replyTo } : {}), - timestamp: m.timestamp, - })); + const chatItems: TimelineItem[] = page.map((m) => { + const from = messageFrom(m); + const type: TimelineItem['type'] = + from.kind === 'agent' + ? 'assistant' + : from.kind === 'external' || from.kind === 'plugin' || (from.kind === 'system' && Boolean(m.source)) + ? 'connector' + : from.kind === 'system' + ? 'system' + : 'user'; + return { + id: m.id, + type, + from, + catId: m.catId, + content: m.content, + ...(m.lifecycle ? { lifecycle: m.lifecycle } : {}), + ...(m.lifecycle?.kind === 'delivery_failure' ? { variant: 'error' } : {}), + ...(m.contentBlocks ? { contentBlocks: m.contentBlocks } : {}), + ...(m.toolEvents ? { toolEvents: m.toolEvents } : {}), + ...(m.metadata ? { metadata: m.metadata } : {}), + ...(m.origin ? { origin: m.origin } : {}), + ...(m.thinking ? { thinking: m.thinking } : {}), + ...(m.extra?.rich || + m.extra?.routingWarnings || + isCrossThreadProvenance(m.extra?.crossPost?.sourceThreadId, m.threadId) || + m.extra?.coordination || + m.extra?.isExplicitPost || + m.extra?.stream || + m.extra?.targetCats || + m.extra?.messageBundle || + m.extra?.scheduler || + m.extra?.systemKind || + m.extra?.a2aRouting || + m.extra?.freshness || + m.extra?.supplement || + m.extra?.causal || + m.extra?.turnExecution || + m.extra?.auxiliaryTurnExecutions || + supplementProjectionByOriginal.has(m.id) || + m.recall || + m.extra?.recovery + ? { + extra: { + ...(m.extra?.rich ? { rich: m.extra.rich } : {}), + ...(m.extra?.routingWarnings ? { routingWarnings: m.extra.routingWarnings } : {}), + ...(isCrossThreadProvenance(m.extra?.crossPost?.sourceThreadId, m.threadId) + ? { crossPost: m.extra!.crossPost! } + : {}), + ...(m.extra?.coordination ? { coordination: m.extra.coordination } : {}), + ...(m.extra?.isExplicitPost ? { isExplicitPost: true } : {}), + ...(m.extra?.stream ? { stream: m.extra.stream } : {}), + ...(m.extra?.targetCats ? { targetCats: m.extra.targetCats } : {}), + ...(m.extra?.messageBundle ? { messageBundle: m.extra.messageBundle } : {}), + ...(m.extra?.scheduler ? { scheduler: m.extra.scheduler } : {}), + ...(m.extra?.systemKind ? { systemKind: m.extra.systemKind } : {}), + ...(m.extra?.a2aRouting ? { a2aRouting: m.extra.a2aRouting } : {}), + ...(m.extra?.freshness ? { freshness: m.extra.freshness } : {}), + ...(m.extra?.supplement ? { supplement: m.extra.supplement } : {}), + ...(m.extra?.causal ? { causal: m.extra.causal } : {}), + ...(m.extra?.turnExecution ? { turnExecution: m.extra.turnExecution } : {}), + ...(m.extra?.auxiliaryTurnExecutions + ? { auxiliaryTurnExecutions: m.extra.auxiliaryTurnExecutions } + : {}), + ...(supplementProjectionByOriginal.has(m.id) + ? { freshnessSupplement: supplementProjectionByOriginal.get(m.id) } + : {}), + ...(m.recall ? { recall: m.recall } : {}), + ...(m.extra?.recovery ? { recovery: projectRecoveryForHistory(m.extra.recovery) } : {}), + }, + } + : {}), + ...(m.visibility ? { visibility: m.visibility } : {}), + ...(m.whisperTo ? { whisperTo: m.whisperTo } : {}), + ...(m.revealedAt ? { revealedAt: m.revealedAt } : {}), + ...(m.deliveredAt ? { deliveredAt: m.deliveredAt } : {}), + ...(m.timelineOrderAt !== undefined ? { timelineOrderAt: m.timelineOrderAt } : {}), + ...(m.source + ? { + source: { + connector: m.source.connector, + label: m.source.label, + icon: m.source.icon, + ...(m.source.url ? { url: m.source.url } : {}), + ...(m.source.meta ? { meta: m.source.meta } : {}), + ...(m.source.sender ? { sender: m.source.sender } : {}), + }, + } + : {}), + ...(m.replyTo ? { replyTo: m.replyTo } : {}), + timestamp: m.timestamp, + }; + }); // F121: Hydrate reply previews for messages with replyTo const replyItems = chatItems.filter((item) => item.replyTo); @@ -2794,182 +1478,3 @@ export const messagesRoutes: FastifyPluginAsync = async ( }); }); }; - -/** @internal exported for testing — do not use outside of test. */ -export async function cleanupStreamingOnFailure( - threadId: string, - invocationId: string, - streamStartPromise: Promise | undefined, - opts: MessagesRoutesOptions, - logger: typeof log, -): Promise { - if (!opts.streamingHook) return; - try { - if (streamStartPromise) { - await Promise.race([streamStartPromise, new Promise((r) => setTimeout(r, STREAM_START_TIMEOUT_MS))]); - } - await opts.streamingHook.onStreamEnd(threadId, '', invocationId); - await opts.streamingHook.cleanupPlaceholders?.(threadId, invocationId); - } catch (err) { - logger.warn({ err, threadId }, '[messages] cleanupStreamingOnFailure failed'); - } -} - -/** @internal exported for testing — do not use outside of test. */ -export async function deliverOutboundFromWeb( - threadId: string, - primaryCat: string, - invocationId: string, - collectedTextParts: string[], - outboundTurns: Array<{ catId: string; textParts: string[]; richBlocks?: unknown[] }>, - persistenceContext: PersistenceContext, - streamStartPromise: Promise | undefined, - opts: MessagesRoutesOptions, - logger: typeof log, -): Promise { - const deliverableTurns = outboundTurns.filter((turn) => - isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[turn.catId]), - ); - const finalContent = - outboundTurns.length > 0 - ? flattenTurnTextParts(deliverableTurns) - : isConnectorDeliverable(persistenceContext.outputCommitDecisions?.[primaryCat]) - ? flattenTextParts(collectedTextParts) - : ''; - const outputDecisionEntries = Object.entries(persistenceContext.outputCommitDecisions ?? {}); - const supersededOutput = outputDecisionEntries.find(([, decision]) => decision.kind === 'superseded_positive_stale'); - const blockedOutput = outputDecisionEntries.find(([, decision]) => decision.kind === 'blocked_known_closure'); - const hasKnownUndeliverableOutput = outputDecisionEntries.some(([, decision]) => !isConnectorDeliverable(decision)); - - if (opts.streamingHook) { - if (streamStartPromise) { - await Promise.race([ - streamStartPromise, - new Promise((resolve) => setTimeout(resolve, STREAM_START_TIMEOUT_MS)), - ]); - } - if (blockedOutput?.[1].kind === 'blocked_known_closure' && opts.streamingHook.onClosureBlocked) { - await opts.streamingHook - .onClosureBlocked(threadId, blockedOutput[0] as CatId, blockedOutput[1].reason, invocationId) - .catch((err) => logger.warn({ err, threadId }, '[messages] blocked connector projection failed')); - } else if (supersededOutput?.[1].kind === 'superseded_positive_stale' && opts.streamingHook.onClosureCatchingUp) { - await opts.streamingHook - .onClosureCatchingUp(threadId, supersededOutput[0] as CatId, invocationId) - .catch((err) => logger.warn({ err, threadId }, '[messages] catch connector projection failed')); - } else { - await opts.streamingHook.onStreamEnd(threadId, finalContent, invocationId).catch((err) => { - logger.warn({ err, threadId }, '[messages] StreamingHook.onStreamEnd failed'); - }); - } - } - - const hasContent = finalContent.length > 0 || deliverableTurns.some((turn) => (turn.richBlocks?.length ?? 0) > 0); - if (!opts.outboundHook || !hasContent) { - if (!hasKnownUndeliverableOutput && opts.streamingHook?.cleanupPlaceholders) { - await opts.streamingHook.cleanupPlaceholders(threadId, invocationId).catch((err) => { - logger.warn({ err, threadId }, '[messages] StreamingHook.cleanupPlaceholders failed (silent)'); - }); - } - return; - } - - let threadMeta: { threadShortId: string; threadTitle?: string; deepLinkUrl?: string } | undefined; - try { - const LOOKUP_TIMEOUT_MS = 2000; - const thread = opts.threadStore?.get(threadId); - if (thread) { - const lookupPromise = Promise.resolve(thread).catch(() => undefined); - const timeout = new Promise((resolve) => setTimeout(() => resolve(undefined), LOOKUP_TIMEOUT_MS)); - const resolved = await Promise.race([lookupPromise, timeout]); - if (resolved) { - const frontendBase = resolveFrontendBaseUrl(process.env); - threadMeta = { - threadShortId: threadId.slice(0, 15), - threadTitle: resolved.title ?? undefined, - deepLinkUrl: buildThreadDeepLink(frontendBase, threadId), - }; - } - } - } catch { - logger.warn({ threadId }, '[messages] threadMeta lookup failed'); - } - - const DELIVER_TIMEOUT_MS = 10_000; - const nonEmptyTurns = deliverableTurns.filter( - (t) => t.textParts.length > 0 || (t.richBlocks && t.richBlocks.length > 0), - ); - - let deliveryFailed = false; - const inflightDeliverPromises: Promise[] = []; - - if (nonEmptyTurns.length > 1) { - for (const turn of nonEmptyTurns) { - const turnContent = turn.textParts.join(''); - const deliverPromise = opts.outboundHook.deliver(threadId, turnContent, turn.catId, turn.richBlocks, threadMeta); - inflightDeliverPromises.push(deliverPromise); - try { - await Promise.race([ - deliverPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS)), - ]); - } catch (err) { - deliveryFailed = true; - logger.error({ err, threadId, catId: turn.catId }, '[messages] Outbound delivery error'); - } - } - } else if (nonEmptyTurns.length === 1) { - const turn = nonEmptyTurns[0]; - const richBlocks = persistenceContext.richBlocks ?? turn.richBlocks; - const deliverPromise = opts.outboundHook.deliver(threadId, finalContent, turn.catId, richBlocks, threadMeta); - inflightDeliverPromises.push(deliverPromise); - try { - await Promise.race([ - deliverPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS)), - ]); - } catch (err) { - deliveryFailed = true; - logger.error({ err, threadId }, '[messages] Outbound delivery error'); - } - } else { - const richBlocks = persistenceContext.richBlocks; - if (richBlocks) { - const deliverPromise = opts.outboundHook.deliver(threadId, finalContent, primaryCat, richBlocks, threadMeta); - inflightDeliverPromises.push(deliverPromise); - try { - await Promise.race([ - deliverPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('deliver timeout')), DELIVER_TIMEOUT_MS)), - ]); - } catch (err) { - deliveryFailed = true; - logger.error({ err, threadId }, '[messages] Outbound delivery error'); - } - } - } - - if (!deliveryFailed && opts.streamingHook?.cleanupPlaceholders) { - await opts.streamingHook.cleanupPlaceholders(threadId, invocationId).catch((err) => { - logger.warn({ err, threadId }, '[messages] StreamingHook.cleanupPlaceholders failed'); - }); - } else if (deliveryFailed && opts.streamingHook?.cleanupPlaceholders) { - const cleanupFn = opts.streamingHook.cleanupPlaceholders.bind(opts.streamingHook); - Promise.allSettled(inflightDeliverPromises).then((results) => { - if (results.every((r) => r.status === 'fulfilled')) { - cleanupFn(threadId, invocationId).catch((err) => { - logger.warn({ err, threadId }, '[messages] Late-success placeholder cleanup failed'); - }); - } - }); - } - - // F151: Signal adapters that this invocation's delivery batch is complete. - // chainDone = no more active or queued invocations for this thread. - if (opts.streamingHook?.notifyDeliveryBatchDone) { - const threadStillBusy = - (opts.invocationTracker?.has(threadId) ?? false) || (opts.queueProcessor?.isThreadBusy(threadId) ?? false); - await opts.streamingHook.notifyDeliveryBatchDone(threadId, !threadStillBusy).catch((err) => { - logger.warn({ err, threadId }, '[messages] notifyDeliveryBatchDone failed'); - }); - } -} diff --git a/packages/api/src/routes/native-realtime-companion-lifecycle.ts b/packages/api/src/routes/native-realtime-companion-lifecycle.ts index c070ed2b9d..b05f0d2bde 100644 --- a/packages/api/src/routes/native-realtime-companion-lifecycle.ts +++ b/packages/api/src/routes/native-realtime-companion-lifecycle.ts @@ -44,7 +44,7 @@ export async function consumeRealtimeCompanionEvent( const stored = await dependencies.messageStore.append({ userId: session.userId, threadId: session.threadId, - catId: createCatId(session.catId), + from: { kind: 'agent', catId: createCatId(session.catId) }, content: event.text, mentions: [], timestamp: event.occurredAt, diff --git a/packages/api/src/routes/native-thread-goal-projection.ts b/packages/api/src/routes/native-thread-goal-projection.ts index babc9cf208..e49a5dcb32 100644 --- a/packages/api/src/routes/native-thread-goal-projection.ts +++ b/packages/api/src/routes/native-thread-goal-projection.ts @@ -92,8 +92,8 @@ export async function appendGoalEvent( const projection = projectProviderSemanticEvent(event); if (projection.status !== 'projected') throw new Error('invalid_goal_semantic_event'); const stored = await options.messageStore.append({ + from: { kind: 'agent', catId: createCatId(catId) }, userId: access.userId, - catId: createCatId(catId), threadId: access.threadId, content: projection.content, mentions: [], diff --git a/packages/api/src/routes/native-thread-review-execution.ts b/packages/api/src/routes/native-thread-review-execution.ts index 4ba5fb9add..5bf0fd59af 100644 --- a/packages/api/src/routes/native-thread-review-execution.ts +++ b/packages/api/src/routes/native-thread-review-execution.ts @@ -136,8 +136,8 @@ export async function appendReviewEvent( const projection = projectProviderSemanticEvent(event); if (projection.status !== 'projected') throw new Error('invalid_review_semantic_event'); const stored = await options.messageStore.append({ + from: { kind: 'system', service: 'native-thread-review' }, userId: 'system', - catId: 'system' as CatId, threadId: access.thread.id, content: projection.content, mentions: [], diff --git a/packages/api/src/routes/parse-multipart.ts b/packages/api/src/routes/parse-multipart.ts index d71b666aa9..176293197c 100644 --- a/packages/api/src/routes/parse-multipart.ts +++ b/packages/api/src/routes/parse-multipart.ts @@ -19,7 +19,7 @@ export type ParsedMultipart = contentBlocks: MessageContent[]; visibility?: string; whisperTo?: string[]; - deliveryMode?: 'immediate' | 'queue' | 'force'; + mentions?: string[]; messageDisposition?: 'continue_current' | 'next_work'; /** #699: ID of message being replied to (quote). */ replyTo?: string; @@ -63,6 +63,9 @@ export async function parseMultipart( if (fields.whisperTo !== undefined && !Array.isArray(fields.whisperTo)) { fields.whisperTo = [fields.whisperTo]; } + if (fields.mentions !== undefined && !Array.isArray(fields.mentions)) { + fields.mentions = [fields.mentions]; + } // F294 v1 deliberately admits Bundle carriers only through JSON. Detect the // reserved field explicitly so multipart cannot degrade into an ordinary @@ -109,7 +112,7 @@ export async function parseMultipart( ...(idempotencyKey ? { idempotencyKey } : {}), ...(parseResult.data.visibility ? { visibility: parseResult.data.visibility } : {}), ...(parseResult.data.whisperTo ? { whisperTo: parseResult.data.whisperTo as string[] } : {}), - ...(parseResult.data.deliveryMode ? { deliveryMode: parseResult.data.deliveryMode } : {}), + ...(parseResult.data.mentions ? { mentions: parseResult.data.mentions as string[] } : {}), ...(parseResult.data.messageDisposition ? { messageDisposition: parseResult.data.messageDisposition } : {}), ...(parseResult.data.replyTo ? { replyTo: parseResult.data.replyTo } : {}), contentBlocks: buildMessageContentBlocks(content, contextAttachments, uploadedContent), diff --git a/packages/api/src/routes/person-memory-proposal-source-contract.ts b/packages/api/src/routes/person-memory-proposal-source-contract.ts index 88c1fd2431..a846b5abf3 100644 --- a/packages/api/src/routes/person-memory-proposal-source-contract.ts +++ b/packages/api/src/routes/person-memory-proposal-source-contract.ts @@ -14,6 +14,7 @@ import { writeOpportunityRefV1Schema, } from '@cat-cafe/shared'; import { z } from 'zod'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import { type IMessageStore, isDelivered } from '../domains/cats/services/stores/ports/MessageStore.js'; import { canViewMessage } from '../domains/cats/services/stores/visibility.js'; import { estimateTokens } from '../utils/token-counter.js'; @@ -236,8 +237,7 @@ export async function resolveInteractionSourceEvidence( if ( !message || message.userId !== auth.userId || - message.catId !== null || - message.source !== undefined || + messageFrom(message).kind !== 'user' || message.deletedAt !== undefined || message._tombstone === true || !isDelivered(message) || @@ -278,8 +278,7 @@ export async function resolveProposalSourceMessageId( if ( !message || message.userId !== auth.userId || - message.catId !== null || - message.source !== undefined || + messageFrom(message).kind !== 'user' || message.deletedAt !== undefined || message._tombstone === true || !isDelivered(message) || diff --git a/packages/api/src/routes/prompt-injection-preview.ts b/packages/api/src/routes/prompt-injection-preview.ts index fc984a673f..10466117fa 100644 --- a/packages/api/src/routes/prompt-injection-preview.ts +++ b/packages/api/src/routes/prompt-injection-preview.ts @@ -135,7 +135,7 @@ export const promptInjectionPreviewRoutes: FastifyPluginAsync = async (app) => { '── [D11] Skill 触发(条件:Signal 触发)──', tpl('D11'), '', - '── [D12] 活跃参与者 ──', + '── [D12] 最近发言者(不表示执行态)──', tpl('D12'), '', '── [D13] 路由策略(条件:线程有路由策略)──', diff --git a/packages/api/src/routes/proposal-approve-dispatch.ts b/packages/api/src/routes/proposal-approve-dispatch.ts index c6c1b521db..32a6049110 100644 --- a/packages/api/src/routes/proposal-approve-dispatch.ts +++ b/packages/api/src/routes/proposal-approve-dispatch.ts @@ -20,7 +20,7 @@ import { export { enrichWithParentThreadHeader } from './proposal-enrich-header.js'; type ProposalRouter = Pick; -type ProposalInvocationQueue = Pick; +type ProposalInvocationQueue = Pick; type ProposalQueueProcessor = Pick; export interface ProposalInitialMessageDispatchDeps { @@ -230,8 +230,8 @@ export async function appendApprovedInitialMessage({ declaredWorkMode, ); const stored = await messageStore.append({ + from: sourceCatId ? { kind: 'agent', catId: sourceCatId } : { kind: 'user', userId }, userId, - catId: sourceCatId ?? null, // AC-AA4: source cat is the message author content: enrichedFallback, mentions: [], timestamp: Date.now(), @@ -285,8 +285,8 @@ export async function appendApprovedInitialMessage({ return cancelExistingSeed(existingSeed, messageStore, 'no target cats resolved'); } const stored = await messageStore.append({ + from: sourceCatId ? { kind: 'agent', catId: sourceCatId } : { kind: 'user', userId }, userId, - catId: sourceCatId ?? null, // AC-AA4 content, mentions: [], timestamp: Date.now(), @@ -322,6 +322,6 @@ export async function appendApprovedInitialMessage({ socketManager, invocationQueue, queueProcessor, - existingSeed, + ...(existingSeed ? { existingSeed } : {}), }); } diff --git a/packages/api/src/routes/proposal-approve-reconcile.ts b/packages/api/src/routes/proposal-approve-reconcile.ts index d865128313..867e18e3e8 100644 --- a/packages/api/src/routes/proposal-approve-reconcile.ts +++ b/packages/api/src/routes/proposal-approve-reconcile.ts @@ -60,14 +60,15 @@ export function createReconcileApprovedInitialMessage({ /** * #1387 / #1406 B1: materialization and wake-completion are separate facts. - * A seed is dispatch-complete only when it has reached a terminal delivery - * state. Owning queue custody or merely being 'queued' does NOT mean the - * queue processor successfully woke the target — processNext may throw or - * return started:false, leaving the seed in an indistinguishable 'queued' - * state that must be retried. + * A seed is dispatch-complete only when it has terminal owner-delivery state + * or every actually dispatched lifecycle target has settled. Merely owning + * Queue custody does NOT mean the processor woke the target — processNext may + * throw or return started:false before any dispatchRef exists. */ function isDispatchComplete(message: StoredMessage): boolean { - return message.deliveryStatus === 'delivered' || message.deliveryStatus === 'canceled'; + if (message.deliveryStatus === 'delivered' || message.deliveryStatus === 'canceled') return true; + const dispatchRefs = message.lifecycle?.kind === 'input' ? message.lifecycle.dispatchRefs : undefined; + return Boolean(dispatchRefs && dispatchRefs.length > 0 && dispatchRefs.every((ref) => ref.phase === 'settled')); } /** @@ -216,6 +217,7 @@ export async function reconcileApprovedInitialMessage({ router, invocationQueue, queueProcessor, + idempotentSeed, ); return { warnings, wasPresent: true, redispatched: true }; } diff --git a/packages/api/src/routes/proposal-route-options.ts b/packages/api/src/routes/proposal-route-options.ts index 88e283e935..2f17d91fed 100644 --- a/packages/api/src/routes/proposal-route-options.ts +++ b/packages/api/src/routes/proposal-route-options.ts @@ -14,7 +14,7 @@ export interface ProposalRoutesOptions { messageStore: IMessageStore; socketManager: SocketManager; router?: Pick; - invocationQueue?: Pick; + invocationQueue?: Pick; queueProcessor?: Pick; onProposalReject?: (input: { proposalId: string; diff --git a/packages/api/src/routes/proposal-seed-admission.ts b/packages/api/src/routes/proposal-seed-admission.ts index cf41e9fac4..b3d44e5ac3 100644 --- a/packages/api/src/routes/proposal-seed-admission.ts +++ b/packages/api/src/routes/proposal-seed-admission.ts @@ -1,15 +1,15 @@ import type { CatId, MessageContent } from '@cat-cafe/shared'; -import type { InvocationQueue, QueueEntry } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; +import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { OwnerAuthProvenance } from '../domains/cats/services/agents/invocation/owner-auth-provenance.js'; -import { createInitialQueuedMessageCustody } from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; import type { AppendApprovedInitialMessageResult } from './proposal-approve-dispatch.js'; import { admitThreadParticipants } from './thread-participant-admission.js'; -type ProposalInvocationQueue = Pick; +type ProposalInvocationQueue = Pick; type ProposalQueueProcessor = Pick; /** @@ -53,64 +53,6 @@ export async function resolveSourceContentBlocks( return undefined; } -export interface ExistingSeedAdmissionResult { - kind: 'complete' | 'admitted' | 'failed'; - messageId: string; - warning?: string; -} - -/** - * #1406 B1 Part 2: a previously materialized seed (e.g. from a queue-full - * fallback append, or a legacy row written before the idempotency-key index) - * must be atomically admitted into the queue so it can be driven to a terminal - * delivery state. Without this, the seed stays in the ambiguous - * undefined-deliveryStatus state forever and every reconcile retries a brand-new - * enqueue, risking duplicate invocations once the queue has space. - */ -export async function ensureExistingSeedAdmitted( - existingSeed: StoredMessage, - entry: QueueEntry, - messageStore: IMessageStore, -): Promise { - if (existingSeed.deliveryStatus === 'delivered' || existingSeed.deliveryStatus === 'canceled') { - return { kind: 'complete', messageId: existingSeed.id }; - } - - if (existingSeed.deliveryStatus === 'queued' && existingSeed.queueCustody) { - return { kind: 'admitted', messageId: existingSeed.id }; - } - - if (existingSeed.deliveryStatus !== 'queued') { - const prepared = await messageStore.prepareQueueAdmission(existingSeed.id); - if (prepared.kind === 'conflict') { - // A concurrent transition may have finished the seed; refresh once. - const refreshed = await messageStore.getById(existingSeed.id); - if (refreshed?.deliveryStatus === 'delivered' || refreshed?.deliveryStatus === 'canceled') { - return { kind: 'complete', messageId: refreshed.id }; - } - return { - kind: 'failed', - messageId: existingSeed.id, - warning: 'existing seed could not be admitted to queue', - }; - } - } - - if (!existingSeed.queueCustody) { - const custody = createInitialQueuedMessageCustody(entry); - const initialized = await messageStore.initializeQueueCustody(existingSeed.id, custody); - if (initialized.kind !== 'initialized' && initialized.kind !== 'existing') { - return { - kind: 'failed', - messageId: existingSeed.id, - warning: 'existing seed custody initialization failed', - }; - } - } - - return { kind: 'admitted', messageId: existingSeed.id }; -} - /** * Cancel an existing seed so the materialization-vs-wake invariant stays terminal. * Legacy rows have deliveryStatus=undefined, which MessageStore.markCanceled treats @@ -195,16 +137,60 @@ export async function executeQueuedDispatch({ existingSeed, }: ExecuteQueuedDispatchInput): Promise { const idempotencyKey = `proposal-initial:${proposalId}`; - const enqueueResult = invocationQueue.enqueue({ - threadId, - userId, - ownerAuthProvenance, - idempotencyKey, - content, - source: 'user', - targetCats: targetCats as CatId[], - intent: intentName, - }); + const from = sourceCatId ? ({ kind: 'agent', catId: sourceCatId } as const) : ({ kind: 'user', userId } as const); + let enqueueResult: Awaited>; + if (existingSeed) { + try { + enqueueResult = await invocationQueue.enqueueExistingMessageDurable(messageStore, existingSeed.id, { + from: messageFrom(existingSeed), + threadId, + userId, + kind: 'conversation_input', + ownerAuthProvenance, + content: existingSeed.content, + messageId: existingSeed.id, + sourceId: existingSeed.id, + targetCats: targetCats as CatId[], + intent: intentName, + }); + } catch (error) { + // The ledger admission and Message transition are one CAS. If another + // worker terminalized the seed after our reconcile read, the losing + // admission leaves no orphan row and should converge as a clean replay. + const refreshed = await messageStore.getById(existingSeed.id); + if (refreshed?.deliveryStatus === 'delivered' || refreshed?.deliveryStatus === 'canceled') { + return { messageId: refreshed.id }; + } + throw error; + } + } else { + enqueueResult = await invocationQueue.appendAndEnqueueDurable( + messageStore, + { + from, + userId, + content, + mentions: [...targetCats], + timestamp: Date.now(), + threadId, + idempotencyKey, + deliveryStatus: 'queued', + extra: crossPostExtra, + contentBlocks: sourceContentBlocks, + }, + { + from, + threadId, + userId, + kind: 'conversation_input', + ownerAuthProvenance, + idempotencyKey, + content, + targetCats: targetCats as CatId[], + intent: intentName, + }, + ); + } if (enqueueResult.outcome === 'full' || !enqueueResult.entry) { // Queue-full: if we already have a materialized seed (legacy or prior @@ -218,8 +204,8 @@ export async function executeQueuedDispatch({ }; } const stored = await messageStore.append({ + from, userId, - catId: sourceCatId ?? null, // AC-AA4 content, mentions: [...targetCats], timestamp: Date.now(), @@ -234,48 +220,7 @@ export async function executeQueuedDispatch({ }; } - let storedMessageId = enqueueResult.entry.messageId ?? null; - if (!enqueueResult.deduped || !storedMessageId) { - // A prior queue-full or failed-persistence attempt may have already - // materialized the seed. Reuse it atomically instead of appending a second - // message. - const seedToAdmit = existingSeed ?? (await messageStore.getByIdempotencyKey(userId, threadId, idempotencyKey)); - if (seedToAdmit) { - const admission = await ensureExistingSeedAdmitted(seedToAdmit, enqueueResult.entry, messageStore); - if (admission.kind === 'complete') { - // #1406 B1: the queue carrier was created but the seed is already terminal. - // Roll it back so we do not leave an orphan queue entry behind. - invocationQueue.rollbackEnqueue(threadId, userId, enqueueResult.entry.id); - return { messageId: admission.messageId }; - } - if (admission.kind === 'failed') { - invocationQueue.rollbackEnqueue(threadId, userId, enqueueResult.entry.id); - return { messageId: admission.messageId, warning: admission.warning }; - } - storedMessageId = admission.messageId; - } else { - try { - const stored = await messageStore.append({ - userId, - catId: sourceCatId ?? null, // AC-AA4 - content, - mentions: [...targetCats], - timestamp: Date.now(), - threadId, - idempotencyKey, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(enqueueResult.entry), - extra: crossPostExtra, // AC-AA5 - contentBlocks: sourceContentBlocks, - }); - storedMessageId = stored.id; - } catch (err) { - invocationQueue.rollbackEnqueue(threadId, userId, enqueueResult.entry.id); - throw err; - } - } - invocationQueue.backfillMessageId(threadId, userId, enqueueResult.entry.id, storedMessageId); - } + const storedMessageId = enqueueResult.message.id; // F128 owns the final dispatch plan: preferredCats ordering and explicit // parallel intent can differ from raw-message mentions. Admit only those diff --git a/packages/api/src/routes/queue.ts b/packages/api/src/routes/queue.ts index eccdbd19be..f9c04c7e00 100644 --- a/packages/api/src/routes/queue.ts +++ b/packages/api/src/routes/queue.ts @@ -1,13 +1,13 @@ /** * Queue Management API Routes (F39) - * Architecture cell: dispatch * * GET /api/threads/:threadId/queue → 列出队列条目 * DELETE /api/threads/:threadId/queue/:entryId → 撤回条目 - * POST /api/threads/:threadId/queue/next → 手动触发处理下一条 - * POST /api/threads/:threadId/queue/steer-batch → #1291 exact ordinary-user Batch Steer + * GET /api/threads/:threadId/queue/:entryId/targets → 读取同源逐目标终局/待处理真相 + * POST /api/threads/:threadId/queue/:entryId/targets → 原子绑定/扩展同源逐目标 Steer 工单 * POST /api/threads/:threadId/queue/:entryId/steer → Steer queued entry(取消当前轮并以同一消息立即启动) - * POST /api/threads/:threadId/queue/:entryId/targets/:targetCatId/retry → Retry one failed target + * POST /api/threads/:threadId/queue/:entryId/continue → 立即引导当前回复,或保留为排队等待 + * POST /api/threads/:threadId/queue/:entryId/append → Append queued entry into exact existing Active Run(s) * PATCH /api/threads/:threadId/queue/:entryId/move → 重排序(上移/下移) * PATCH /api/threads/:threadId/queue/reorder → F175: 批量设置 position(拖拽重排) * DELETE /api/threads/:threadId/queue → 清空队列 @@ -18,8 +18,6 @@ import { randomUUID } from 'node:crypto'; import type { CatId, FreshnessCarrierCapability } from '@cat-cafe/shared'; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; -import type { WaitContinuationRetryCommitter } from '../domains/ball-custody/WaitContinuationRetryCommitter.js'; -import type { WaitContinuationRetryPreflight } from '../domains/ball-custody/WaitContinuationRetryPreflight.js'; import { type AgentSessionMutexLike, agentSessionMutex, @@ -32,20 +30,27 @@ import { } from '../domains/cats/services/agents/invocation/active-execution-service.js'; import { type InvocationQueue, - isOrdinaryQueueTargetEligible, isSystemPinnedQueueEntry, type QueueEntry, + queueEntryMessageIds, + queueEntryOwnerId, + queueEntryTargetCats, } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; +import { DEFAULT_PRESTART_RESERVATION_TTL_MS } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; import { - canSteerQueueSources, - readQueueCarrierMessages, -} from '../domains/cats/services/agents/invocation/QueueCarrierSourceProjection.js'; -import type { QueuedMessageCustodyCoordinator } from '../domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; + projectLifecycleAppendAction, + projectLifecycleAppendCapability, +} from '../domains/cats/services/agents/invocation/lifecycle-append-projection.js'; import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; +import { queueEntryId } from '../domains/cats/services/agents/invocation/queue-ledger/QueueLedger.js'; import type { IDraftStore } from '../domains/cats/services/stores/ports/DraftStore.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; -import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; -import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; +import { + type IMessageStore, + type StoredMessage, + settleLifecycleResponseInputs, +} from '../domains/cats/services/stores/ports/MessageStore.js'; +import type { IThreadStore, Thread } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { ITurnExecutionStore } from '../domains/cats/services/stores/ports/TurnExecutionStore.js'; import type { DynamicTaskStore } from '../infrastructure/scheduler/DynamicTaskStore.js'; import { buildCancelMessages, type SocketManager } from '../infrastructure/websocket/index.js'; @@ -53,12 +58,14 @@ import type { CliExecutionOwnerService, LiveCliExecutionOwner } from '../utils/c import { emitQueueUpdated, enrichQueueEntries, + isPublicQueueEntry, projectPublicQueueEntry, - resolveDurableRetryAttemptId, } from '../utils/queue-enrichment.js'; import { resolveUserId } from '../utils/request-identity.js'; import { type LiveExecutionCandidate, registerActiveExecutionRoutes } from './active-execution-routes.js'; import { getMultiMentionOrchestrator } from './callback-multi-mention-routes.js'; +import { resolveQueueAuthorIntentByCatId } from './message-disposition-admission.js'; +import { admitThreadParticipants } from './thread-participant-admission.js'; interface ManagedCommandWakeRecoveryLike { retireCarrier(messageIds: readonly string[], reason: 'withdrawn'): Promise; @@ -74,18 +81,15 @@ export interface QueueRoutesOptions { invocationQueue: InvocationQueue; queueProcessor: QueueProcessor; invocationTracker: InvocationTrackerLike; - /** Exact concrete provider carrier used by active-turn composer/reminder surfaces. */ + /** Exact concrete provider carrier used by active-turn delivery surfaces. */ resolveCarrierCapability?: (catId: CatId) => FreshnessCarrierCapability | undefined; + /** Current roster availability, rechecked when a Steer plan is committed. */ + isCatAvailable?: (catId: CatId) => boolean; /** Shared owner-aware session lock released by explicit terminal actions. */ agentSessionMutex?: AgentSessionMutexLike; socketManager: SocketManager; - /** MessageStore supplies receipt hydration; Queue withdrawal never deletes author history. */ + /** MessageStore supplies History preview/lifecycle truth; Queue withdrawal never deletes author history. */ messageStore?: IMessageStore; - /** Canonical authority guards shared with the message-bound Retry route. */ - retryAuthorityPreflight?: Pick; - retryAuthorityCommitter?: Pick; - /** F254: persist reorder/promote mutations before acknowledging them. */ - queueCustodyCoordinator?: QueuedMessageCustodyCoordinator; /** F194 Phase B: canonical liveness read sources (record + draft). When omitted, * GET /queue's activeInvocations falls back to legacy tracker-only enumeration * for backward compat in tests. */ @@ -124,6 +128,24 @@ interface ProcessOwnerSnapshot { } const processOwnerSnapshotByRequest = new WeakMap>(); +const CONTROL_PLANE_RETRY_ATTEMPTS = 3; +const CONTROL_PLANE_RETRY_DELAY_MS = 50; + +type ExecutionFailureReason = 'control_plane_unavailable' | 'execution_owner_lost'; + +function executionFailureExplanation(reason: ExecutionFailureReason): string { + return reason === 'control_plane_unavailable' ? '执行控制面不可用' : '执行进程归属已丢失'; +} + +function completeExecutionFailureContent(response: StoredMessage, reason: ExecutionFailureReason): string { + const existing = response.content.trim(); + const failureMessage = executionFailureExplanation(reason); + return [existing, ...(!existing.includes(failureMessage) ? [failureMessage] : [])].filter(Boolean).join('\n\n'); +} + +async function waitForControlPlaneRetry(): Promise { + await new Promise((resolve) => setTimeout(resolve, CONTROL_PLANE_RETRY_DELAY_MS)); +} async function processOwnerSnapshotForRequest( request: FastifyRequest, @@ -143,6 +165,46 @@ async function processOwnerSnapshotForRequest( return pending; } +async function retryProcessOwnerSnapshot( + request: FastifyRequest, + service: CliExecutionOwnerService | undefined, +): Promise { + if (!service) return { owners: [], complete: true }; + let latest = await processOwnerSnapshotForRequest(request, service); + for (let attempt = 1; !latest.complete && attempt < CONTROL_PLANE_RETRY_ATTEMPTS; attempt += 1) { + await waitForControlPlaneRetry(); + try { + const snapshot = await service.listLive(); + latest = { owners: snapshot.owners, complete: snapshot.complete }; + } catch (err) { + request.log.warn({ err, attempt }, 'CLI execution owner retry failed'); + latest = { owners: [], complete: false }; + } + } + processOwnerSnapshotByRequest.set(request, Promise.resolve(latest)); + return latest; +} + +function emitLifecycleMessageUpdated(socketManager: SocketManager, userId: string, message: StoredMessage): void { + if (!message.lifecycle) return; + socketManager.emitToUser(userId, 'message_lifecycle_updated', { + threadId: message.threadId, + message: { + id: message.id, + ...(message.from ? { from: message.from } : {}), + catId: message.catId, + content: message.content, + lifecycle: message.lifecycle, + timestamp: message.timestamp, + ...(message.timelineOrderAt !== undefined ? { timelineOrderAt: message.timelineOrderAt } : {}), + ...(message.contentBlocks ? { contentBlocks: message.contentBlocks } : {}), + ...(message.extra ? { extra: message.extra } : {}), + ...(message.origin ? { origin: message.origin } : {}), + ...(message.replyTo ? { replyTo: message.replyTo } : {}), + }, + }); +} + function projectCanonicalLiveCandidate( threadId: string, fallbackUserId: string, @@ -151,11 +213,19 @@ function projectCanonicalLiveCandidate( ): LiveExecutionCandidate { const ownerUserId = tracker.getUserId(threadId, candidate.catId) ?? fallbackUserId; const trackerExecutionId = tracker.getExecutionId?.(threadId, candidate.catId); + const append = projectLifecycleAppendCapability({ + threadId, + userId: fallbackUserId, + targetId: candidate.catId, + activeRun: candidate.activeRun, + invocationTracker: tracker, + }); return { ...candidate, ...(candidate.turnInvocationId ? { invocationId: candidate.turnInvocationId } : {}), ownerUserId, controlSource: candidate.executionId && trackerExecutionId === candidate.executionId ? 'tracker' : 'unavailable', + ...(append.available ? { inputCapabilities: { append: append.capability } } : {}), }; } @@ -235,10 +305,6 @@ async function terminalizeTrackerlessTurn(input: { } } -function queueEntryMessageIds(entry: Pick): string[] { - return [entry.messageId, ...entry.mergedMessageIds].filter((messageId): messageId is string => !!messageId); -} - async function retireWithdrawnManagedWake(opts: QueueRoutesOptions, entry: QueueEntry): Promise { const recovery = opts.getManagedCommandWakeRecovery?.(); if (!recovery) return; @@ -252,87 +318,65 @@ const moveBodySchema = z.object({ const steerBodySchema = z .object({ mode: z.literal('immediate').optional(), + targetCatId: z.string().min(1).optional(), }) .strict(); -const steerBatchBodySchema = z +const continueBodySchema = z.object({ targetCatId: z.string().min(1) }).strict(); + +const steerTargetsBodySchema = z .object({ - entryIds: z.array(z.string().min(1)).min(2).max(5), + sourceRecordId: z.string().min(1), + observedPendingTargetIds: z.array(z.string().min(1)), + targets: z + .array( + z + .object({ + targetCatId: z.string().min(1), + strategy: z.enum(['guide_reply', 'interrupt_reply']), + membershipAtOpen: z.enum(['member', 'admit']), + }) + .strict(), + ) + .min(1) + .superRefine((targets, context) => { + const seen = new Set(); + for (const target of targets) { + if (seen.has(target.targetCatId)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: `Duplicate target: ${target.targetCatId}` }); + } + seen.add(target.targetCatId); + } + }), }) .strict() - .superRefine(({ entryIds }, ctx) => { - if (new Set(entryIds).size !== entryIds.length) { - ctx.addIssue({ code: 'custom', path: ['entryIds'], message: 'entryIds must be distinct' }); + .superRefine((value, context) => { + if (new Set(value.observedPendingTargetIds).size !== value.observedPendingTargetIds.length) { + context.addIssue({ code: z.ZodIssueCode.custom, message: 'Duplicate observed pending target' }); } }); -const remindBodySchema = z - .object({ - targetCatId: z.string().min(1), - }) - .strict(); - -const retryTargetBodySchema = z +const appendBodySchema = z .object({ - recoveryActionId: z.string().min(1), + expectedQueueRevision: z.string().min(1), + expectedRuns: z + .array( + z + .object({ + targetId: z.string().min(1), + invocationId: z.string().min(1), + responseMessageId: z.string().min(1), + }) + .strict(), + ) + .min(1), }) .strict(); -type ReminderRequestResolution = - | { - ok: true; - entry: QueueEntry; - invocationId: string; - coordinator: QueuedMessageCustodyCoordinator; - } - | { ok: false; status: 404 | 409 | 503; error: string; code: string }; - function projectQueueStartResult(result: { started: boolean; entry?: QueueEntry }) { - return result.entry ? { ...result, entry: projectPublicQueueEntry(result.entry) } : result; -} - -function resolveReminderRequest(input: { - entry: QueueEntry | undefined; - targetCatId: string; - threadId: string; - userId: string; - invocationTracker: InvocationTrackerLike; - coordinator: QueuedMessageCustodyCoordinator | undefined; - carrierCapability: FreshnessCarrierCapability | undefined; -}): ReminderRequestResolution { - if (!input.entry) return { ok: false, status: 404, error: '队列条目不存在', code: 'ENTRY_NOT_FOUND' }; - if (!input.entry.targetCats.includes(input.targetCatId)) { - return { ok: false, status: 409, error: '该猫已不再等待处理此消息', code: 'TARGET_NOT_PENDING' }; - } - if (input.entry.status === 'processing') { - return { ok: false, status: 409, error: '该消息已经进入处理,无需提醒', code: 'ENTRY_PROCESSING' }; - } - if (!input.coordinator) { - return { ok: false, status: 503, error: '持久回执暂不可用', code: 'RECEIPT_STORE_UNAVAILABLE' }; - } - if (!input.carrierCapability || input.carrierCapability.deliverySemantics === 'undeclared') { - return { - ok: false, - status: 409, - error: '当前猫的本轮提醒能力未声明,已按下一件工作处理', - code: 'REMINDER_CAPABILITY_UNDECLARED', - }; - } - if (input.carrierCapability.deliverySemantics !== 'exact_active_turn') { - return { - ok: false, - status: 409, - error: '当前接入不支持本轮提醒', - code: 'REMINDER_UNSUPPORTED_CARRIER', - }; - } - const activeUserId = input.invocationTracker.getUserId(input.threadId, input.targetCatId); - const invocationId = input.invocationTracker.getExecutionId?.(input.threadId, input.targetCatId); - const active = input.invocationTracker.has(input.threadId, input.targetCatId); - if (!active || activeUserId !== input.userId || !invocationId) { - return { ok: false, status: 409, error: '当前没有可接收提醒的工作轮次', code: 'NO_ACTIVE_INVOCATION' }; - } - return { ok: true, entry: input.entry, invocationId, coordinator: input.coordinator }; + return result.entry && isPublicQueueEntry(result.entry) + ? { ...result, entry: projectPublicQueueEntry(result.entry) } + : { started: result.started }; } /** @@ -344,7 +388,7 @@ async function guardThreadOwnership( reply: FastifyReply, threadStore: IThreadStore, threadId: string, -): Promise<{ userId: string } | null> { +): Promise<{ userId: string; thread: Thread } | null> { const userId = resolveUserId(request, {}); if (!userId) { reply.status(401); @@ -366,30 +410,12 @@ async function guardThreadOwnership( return null; } - return { userId }; + return { userId, thread }; } export const queueRoutes: FastifyPluginAsync = async (app, opts) => { const { threadStore, invocationQueue, queueProcessor, invocationTracker, socketManager, messageStore } = opts; const sessionLocks = opts.agentSessionMutex ?? agentSessionMutex; - const persistQueueEntries = async (threadId: string, userId: string, entryIds: readonly string[]) => { - if (!opts.queueCustodyCoordinator) return; - for (const entryId of new Set(entryIds)) { - const entry = invocationQueue.getEntrySnapshot(threadId, userId, entryId); - if (entry) await opts.queueCustodyCoordinator.persistEntry(entry); - } - }; - - const validateSteerSources = async (threadId: string, userId: string, entryIds: readonly string[], catId: string) => { - if (!messageStore) return; - for (const entryId of entryIds) { - const entry = invocationQueue.getEntrySnapshot(threadId, userId, entryId); - if (!entry || !canSteerQueueSources(entry, await readQueueCarrierMessages(entry, messageStore), catId)) { - throw new Error(`Steer source is no longer executable: ${entryId}/${catId}`); - } - } - }; - const releaseAgentSessionLocks = ( scope: { threadId: string; userId: string; catId?: string }, request: FastifyRequest, @@ -405,11 +431,20 @@ export const queueRoutes: FastifyPluginAsync = async (app, o } return result; }; + const releaseSlotUnlessExecutionOwnsRetirement = (threadId: string, catId: string): void => { + // A QueueProcessor execute promise must retain its reservation until its + // finally path records the cancellation and drains the next row. Releasing + // it here makes that completion stale and strands following Queue work. + if (!queueProcessor.hasProcessingSlotReservation(threadId, catId)) { + queueProcessor.releaseSlot(threadId, catId); + } + }; const preemptSteerTarget = async ( threadId: string, userId: string, steerCatId: string, + reservedEntryId: string, request: FastifyRequest, ): Promise<{ ok: true; deferred: boolean } | { ok: false; status: 409 | 503; error: string; code: string }> => { if (invocationTracker.has(threadId, steerCatId)) { @@ -434,18 +469,16 @@ export const queueRoutes: FastifyPluginAsync = async (app, o if (!cancelResult.cancelled && invocationTracker.has(threadId, steerCatId)) { return { ok: false, status: 409, error: '当前调用无法取消,无法立即执行', code: 'INVOCATION_CANCEL_FAILED' }; } - queueProcessor.clearPause(threadId, steerCatId); queueProcessor.releaseSlot(threadId, steerCatId); return { ok: true, deferred: false }; } releaseAgentSessionLocks({ threadId, userId, catId: steerCatId }, request, 'steer'); - const inflight = invocationQueue.findProcessingByCat(threadId, steerCatId); - if (inflight && inflight.userId !== userId) { + const inflight = invocationQueue.findProcessingByCat(threadId, steerCatId, reservedEntryId); + if (inflight && queueEntryOwnerId(inflight) !== userId) { return { ok: false, status: 409, error: '当前有其他用户的调用在执行,无法立即执行', code: 'INVOCATION_ACTIVE' }; } if (!inflight) { - queueProcessor.clearPause(threadId, steerCatId); return { ok: true, deferred: false }; } @@ -453,7 +486,6 @@ export const queueRoutes: FastifyPluginAsync = async (app, o // a retirement barrier before awaiting durable terminalization; the old // coroutine loses its reservation immediately, while the complete group // remains visible and recoverable until every durable projection closes. - queueProcessor.clearPause(threadId, steerCatId); const retirement = await queueProcessor.retirePrestartProcessingGroup(threadId, steerCatId, userId); if (retirement === 'state_changed') { return { ok: false, status: 409, error: '启动中的队列状态已变化,请重试', code: 'PRESTART_STATE_CHANGED' }; @@ -471,6 +503,131 @@ export const queueRoutes: FastifyPluginAsync = async (app, o return { ok: true, deferred: false }; }; + const failUncontrollableInvocation = async (input: { + threadId: string; + userId: string; + catId: string; + executionId: string; + invocationId?: string; + request: FastifyRequest; + reason?: ExecutionFailureReason; + }): Promise<{ reconciled: boolean }> => { + if (!messageStore || !opts.invocationRecordStore) { + throw new Error('durable execution failure stores are unavailable'); + } + const failedAt = Date.now(); + const failureReason = input.reason ?? 'control_plane_unavailable'; + const childExecutions = opts.turnExecutionStore + ? (await opts.turnExecutionStore.listByParent(input.executionId)).filter( + (child) => child.threadId === input.threadId && child.userId === input.userId && child.catId === input.catId, + ) + : []; + const childInvocationIds = [ + ...new Set([ + ...(input.invocationId ? [input.invocationId] : []), + ...childExecutions.map((child) => child.invocationId), + ]), + ]; + let responseTerminalized = false; + for (const invocationId of childInvocationIds) { + const response = await messageStore.getByIdempotencyKey( + input.userId, + input.threadId, + `message-lifecycle-response:${invocationId}`, + ); + if (!response?.lifecycle || response.lifecycle.kind !== 'response') continue; + const result = await messageStore.commitLifecycleResponseTerminal(response.id, { + invocationId, + status: 'failed', + completedAt: Math.max(failedAt, response.lifecycle.startedAt), + reason: failureReason, + content: completeExecutionFailureContent(response, failureReason), + ...(response.contentBlocks ? { contentBlocks: response.contentBlocks } : {}), + ...(response.toolEvents ? { toolEvents: response.toolEvents } : {}), + ...(response.metadata ? { metadata: response.metadata } : {}), + ...(response.extra ? { extra: response.extra } : {}), + ...(response.thinking ? { thinking: response.thinking } : {}), + ...(response.origin ? { origin: response.origin } : {}), + mentions: response.mentions, + ...(response.mentionsUser ? { mentionsUser: true } : {}), + ...(response.replyTo ? { replyTo: response.replyTo } : {}), + }); + if (result.kind !== 'applied' && result.kind !== 'replayed') { + const resultLifecycle = result.kind === 'conflict' ? result.message.lifecycle : undefined; + if ( + result.kind !== 'conflict' || + resultLifecycle?.kind !== 'response' || + resultLifecycle.status === 'processing' + ) { + throw new Error(`control-plane failure response terminalization rejected: ${result.kind}`); + } + } + const terminalMessage = result.message; + await settleLifecycleResponseInputs(messageStore, terminalMessage, terminalMessage.id); + emitLifecycleMessageUpdated(socketManager, input.userId, terminalMessage); + responseTerminalized = true; + } + + const inflight = invocationQueue.findProcessingByCat(input.threadId, input.catId); + if (!responseTerminalized && inflight) { + if (queueEntryOwnerId(inflight) !== input.userId) throw new Error('pre-start execution owner changed'); + const outcome = await queueProcessor.failPrestartProcessingGroup( + input.threadId, + input.catId, + input.userId, + failureReason, + ); + if (outcome !== 'retired') throw new Error(`pre-start failure terminalization ${outcome}`); + } + + for (const invocationId of childInvocationIds) { + await opts.turnExecutionStore?.transitionTerminal(invocationId, { + status: 'failed', + endedAt: failedAt, + terminalReason: failureReason, + }); + } + const record = await opts.invocationRecordStore.get(input.executionId); + const siblingCatIds = (record?.targetCats ?? []).filter((catId) => catId !== input.catId); + const ownerSnapshot = await processOwnerSnapshotForRequest(input.request, opts.cliExecutionOwnerService); + const siblingStillActive = siblingCatIds.some( + (catId) => + invocationTracker.has(input.threadId, catId) || + ownerSnapshot.owners.some( + (owner) => + owner.executionId === input.executionId && + owner.threadId === input.threadId && + owner.userId === input.userId && + owner.catId === catId, + ), + ); + if (record?.status === 'running' && !siblingStillActive) { + const updated = await opts.invocationRecordStore.update(input.executionId, { + status: 'failed', + error: failureReason, + expectedStatus: 'running', + }); + if (!updated) throw new Error('control-plane failure parent terminalization lost its running fence'); + } + + releaseAgentSessionLocks( + { threadId: input.threadId, userId: input.userId, catId: input.catId }, + input.request, + 'cancel', + ); + if (queueProcessor.canReleaseSlotForUser(input.threadId, input.catId, input.userId)) { + queueProcessor.releaseSlot(input.threadId, input.catId); + } + for (const message of buildCancelMessages({ + cancelled: true, + catIds: [input.catId], + executionIds: [input.executionId], + })) { + socketManager.broadcastAgentMessage(message, input.threadId); + } + return { reconciled: true }; + }; + const cancelProcessOwnedInvocation = async (input: { threadId: string; userId: string; @@ -479,15 +636,32 @@ export const queueRoutes: FastifyPluginAsync = async (app, o invocationId: string; ownerUserId?: string; request: FastifyRequest; - }): Promise<{ cancelled: boolean; controlPlaneUnavailable?: boolean }> => { - const processResult = await opts.cliExecutionOwnerService?.terminateExact({ + }): Promise<{ cancelled: boolean; reconciled?: boolean; controlPlaneUnavailable?: boolean }> => { + const executionOwner = { executionId: input.executionId, invocationId: input.invocationId, threadId: input.threadId, catId: input.catId, userId: input.ownerUserId ?? input.userId, - }); - if (!processResult?.complete) return { cancelled: false, controlPlaneUnavailable: true }; + }; + let processResult = await opts.cliExecutionOwnerService?.terminateExact(executionOwner); + for ( + let attempt = 1; + processResult && !processResult.complete && attempt < CONTROL_PLANE_RETRY_ATTEMPTS; + attempt += 1 + ) { + await waitForControlPlaneRetry(); + processResult = await opts.cliExecutionOwnerService?.terminateExact(executionOwner); + } + if (!processResult?.complete) { + try { + await failUncontrollableInvocation({ ...input, invocationId: input.invocationId }); + return { cancelled: false, reconciled: true }; + } catch (err) { + input.request.log.error({ err, ...executionOwner }, 'failed to persist control-plane failure terminal'); + return { cancelled: false, controlPlaneUnavailable: true }; + } + } // The request already resolved this exact live owner. A zero-signal result // means it exited in the snapshot-to-signal race; cancellation is // idempotently complete and its durable child truth still needs closing. @@ -512,7 +686,6 @@ export const queueRoutes: FastifyPluginAsync = async (app, o })) { socketManager.broadcastAgentMessage(message, input.threadId); } - queueProcessor.clearPause(input.threadId, input.catId); return { cancelled: true }; }; @@ -533,12 +706,213 @@ export const queueRoutes: FastifyPluginAsync = async (app, o for (const message of buildCancelMessages({ ...cancelResult, catIds: [input.catId] })) { socketManager.broadcastAgentMessage(message, input.threadId); } - queueProcessor.clearPause(input.threadId, input.catId); - queueProcessor.releaseSlot(input.threadId, input.catId); + releaseSlotUnlessExecutionOwnsRetirement(input.threadId, input.catId); } return { cancelled: cancelResult.cancelled }; }; + const reconcileInactiveLiveInvocation = async (input: { + threadId: string; + userId: string; + catId: string; + executionId: string; + request: FastifyRequest; + }): Promise<{ + reconciled: boolean; + replacement?: boolean; + controlPlaneUnavailable?: boolean; + error?: string; + code?: string; + }> => { + const trackerExecutionId = invocationTracker.getExecutionId?.(input.threadId, input.catId); + if (invocationTracker.has(input.threadId, input.catId)) { + const result = cancelTrackedInvocation(input); + return { + reconciled: result.cancelled, + ...(trackerExecutionId !== input.executionId ? { replacement: true } : {}), + }; + } + + const processSnapshot = await retryProcessOwnerSnapshot(input.request, opts.cliExecutionOwnerService); + if (!processSnapshot.complete) { + try { + return await failUncontrollableInvocation(input); + } catch (err) { + input.request.log.error( + { err, threadId: input.threadId, catId: input.catId, executionId: input.executionId }, + 'failed to persist control-plane failure terminal', + ); + return { + reconciled: false, + controlPlaneUnavailable: true, + error: '执行失败终态未能持久化,请重试', + code: 'EXECUTION_TERMINAL_PERSISTENCE_UNAVAILABLE', + }; + } + } + const processOwner = processSnapshot.owners.find( + (owner) => owner.threadId === input.threadId && owner.catId === input.catId && owner.userId === input.userId, + ); + if (processOwner) { + const result = await cancelProcessOwnedInvocation({ + ...input, + executionId: processOwner.executionId, + invocationId: processOwner.invocationId, + ownerUserId: processOwner.userId, + }); + return { + reconciled: result.cancelled || result.reconciled === true, + ...(processOwner.executionId !== input.executionId ? { replacement: true } : {}), + ...(result.controlPlaneUnavailable ? { controlPlaneUnavailable: true } : {}), + }; + } + + const inflight = invocationQueue.findProcessingByCat(input.threadId, input.catId); + if (inflight && queueEntryOwnerId(inflight) !== input.userId) { + return { + reconciled: false, + controlPlaneUnavailable: true, + error: '当前执行归属已变化,请重试', + code: 'EXECUTION_OWNER_CHANGED', + }; + } + if (inflight) { + const retirement = await queueProcessor.retirePrestartProcessingGroup(input.threadId, input.catId, input.userId); + if (retirement === 'terminalization_failed') { + return { + reconciled: false, + controlPlaneUnavailable: true, + error: '启动中的执行未能写入持久终态,请重试', + code: 'PRESTART_TERMINALIZATION_FAILED', + }; + } + if (retirement === 'state_changed') { + return { + reconciled: false, + controlPlaneUnavailable: true, + error: '启动中的执行状态已变化,请重试', + code: 'PRESTART_STATE_CHANGED', + }; + } + } + + // Re-check after durable retirement: a newer run may have claimed the cat + // while the request awaited Redis. Never release that run's lock or slot. + if (invocationTracker.has(input.threadId, input.catId)) { + const replacementExecutionId = invocationTracker.getExecutionId?.(input.threadId, input.catId); + const result = cancelTrackedInvocation(input); + return { + reconciled: result.cancelled, + ...(replacementExecutionId !== input.executionId ? { replacement: true } : {}), + }; + } + + let canceledRecord = false; + if (opts.invocationRecordStore) { + const runningRecords = await opts.invocationRecordStore.listRunningByThread(input.threadId, input.userId); + const exactRecord = runningRecords.find( + (record) => record.id === input.executionId && (record.targetCats as string[]).includes(input.catId), + ); + if (exactRecord) { + const siblingStillActive = (exactRecord.targetCats as string[]) + .filter((catId) => catId !== input.catId) + .some((catId) => invocationTracker.has(input.threadId, catId)); + if (!siblingStillActive) { + await opts.invocationRecordStore.update(exactRecord.id, { status: 'canceled' }); + canceledRecord = true; + } + } + } + + if (invocationTracker.has(input.threadId, input.catId)) { + const replacementExecutionId = invocationTracker.getExecutionId?.(input.threadId, input.catId); + const result = cancelTrackedInvocation(input); + return { + reconciled: result.cancelled, + ...(replacementExecutionId !== input.executionId ? { replacement: true } : {}), + }; + } + const lockRelease = releaseAgentSessionLocks( + { threadId: input.threadId, userId: input.userId, catId: input.catId }, + input.request, + 'cancel', + ); + const canReleaseSlot = queueProcessor.canReleaseSlotForUser(input.threadId, input.catId, input.userId); + if (canReleaseSlot && !invocationTracker.has(input.threadId, input.catId)) { + queueProcessor.releaseSlot(input.threadId, input.catId); + } + // The socket projection is only a legacy witness. Once every authoritative + // source says the exact run is gone, publish convergence even when there was + // no remaining local artifact to remove. + for (const message of buildCancelMessages({ + cancelled: true, + catIds: [input.catId], + executionIds: [input.executionId], + })) { + socketManager.broadcastAgentMessage(message, input.threadId); + } + return { + reconciled: Boolean( + inflight || + canceledRecord || + lockRelease.releasedHolders > 0 || + lockRelease.rejectedWaiters > 0 || + canReleaseSlot, + ), + }; + }; + + const resolveAndRepairLiveExecutions = async ( + threadId: string, + userId: string, + request: FastifyRequest, + ): Promise => { + const candidates = await resolveLiveExecutionCandidates(threadId, userId, request, opts); + if (!opts.invocationRecordStore) return candidates; + const processSnapshot = await processOwnerSnapshotForRequest(request, opts.cliExecutionOwnerService); + if (!processSnapshot.complete) return candidates; + const ownerExecutionTargets = new Set( + processSnapshot.owners + .filter((owner) => owner.threadId === threadId && owner.userId === userId) + .map((owner) => `${owner.executionId}\u0000${owner.catId}`), + ); + const repairedExecutionIds = new Set(); + const runningRecords = await opts.invocationRecordStore.listRunningByThread(threadId, userId); + for (const record of runningRecords) { + if (Date.now() - record.updatedAt <= DEFAULT_PRESTART_RESERVATION_TTL_MS) continue; + const repairTargets = (record.targetCats as string[]).filter((catId) => { + const canceled = invocationTracker.getCanceledSlotIdentity?.(threadId, catId); + const exactCanceledOwner = canceled?.executionId === record.id && canceled.userId === userId; + return ( + !exactCanceledOwner && + !invocationTracker.has(threadId, catId) && + !ownerExecutionTargets.has(`${record.id}\u0000${catId}`) + ); + }); + if (repairTargets.length === 0) continue; + try { + for (const catId of repairTargets) { + await failUncontrollableInvocation({ + threadId, + userId, + catId, + executionId: record.id, + request, + reason: 'execution_owner_lost', + }); + } + const repairedRecord = await opts.invocationRecordStore.get(record.id); + if (repairedRecord?.status !== 'running') repairedExecutionIds.add(record.id); + } catch (err) { + request.log.error( + { err, threadId, userId, executionId: record.id, targetCats: repairTargets }, + 'active execution read-repair failed closed', + ); + } + } + return candidates.filter((candidate) => !candidate.executionId || !repairedExecutionIds.has(candidate.executionId)); + }; + registerActiveExecutionRoutes(app, { threadStore, invocationTracker, @@ -557,10 +931,7 @@ export const queueRoutes: FastifyPluginAsync = async (app, o }, } : {}), - resolveLiveExecutions: (threadId, userId, request) => - resolveLiveExecutionCandidates(threadId, userId, request, opts), - isLiveExecutionControlPlaneComplete: async (request) => - (await processOwnerSnapshotForRequest(request, opts.cliExecutionOwnerService)).complete, + resolveLiveExecutions: resolveAndRepairLiveExecutions, cancelExactLiveInvocation: async ({ threadId, userId, catId, executionId, candidate, request }) => { if (candidate.controlSource === 'process_owner' && opts.cliExecutionOwnerService && candidate.invocationId) { return cancelProcessOwnedInvocation({ @@ -573,8 +944,48 @@ export const queueRoutes: FastifyPluginAsync = async (app, o request, }); } - return cancelTrackedInvocation({ threadId, userId, catId, request }); + if (candidate.controlSource === 'tracker' && invocationTracker.has(threadId, catId)) { + return cancelTrackedInvocation({ threadId, userId, catId, request }); + } + const snapshot = await retryProcessOwnerSnapshot(request, opts.cliExecutionOwnerService); + if (!snapshot.complete) { + try { + await failUncontrollableInvocation({ + threadId, + userId, + catId, + executionId, + ...(candidate.invocationId ? { invocationId: candidate.invocationId } : {}), + request, + }); + return { cancelled: false, reconciled: true }; + } catch (err) { + request.log.error({ err, threadId, catId, executionId }, 'failed to persist control-plane failure terminal'); + return { cancelled: false, controlPlaneUnavailable: true }; + } + } + const owner = snapshot.owners.find( + (item) => item.threadId === threadId && item.catId === catId && item.userId === userId, + ); + if (owner) { + return cancelProcessOwnedInvocation({ + threadId, + userId, + catId, + executionId: owner.executionId, + invocationId: owner.invocationId, + ownerUserId: owner.userId, + request, + }); + } + const reconciled = await reconcileInactiveLiveInvocation({ threadId, userId, catId, executionId, request }); + return { + cancelled: false, + reconciled: reconciled.reconciled, + ...(reconciled.controlPlaneUnavailable ? { controlPlaneUnavailable: true } : {}), + }; }, + reconcileInactiveLiveInvocation, }); // GET /api/threads/:threadId/queue @@ -600,17 +1011,72 @@ export const queueRoutes: FastifyPluginAsync = async (app, o provider: 'other' as const, carrier: 'other' as const, deliverySemantics: 'undeclared' as const, + activeInvocationGuidance: 'undeclared' as const, }, })); - const enrichedQueue = await enrichQueueEntries(invocationQueue.list(threadId, guard.userId), messageStore); + const queueEntries = invocationQueue.list(threadId, guard.userId); + const queueRevision = invocationQueue.snapshotRevision(threadId, guard.userId); + const enrichedQueue = await enrichQueueEntries(queueEntries, messageStore); return { - queue: enrichedQueue, - paused: queueProcessor.isPaused(threadId), - pauseReason: queueProcessor.getPauseReason(threadId), + queue: enrichedQueue.map((entry) => { + const internal = queueEntries.find((candidate) => candidate.id === entry.id); + if (!internal) return entry; + const projection = projectLifecycleAppendAction({ + threadId, + userId: guard.userId, + queueRevision, + entry: internal, + invocationTracker, + }); + return projection.available ? { ...entry, lifecycleActions: { append: projection.action } } : entry; + }), + queueRevision, activeInvocations, }; }); + app.post<{ Params: { threadId: string; entryId: string } }>( + '/api/threads/:threadId/queue/:entryId/append', + async (request, reply) => { + const { threadId, entryId } = request.params; + const guard = await guardThreadOwnership(request, reply, threadStore, threadId); + if (!guard) return; + const parsed = appendBodySchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Append 请求格式无效', code: 'INVALID_APPEND_REQUEST' }; + } + const result = await queueProcessor.appendExactEntry({ + threadId, + userId: guard.userId, + entryId, + ...parsed.data, + }); + if (result.outcome === 'appended') { + const queueRevision = invocationQueue.snapshotRevision(threadId, guard.userId); + return { + ...result, + queue: await enrichQueueEntries(invocationQueue.list(threadId, guard.userId), messageStore), + queueRevision, + }; + } + const status = result.reason === 'custody_unavailable' ? 503 : result.reason === 'provider_rejected' ? 502 : 409; + reply.status(status); + return { + error: + result.reason === 'append_unavailable' + ? '当前 Agent Client 已不再接受 Append' + : result.reason === 'state_changed' + ? 'Queue 或 Active Run 已变化,请刷新后重试' + : result.reason === 'provider_rejected' + ? '部分或全部 Agent Client 拒绝了 Append;失败回执已保留' + : 'Append 持久化暂不可用', + code: result.reason.toUpperCase(), + ...(result.rejectedTargetIds ? { rejectedTargetIds: result.rejectedTargetIds } : {}), + }; + }, + ); + // DELETE /api/threads/:threadId/queue/:entryId app.delete<{ Params: { threadId: string; entryId: string }; Querystring: { deleteMessage?: string } }>( '/api/threads/:threadId/queue/:entryId', @@ -622,47 +1088,49 @@ export const queueRoutes: FastifyPluginAsync = async (app, o // Check if entry exists and is not processing const entries = invocationQueue.list(threadId, guard.userId); const entry = entries.find((e) => e.id === entryId); - if (!entry) { + if (!entry || !isPublicQueueEntry(entry)) { reply.status(404); return { error: '队列条目不存在', code: 'ENTRY_NOT_FOUND' }; } - if (entry.status === 'processing') { + if (entry.status !== 'queued') { reply.status(409); return { error: '条目正在处理中,无法撤回', code: 'ENTRY_PROCESSING' }; } - if (invocationQueue.hasUnsettledExactSteerReservation(threadId, guard.userId, entryId)) { + const claimed = await invocationQueue.claimQueuedEntryForWithdrawal(threadId, guard.userId, entryId); + if (!claimed) { reply.status(409); - return { error: 'Steer 正在抢占,暂无法撤回', code: 'ENTRY_STEERING' }; + return { error: '条目正在处理中,无法撤回', code: 'ENTRY_PROCESSING' }; } - - // Remove entry from queue FIRST (sync) to close the TOCTOU window — - // prevents queue processor from promoting to 'processing' during the - // async contentBlocks snapshot below. - const nextEntryId = entries[entries.findIndex((candidate) => candidate.id === entryId) + 1]?.id; - const removed = invocationQueue.remove(threadId, guard.userId, entryId); - if (removed && opts.queueCustodyCoordinator) { - try { - await opts.queueCustodyCoordinator.withdrawEntry(removed); - } catch (err) { - invocationQueue.restoreDurableEntry(removed, { beforeEntryId: nextEntryId }); - request.log.error({ err, entryId, threadId }, 'durable Queue withdrawal failed; entry restored'); - await emitQueueUpdated( - socketManager, - guard.userId, - threadId, - invocationQueue.list(threadId, guard.userId), - messageStore, - 'withdraw_failed', - ); - reply.status(503); - return { - error: '撤出未完成,消息仍保留在待处理队列中', - code: 'QUEUE_WITHDRAWAL_FAILED', - queue: await enrichQueueEntries(invocationQueue.list(threadId, guard.userId), messageStore), - }; + let removed: QueueEntry | null = null; + try { + const messageIds = queueEntryMessageIds(claimed); + const remaining = invocationQueue.list(threadId, guard.userId).filter((candidate) => candidate.id !== entryId); + for (const messageId of messageIds) { + const hasSibling = remaining.some((candidate) => queueEntryMessageIds(candidate).includes(messageId)); + if (!hasSibling) await messageStore?.markCanceled(messageId); } + removed = await invocationQueue.commitClaimedWithdrawal(threadId, entryId); + if (!removed) throw new Error('Queue withdrawal claim changed before commit'); + } catch (err) { + await invocationQueue.restoreClaimedEntries(threadId, [entryId]); + request.log.error({ err, entryId, threadId }, 'durable Queue withdrawal failed; entry restored'); + await emitQueueUpdated( + socketManager, + guard.userId, + threadId, + invocationQueue.list(threadId, guard.userId), + messageStore, + 'withdraw_failed', + ); + reply.status(503); + return { + error: '撤出未完成,消息仍保留在待处理队列中', + code: 'QUEUE_WITHDRAWAL_FAILED', + queue: await enrichQueueEntries(invocationQueue.list(threadId, guard.userId), messageStore), + }; } if (removed) { + queueProcessor.registerCallerDispatchQueueWithdrawal(removed); try { await retireWithdrawnManagedWake(opts, removed); } catch (err) { @@ -680,94 +1148,258 @@ export const queueRoutes: FastifyPluginAsync = async (app, o invocationQueue.list(threadId, guard.userId), messageStore, 'removed', - { receiptMessageIds: removed ? queueEntryMessageIds(removed) : [] }, ); return { removed: removed ? projectPublicQueueEntry(removed) : removed }; }, ); - // POST /api/threads/:threadId/queue/next - app.post<{ Params: { threadId: string } }>('/api/threads/:threadId/queue/next', async (request, reply) => { - const { threadId } = request.params; - const guard = await guardThreadOwnership(request, reply, threadStore, threadId); - if (!guard) return; + // GET /api/threads/:threadId/queue/:entryId/targets + // Join the one pending Queue target set with actual History dispatch refs. + // Queue never manufactures delivered or terminal target state. + app.get<{ Params: { threadId: string; entryId: string } }>( + '/api/threads/:threadId/queue/:entryId/targets', + async (request, reply) => { + const { threadId, entryId } = request.params; + const guard = await guardThreadOwnership(request, reply, threadStore, threadId); + if (!guard) return; - const result = await queueProcessor.processNext(threadId, guard.userId); - return projectQueueStartResult(result); - }); + const anchor = invocationQueue + .list(threadId, guard.userId) + .find((candidate) => candidate.id === entryId && isPublicQueueEntry(candidate)); + if ( + !anchor || + anchor.status !== 'queued' || + anchor.kind !== 'conversation_input' || + !anchor.payload.messageId || + isSystemPinnedQueueEntry(anchor) + ) { + reply.status(409); + return { error: '该消息当前不可 Steer', code: 'STEER_ENTRY_UNAVAILABLE' }; + } - // POST /api/threads/:threadId/queue/:entryId/remind - // Non-interrupting: records one exact attempt for the current invocation and waits - // for the existing safe-boundary freshness notice path to deliver it. + const source = await messageStore?.getById(anchor.payload.messageId); + if (!source || source.threadId !== threadId || source.userId !== guard.userId) { + reply.status(409); + return { error: '该消息当前不可 Steer', code: 'STEER_SOURCE_UNAVAILABLE' }; + } + const pending = new Set(anchor.targets); + const dispatchRefs = source.lifecycle?.dispatchRefs ?? []; + const dispatched = new Map(dispatchRefs.map((ref) => [ref.targetId, ref])); + const targetIds = [...new Set([...pending, ...dispatched.keys()])]; + return { + sourceRecordId: source.id, + targets: targetIds.map((targetCatId) => ({ + targetCatId, + state: dispatched.get(targetCatId)?.phase ?? 'pending', + actionable: pending.has(targetCatId) && !dispatched.has(targetCatId), + ...(dispatched.get(targetCatId)?.dispatchedAt !== undefined + ? { dispatchedAt: dispatched.get(targetCatId)!.dispatchedAt } + : {}), + ...(dispatched.get(targetCatId)?.statusMessageId + ? { statusMessageId: dispatched.get(targetCatId)!.statusMessageId } + : {}), + })), + }; + }, + ); + + // POST /api/threads/:threadId/queue/:entryId/targets + // Merge explicit modal edits into current Queue + History truth. Delivery + // that wins while the modal is open is an idempotent no-op, not a conflict. app.post<{ Params: { threadId: string; entryId: string } }>( - '/api/threads/:threadId/queue/:entryId/remind', + '/api/threads/:threadId/queue/:entryId/targets', async (request, reply) => { const { threadId, entryId } = request.params; const guard = await guardThreadOwnership(request, reply, threadStore, threadId); if (!guard) return; - const parsed = remindBodySchema.safeParse(request.body ?? {}); + const parsed = steerTargetsBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { reply.status(400); - return { error: 'Invalid body', details: parsed.error.issues }; + return { error: '请选择至少一位当前对话成员', code: 'STEER_TARGETS_REQUIRED' }; + } + if (!messageStore) { + reply.status(503); + return { error: 'Steer 目标映射暂不可用', code: 'STEER_TARGET_MAPPING_UNAVAILABLE' }; } - const { targetCatId } = parsed.data; - const resolution = resolveReminderRequest({ - entry: invocationQueue.list(threadId, guard.userId).find((candidate) => candidate.id === entryId), - targetCatId, + const { sourceRecordId, observedPendingTargetIds } = parsed.data; + if (queueEntryId(sourceRecordId) !== entryId) { + reply.status(400); + return { error: 'Steer 来源身份不匹配', code: 'STEER_SOURCE_IDENTITY_MISMATCH' }; + } + const source = await messageStore.getById(sourceRecordId); + if ( + !source || + source.threadId !== threadId || + source.userId !== guard.userId || + !source.from || + source.recall || + source.deliveryStatus === 'canceled' + ) { + reply.status(409); + return { error: '该消息当前不可 Steer', code: 'STEER_ENTRY_UNAVAILABLE' }; + } + + const currentEntries = invocationQueue.list(threadId, guard.userId); + const anchor = currentEntries.find((candidate) => candidate.id === entryId); + if ( + anchor && + (!isPublicQueueEntry(anchor) || + anchor.status !== 'queued' || + anchor.kind !== 'conversation_input' || + anchor.payload.messageId !== sourceRecordId || + isSystemPinnedQueueEntry(anchor)) + ) { + reply.status(409); + return { error: '该消息当前不可 Steer', code: 'STEER_ENTRY_UNAVAILABLE' }; + } + + // Preserve the user's per-target choice exactly. Unsupported guide is a + // truthful next-work fallback in /continue; it must never be rewritten + // into the destructive /steer action. + const normalizedTargets = parsed.data.targets; + const alreadyDispatched = new Set((source.lifecycle?.dispatchRefs ?? []).map((ref) => ref.targetId)); + const eligibleTargets = normalizedTargets.filter((target) => { + if (alreadyDispatched.has(target.targetCatId)) return false; + const currentMember = guard.thread.participants.includes(target.targetCatId as CatId); + return !( + opts.isCatAvailable?.(target.targetCatId as CatId) === false || + (target.membershipAtOpen === 'member' && !currentMember) + ); + }); + const selectedIds = new Set(eligibleTargets.map((target) => target.targetCatId)); + const observedPending = new Set(observedPendingTargetIds); + const addTargetIds = [...selectedIds].filter((targetId) => !observedPending.has(targetId)); + const removeTargetIds = [ + ...new Set([ + ...observedPendingTargetIds.filter((targetId) => !selectedIds.has(targetId)), + ...alreadyDispatched, + ]), + ].filter((targetId) => !addTargetIds.includes(targetId)); + const tracker = invocationTracker.getExecutionId + ? { + has: (candidateThreadId: string, catId: CatId) => invocationTracker.has(candidateThreadId, catId), + getUserId: (candidateThreadId: string, catId: CatId) => + invocationTracker.getUserId(candidateThreadId, catId), + getExecutionId: (candidateThreadId: string, catId: CatId) => + invocationTracker.getExecutionId?.(candidateThreadId, catId), + } + : undefined; + const authorIntentByCatId = Object.fromEntries( + eligibleTargets.flatMap((target) => { + const intent = resolveQueueAuthorIntentByCatId({ + targetCats: [target.targetCatId as CatId], + requested: target.strategy === 'guide_reply' ? 'continue_current' : 'next_work', + threadId, + userId: guard.userId, + invocationTracker: tracker, + resolveCarrierCapability: opts.resolveCarrierCapability, + })[target.targetCatId]; + return intent ? [[target.targetCatId, intent] as const] : []; + }), + ); + const buildQueueInput = (targetCats: string[]) => ({ threadId, userId: guard.userId, - invocationTracker, - coordinator: opts.queueCustodyCoordinator, - carrierCapability: opts.resolveCarrierCapability?.(targetCatId as CatId), + owner: { kind: 'user' as const, userId: guard.userId }, + sourceId: source.id, + kind: 'conversation_input' as const, + ownerAuthProvenance: 'strict' as const, + content: source.content, + messageId: source.id, + from: source.from!, + targetCats, + ...(source.extra?.routingWarnings ? { routingWarnings: [...source.extra.routingWarnings] } : {}), + authorIntentByCatId, + intent: anchor?.execution.intent ?? 'execute', + autoExecute: anchor?.execution.autoExecute ?? true, + priority: anchor?.priority ?? 'normal', + ...(anchor?.sourceCategory + ? { sourceCategory: anchor.sourceCategory } + : source.from?.kind === 'agent' + ? { sourceCategory: 'a2a' as const } + : {}), }); - if (!resolution.ok) { - reply.status(resolution.status); - return { error: resolution.error, code: resolution.code }; - } - const existingAttempt = await resolution.coordinator.findReminderAttempt( - resolution.entry, - targetCatId, - resolution.invocationId, + let reconciled = await invocationQueue.reconcileQueuedMessageTargetsDurable( + threadId, + guard.userId, + entryId, + addTargetIds, + removeTargetIds, + authorIntentByCatId, ); - if (existingAttempt) { - return { - ok: true, - reminderId: existingAttempt.id, - targetCatId, - invocationId: resolution.invocationId, - state: existingAttempt.state, - idempotent: true, - }; + if (reconciled.outcome === 'not_found' && addTargetIds.length > 0) { + try { + const admitted = await invocationQueue.enqueueDurable(buildQueueInput(addTargetIds)); + if (admitted.outcome === 'full') { + reply.status(429); + return { error: '消息队列已满', code: 'QUEUE_FULL' }; + } + reconciled = { outcome: 'updated' as const, entry: admitted.entry ?? null }; + } catch (error) { + if (!(error instanceof Error) || !error.message.startsWith('Queue admission identity conflict')) throw error; + reconciled = await invocationQueue.reconcileQueuedMessageTargetsDurable( + threadId, + guard.userId, + entryId, + addTargetIds, + removeTargetIds, + authorIntentByCatId, + ); + } } - - const reminderId = randomUUID(); - const persisted = await resolution.coordinator.requestReminder( - resolution.entry, - targetCatId, - resolution.invocationId, - reminderId, - ); - if (!persisted) { - const racedAttempt = await resolution.coordinator.findReminderAttempt( - resolution.entry, - targetCatId, - resolution.invocationId, + if (reconciled.outcome === 'state_changed') { + // Claims are short reversible reservations. Yield once so the winning + // delivery removes its exact target, then merge against current truth. + await new Promise((resolve) => setTimeout(resolve, 0)); + reconciled = await invocationQueue.reconcileQueuedMessageTargetsDurable( + threadId, + guard.userId, + entryId, + addTargetIds, + removeTargetIds, + authorIntentByCatId, ); - if (racedAttempt) { - return { - ok: true, - reminderId: racedAttempt.id, - targetCatId, - invocationId: resolution.invocationId, - state: racedAttempt.state, - idempotent: true, - }; - } + } + if (reconciled.outcome === 'state_changed') { reply.status(409); - return { error: '消息尚未建立可持久回执', code: 'RECEIPT_NOT_PERSISTED' }; + return { error: 'Steer 正在与投递收敛,请重试', code: 'STEER_DELIVERY_IN_FLIGHT' }; + } + const pendingAfter = new Set('entry' in reconciled ? (reconciled.entry?.targets ?? []) : []); + queueProcessor.registerCallerDispatchSteerChanges?.(source, { + addedTargetIds: addTargetIds.filter((targetId) => pendingAfter.has(targetId)), + removedTargetIds: removeTargetIds.filter( + (targetId) => !alreadyDispatched.has(targetId) && !pendingAfter.has(targetId), + ), + }); + const targets = eligibleTargets + .filter((target) => pendingAfter.has(target.targetCatId)) + .map((target) => ({ targetCatId: target.targetCatId, strategy: target.strategy, entryId })); + try { + await admitThreadParticipants({ + userId: guard.userId, + threadId, + targetCats: eligibleTargets + .filter((target) => target.membershipAtOpen === 'admit') + .map((target) => target.targetCatId as CatId), + threadStore, + socketManager, + emitPolicy: 'membership-changed', + }); + } catch (error) { + request.log.error( + { error, threadId, entryId, targets }, + 'Steer target changes committed; participant admission remains retryable', + ); + reply.status(503); + return { + error: '目标工单已保存,成员加入尚未完成;请重试同一操作', + code: 'STEER_PARTICIPANT_ADMISSION_PENDING', + mappingCommitted: true, + targets, + }; } await emitQueueUpdated( socketManager, @@ -775,264 +1407,127 @@ export const queueRoutes: FastifyPluginAsync = async (app, o threadId, invocationQueue.list(threadId, guard.userId), messageStore, - 'reminder_requested', + 'steer_targets_resolved', ); - return { ok: true, reminderId, targetCatId, invocationId: resolution.invocationId, state: 'requested' as const }; + // Mapping is a durable Queue mutation in its own right. If the browser + // disappears before issuing the per-target follow-up commands, ordinary + // Queue liveness must still converge every newly added row. + void queueProcessor.requestDrain(threadId); + return { + targets, + skippedAlreadyDispatched: normalizedTargets + .filter((target) => alreadyDispatched.has(target.targetCatId)) + .map((target) => target.targetCatId), + }; }, ); - // POST /api/threads/:threadId/queue/:entryId/targets/:targetCatId/retry - // The Queue row owns the action identity. Message-backed work delegates the - // mutation to durable custody; message-less A2A uses the same exact Queue - // failure fence without inventing another store. - app.post<{ - Params: { threadId: string; entryId: string; targetCatId: string }; - Body: { recoveryActionId?: unknown }; - }>('/api/threads/:threadId/queue/:entryId/targets/:targetCatId/retry', async (request, reply) => { - const { threadId, entryId, targetCatId } = request.params; - const guard = await guardThreadOwnership(request, reply, threadStore, threadId); - if (!guard) return; - const parsed = retryTargetBodySchema.safeParse(request.body ?? {}); - if (!parsed.success) { - reply.status(400); - return { error: 'Invalid body', details: parsed.error.issues }; - } + // POST /api/threads/:threadId/queue/:entryId/continue + app.post<{ Params: { threadId: string; entryId: string } }>( + '/api/threads/:threadId/queue/:entryId/continue', + async (request, reply) => { + const { threadId, entryId } = request.params; + const guard = await guardThreadOwnership(request, reply, threadStore, threadId); + if (!guard) return; + const parsed = continueBodySchema.safeParse(request.body ?? {}); + if (!parsed.success) { + reply.status(400); + return { error: '请选择当前对话中的成员', code: 'CONTINUE_TARGET_REQUIRED' }; + } - const entry = invocationQueue.getEntrySnapshot(threadId, guard.userId, entryId); - const expectedAction = entry - ? projectPublicQueueEntry(entry).recoveryActions.find( - (action) => action.kind === 'retry_target' && action.targetCatId === targetCatId, - ) - : undefined; - if (!entry || expectedAction?.id !== parsed.data.recoveryActionId) { - reply.status(409); - return { error: 'This target is no longer retryable', code: 'QUEUE_TARGET_NOT_RETRYABLE' }; - } + const entry = invocationQueue.list(threadId, guard.userId).find((candidate) => candidate.id === entryId); + if (!entry || !isPublicQueueEntry(entry)) { + reply.status(404); + return { error: '队列条目不存在', code: 'ENTRY_NOT_FOUND' }; + } + if (entry.status !== 'queued') { + reply.status(409); + return { error: '条目正在处理中,无法改派', code: 'ENTRY_PROCESSING' }; + } + if (isSystemPinnedQueueEntry(entry) || entry.kind !== 'conversation_input' || entry.from.kind !== 'user') { + reply.status(409); + return { error: '该条目不支持不中断发送', code: 'ENTRY_CONTINUE_UNAVAILABLE' }; + } + + const targetCatId = parsed.data.targetCatId as CatId; + const currentTargets = queueEntryTargetCats(entry); + if ( + !guard.thread.participants.includes(targetCatId) || + (currentTargets.length > 0 && !currentTargets.includes(targetCatId)) + ) { + reply.status(400); + return { error: '所选成员不属于此消息的当前对话目标', code: 'INVALID_CONTINUE_TARGET' }; + } - const messageIds = queueEntryMessageIds(entry); - if (messageIds.length === 0) { - const result = await queueProcessor.retryFailedTargetWithoutCustody( + const authorIntent = resolveQueueAuthorIntentByCatId({ + targetCats: [targetCatId], + requested: 'continue_current', + threadId, + userId: guard.userId, + invocationTracker: invocationTracker.getExecutionId + ? { + has: (candidateThreadId, catId) => invocationTracker.has(candidateThreadId, catId), + getUserId: (candidateThreadId, catId) => invocationTracker.getUserId(candidateThreadId, catId), + getExecutionId: (candidateThreadId, catId) => + invocationTracker.getExecutionId?.(candidateThreadId, catId), + } + : undefined, + resolveCarrierCapability: opts.resolveCarrierCapability, + })[targetCatId]; + if (!authorIntent) { + reply.status(409); + return { error: '无法保存发送意图', code: 'CONTINUE_STATE_CHANGED' }; + } + const bound = await invocationQueue.bindContinueCurrentIntentDurable( threadId, guard.userId, entryId, targetCatId, - parsed.data.recoveryActionId, + authorIntent, ); - if (result.outcome !== 'retried') { + if (!bound) { reply.status(409); - return { error: 'This target is no longer retryable', code: 'QUEUE_TARGET_NOT_RETRYABLE' }; + return { error: '队列状态已变化,请刷新后重试', code: 'CONTINUE_STATE_CHANGED' }; } - reply.status(202); - return { status: 'retry_queued', entryId, targetCatId, attemptId: result.attemptId }; - } - const retryAuthorityPreflight = opts.retryAuthorityPreflight; - const retryAuthorityCommitter = opts.retryAuthorityCommitter; - if (!messageStore || !retryAuthorityPreflight || !retryAuthorityCommitter) { - reply.status(503); - return { error: 'Queue retry is temporarily unavailable', code: 'QUEUE_RETRY_UNAVAILABLE' }; - } - let authorityMessage: Awaited> | null = null; - let expectedAttemptId: string | undefined; - for (const messageId of messageIds) { - const message = await messageStore.getById(messageId); - if (!message || message.userId !== guard.userId || !message.queueCustody) continue; - const attemptId = resolveDurableRetryAttemptId(entry, message, targetCatId); - if (!attemptId) continue; - authorityMessage = message; - expectedAttemptId = attemptId; - break; - } - if (!authorityMessage || !expectedAttemptId) { - reply.status(409); - return { error: 'This target is no longer retryable', code: 'QUEUE_TARGET_NOT_RETRYABLE' }; - } - const authorityMessageId = authorityMessage.id; - - const authority = await retryAuthorityPreflight.preflight({ - message: authorityMessage, - requestingUserId: guard.userId, - targetCatId, - }); - if (!authority.ok) { - reply.status(409); - return { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: authority.reason, - }; - } - const result = await queueProcessor.retryFailedTarget( - threadId, - guard.userId, - entryId, - targetCatId, - expectedAttemptId, - (transitions) => - retryAuthorityCommitter.commit({ - authorityMessageId, - requestingUserId: guard.userId, + if (authorIntent.fallbackAt === undefined) { + const append = await queueProcessor.tryAutoAppendExactEntry({ + threadId, + userId: guard.userId, + entryId, targetCatId, - transitions, - }), - ); - if (result.outcome === 'unavailable') { - reply.status(503); - return { error: 'Queue retry is temporarily unavailable', code: 'QUEUE_RETRY_UNAVAILABLE' }; - } - if (result.outcome === 'authority_stale') { - reply.status(409); - return { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: result.reason, - }; - } - if (result.outcome !== 'retried') { - reply.status(409); - return { error: 'This target is no longer retryable', code: 'QUEUE_TARGET_NOT_RETRYABLE' }; - } - reply.status(202); - return { status: 'retry_queued', entryId, targetCatId, attemptId: result.attemptId }; - }); - - // POST /api/threads/:threadId/queue/steer-batch - app.post<{ Params: { threadId: string } }>('/api/threads/:threadId/queue/steer-batch', async (request, reply) => { - const { threadId } = request.params; - const guard = await guardThreadOwnership(request, reply, threadStore, threadId); - if (!guard) return; - - const parseResult = steerBatchBodySchema.safeParse(request.body); - if (!parseResult.success) { - reply.status(400); - return { error: 'Invalid body', details: parseResult.error.issues }; - } - - const reserved = invocationQueue.reserveExactUserBatch(threadId, guard.userId, parseResult.data.entryIds); - if (reserved.outcome === 'rejected') { - const rejection = { - invalid_entry_ids: { status: 400 as const, code: 'INVALID_BATCH_ENTRY_IDS', error: 'entryIds 无效' }, - entry_not_found: { status: 404 as const, code: 'BATCH_ENTRY_NOT_FOUND', error: '批量 Steer 条目不存在' }, - entry_ineligible: { - status: 409 as const, - code: 'BATCH_ENTRY_INELIGIBLE', - error: '批量 Steer 仅支持普通用户消息', - }, - entries_incompatible: { - status: 409 as const, - code: 'BATCH_ENTRIES_INCOMPATIBLE', - error: '批量 Steer 条目必须属于同一猫、意图和授权来源', - }, - }[reserved.reason]; - reply.status(rejection.status); - return { error: rejection.error, code: rejection.code }; - } - - const releaseReservation = async () => { - const restored = invocationQueue.releaseExactUserBatch(threadId, guard.userId, reserved.reservationId); - try { - await persistQueueEntries( + }); + if (append.outcome === 'appended') return append; + if (append.reason === 'provider_rejected') { + reply.status(502); + return { error: '当前 Agent Client 拒绝了 Append;失败回执已保留', code: 'PROVIDER_REJECTED' }; + } + await invocationQueue.fallbackQueuedAuthorIntentDurable( threadId, guard.userId, - restored.map((entry) => entry.id), - ); - } catch (err) { - request.log.error( - { err, threadId, reservationId: reserved.reservationId }, - 'Failed to persist exact Batch Steer reservation rollback', + entryId, + targetCatId, + 'parent_terminal_before_exposure', ); } - return restored; - }; - - try { - await validateSteerSources(threadId, guard.userId, reserved.entryIds, reserved.targetCatId); - await persistQueueEntries(threadId, guard.userId, reserved.entryIds); - await validateSteerSources(threadId, guard.userId, reserved.entryIds, reserved.targetCatId); - } catch (err) { - await releaseReservation(); - request.log.error( - { err, threadId, reservationId: reserved.reservationId }, - 'Failed to persist exact Batch Steer reservation before preemption', - ); - reply.status(503); - return { error: '批量 Steer 预留暂不可用', code: 'BATCH_RESERVATION_PERSIST_FAILED' }; - } - - if (!invocationQueue.beginExactSteerPreemption(threadId, guard.userId, reserved.reservationId)) { - await releaseReservation(); - reply.status(409); - return { error: '批量 Steer 预留已变化', code: 'BATCH_RESERVATION_LOST' }; - } - const preemption = await preemptSteerTarget(threadId, guard.userId, reserved.targetCatId, request); - if (!preemption.ok) { - await releaseReservation(); - reply.status(preemption.status); - return { error: preemption.error, code: preemption.code }; - } - - if (!invocationQueue.activateExactSteerReservation(threadId, guard.userId, reserved.reservationId)) { - await releaseReservation(); - reply.status(409); - return { error: '批量 Steer 预留已变化', code: 'BATCH_RESERVATION_LOST' }; - } - - if (preemption.deferred) { + void queueProcessor.requestDrain(threadId); await emitQueueUpdated( socketManager, guard.userId, threadId, invocationQueue.list(threadId, guard.userId), messageStore, - 'steer_batch_reserved', + 'continue_current_fallback', ); - reply.status(202); return { - ok: true, - deferred: true, - code: 'PREEMPT_PENDING_PRESTART', - batchId: reserved.reservationId, - entryIds: reserved.entryIds, + outcome: 'queued', + targetCatId, + effective: 'next_work', + reason: authorIntent.fallbackReason ?? 'parent_terminal_before_exposure', }; - } - - const result = await queueProcessor.processExactSteerReservation( - threadId, - guard.userId, - reserved.primaryEntryId, - reserved.reservationId, - ); - if (!result.started) { - await releaseReservation(); - await emitQueueUpdated( - socketManager, - guard.userId, - threadId, - invocationQueue.list(threadId, guard.userId), - messageStore, - 'steer_batch_failed', - ); - reply.status(409); - return { error: '队列繁忙,暂无法立即执行', code: 'QUEUE_BUSY' }; - } - - await emitQueueUpdated( - socketManager, - guard.userId, - threadId, - invocationQueue.list(threadId, guard.userId), - messageStore, - 'steer_batch_reserved', - ); - - return { - ok: true, - batchId: reserved.reservationId, - entryIds: reserved.entryIds, - ...projectQueueStartResult(result), - }; - }); + }, + ); // POST /api/threads/:threadId/queue/:entryId/steer app.post<{ Params: { threadId: string; entryId: string } }>( @@ -1050,11 +1545,11 @@ export const queueRoutes: FastifyPluginAsync = async (app, o const entries = invocationQueue.list(threadId, guard.userId); const entry = entries.find((e) => e.id === entryId); - if (!entry) { + if (!entry || !isPublicQueueEntry(entry)) { reply.status(404); return { error: '队列条目不存在', code: 'ENTRY_NOT_FOUND' }; } - if (entry.status === 'processing') { + if (entry.status !== 'queued') { reply.status(409); return { error: '条目正在处理中,无法 steer', code: 'ENTRY_PROCESSING' }; } @@ -1066,94 +1561,48 @@ export const queueRoutes: FastifyPluginAsync = async (app, o // Steer has exactly one meaning: cancel the current target invocation and // immediately start this same durable queue entry. Reordering remains a // separate drag/move interaction and is never accepted as a Steer mode. - const steerCatId = entry.targetCats.find((catId) => isOrdinaryQueueTargetEligible(entry, catId)); + const requestedTargetCatId = parseResult.data.targetCatId; + const targetCats = queueEntryTargetCats(entry); + const steerCatId = requestedTargetCatId ?? targetCats[0]; if (!steerCatId) { - reply.status(409); - return { error: 'Steer 状态已变化,请重试', code: 'STEER_STATE_CHANGED' }; - } - // Reserve the entry BEFORE preempting. Preemption cancels a running turn; - // doing it first meant a losing CAS returned STEER_STATE_CHANGED *after* - // the user's turn was already killed — they lost the work and did not get - // the steer, while the message told them to retry. - const reserved = invocationQueue.reserveExactUserEntry(threadId, guard.userId, entryId, steerCatId); - if (reserved.outcome === 'rejected') { - reply.status(409); - return { error: 'Steer 状态已变化,请重试', code: 'STEER_STATE_CHANGED' }; + reply.status(400); + return { error: '请选择当前对话中的成员', code: 'STEER_TARGET_REQUIRED' }; } - // The reservation must be durable BEFORE anything is cancelled, mirroring - // steer-batch. Reserving only in memory just moved the split: a rejected - // persistence would still have landed after the running turn was killed. - const releaseSteerReservation = async (): Promise => { - const restored = invocationQueue.releaseExactUserBatch(threadId, guard.userId, reserved.reservationId); - try { - await persistQueueEntries( - threadId, - guard.userId, - restored.map((candidate) => candidate.id), - ); - } catch (releaseErr) { - request.log.error( - { err: releaseErr, threadId, entryId }, - 'Failed to persist Steer reservation release; in-memory marker cleared', - ); - } - }; + const targetInThread = guard.thread.participants.includes(steerCatId as CatId); + const targetMatchesEntry = targetCats.length === 0 || targetCats.includes(steerCatId); + if (!targetInThread || !targetMatchesEntry) { + reply.status(400); + return { error: '所选成员不属于此消息的当前对话目标', code: 'INVALID_STEER_TARGET' }; + } + // The ledger claim is the one durable dequeue fence. It happens before + // cancellation so a losing claim cannot kill the active turn, and it can + // be restored in place if cancellation fails. + let claimed; try { - await validateSteerSources(threadId, guard.userId, [entryId], steerCatId); - await persistQueueEntries(threadId, guard.userId, [entryId]); - await validateSteerSources(threadId, guard.userId, [entryId], steerCatId); + claimed = await invocationQueue.claimExactSteerEntryDurable(threadId, guard.userId, entryId, steerCatId); } catch (err) { - await releaseSteerReservation(); - request.log.error({ err, threadId, entryId }, 'Failed to persist exact Steer reservation before preemption'); + request.log.error({ err, threadId, entryId }, 'Failed to claim durable Queue row before Steer preemption'); reply.status(503); - return { error: 'Steer 预留暂不可用', code: 'STEER_RESERVATION_PERSIST_FAILED' }; + return { error: 'Steer 暂不可用', code: 'STEER_CLAIM_FAILED' }; } - - if (!invocationQueue.beginExactSteerPreemption(threadId, guard.userId, reserved.reservationId)) { - await releaseSteerReservation(); - reply.status(409); - return { error: 'Steer 预留已变化,请重试', code: 'STEER_RESERVATION_LOST' }; + if (claimed.outcome === 'rejected') { + const status = claimed.reason === 'entry_not_found' ? 404 : 409; + reply.status(status); + return claimed.reason === 'entry_processing' + ? { error: '条目正在处理中,无法 steer', code: 'ENTRY_PROCESSING' } + : { error: '队列条目不存在', code: 'ENTRY_NOT_FOUND' }; } - const preemption = await preemptSteerTarget(threadId, guard.userId, steerCatId, request); + const preemption = await preemptSteerTarget(threadId, guard.userId, steerCatId, entryId, request); if (!preemption.ok) { - // The replacement reservation must not survive a rejected or partially - // terminalized preemption — locally or durably. - await releaseSteerReservation(); + await invocationQueue.restoreClaimedEntries(threadId, [entryId]); reply.status(preemption.status); return { error: preemption.error, code: preemption.code }; } - if (!invocationQueue.activateExactSteerReservation(threadId, guard.userId, reserved.reservationId)) { - await releaseSteerReservation(); - reply.status(409); - return { error: 'Steer 预留已变化,请重试', code: 'STEER_RESERVATION_LOST' }; - } - if (preemption.deferred) { - await emitQueueUpdated( - socketManager, - guard.userId, - threadId, - invocationQueue.list(threadId, guard.userId), - messageStore, - 'steer_immediate', - ); - reply.status(202); - return { - ok: true, - deferred: true, - code: 'PREEMPT_PENDING_PRESTART', - message: '目标正在启动中,已请求中断,插队消息将在当前调用退出后立即执行', - }; - } - const result = await queueProcessor.processExactSteerReservation( - threadId, - guard.userId, - entryId, - reserved.reservationId, - ); + const result = await queueProcessor.processClaimedSteerEntries(threadId, guard.userId, [entryId], steerCatId); if (!result.started) { - await releaseSteerReservation(); + await invocationQueue.restoreClaimedEntries(threadId, [entryId]); await emitQueueUpdated( socketManager, guard.userId, @@ -1162,8 +1611,8 @@ export const queueRoutes: FastifyPluginAsync = async (app, o messageStore, 'steer_failed', ); - reply.status(409); - return { error: '队列繁忙,暂无法立即执行', code: 'QUEUE_BUSY' }; + reply.status(503); + return { error: 'Steer 启动失败,请重试', code: 'STEER_START_FAILED' }; } await emitQueueUpdated( @@ -1196,29 +1645,33 @@ export const queueRoutes: FastifyPluginAsync = async (app, o // Check if entry is processing const entries = invocationQueue.list(threadId, guard.userId); const entry = entries.find((e) => e.id === entryId); - if (!entry) { + if (!entry || !isPublicQueueEntry(entry)) { reply.status(404); return { error: '队列条目不存在', code: 'ENTRY_NOT_FOUND' }; } - if (entry.status === 'processing') { + if (entry.status !== 'queued') { reply.status(409); return { error: '正在处理中的条目不可移动', code: 'ENTRY_PROCESSING' }; } - if (entry.exactSteerBatch) { - reply.status(409); - return { error: '批量 Steer 已预留,暂不可重排', code: 'ENTRY_STEERING' }; - } if (isSystemPinnedQueueEntry(entry)) { reply.status(409); return { error: '系统续接条目不可手动调整位置', code: 'ENTRY_POSITION_LOCKED' }; } - invocationQueue.move(threadId, guard.userId, entryId, parseResult.data.direction); - await persistQueueEntries( - threadId, - guard.userId, - invocationQueue.list(threadId, guard.userId).map((candidate) => candidate.id), - ); + const queued = entries.filter((candidate) => candidate.status === 'queued'); + const index = queued.findIndex((candidate) => candidate.id === entryId); + const neighborIndex = parseResult.data.direction === 'up' ? index - 1 : index + 1; + if (neighborIndex >= 0 && neighborIndex < queued.length) { + const neighbor = queued[neighborIndex]!; + if (!(await invocationQueue.setPositionDurable(threadId, guard.userId, entryId, neighborIndex))) { + reply.status(409); + return { error: '队列状态已变化,请重试', code: 'ENTRY_PROCESSING' }; + } + if (!(await invocationQueue.setPositionDurable(threadId, guard.userId, neighbor.id, index))) { + reply.status(409); + return { error: '队列状态已变化,请重试', code: 'ENTRY_PROCESSING' }; + } + } await emitQueueUpdated( socketManager, guard.userId, @@ -1260,17 +1713,13 @@ export const queueRoutes: FastifyPluginAsync = async (app, o const entries = invocationQueue.list(threadId, guard.userId); for (const { entryId } of parseResult.data.positions) { const entry = entries.find((e) => e.id === entryId); - if (!entry) { + if (!entry || !isPublicQueueEntry(entry)) { reply.status(400); return { error: `Cannot reorder entry ${entryId} (not found)` }; } - if (entry.status === 'processing') { - reply.status(400); - return { error: `Cannot reorder entry ${entryId} (processing)` }; - } - if (entry.exactSteerBatch) { + if (entry.status !== 'queued') { reply.status(409); - return { error: '批量 Steer 已预留,暂不可重排', code: 'ENTRY_STEERING' }; + return { error: `Cannot reorder entry ${entryId} (processing)` }; } if (isSystemPinnedQueueEntry(entry)) { reply.status(409); @@ -1279,13 +1728,11 @@ export const queueRoutes: FastifyPluginAsync = async (app, o } for (const { entryId, position } of parseResult.data.positions) { - invocationQueue.setPosition(threadId, guard.userId, entryId, position); + if (!(await invocationQueue.setPositionDurable(threadId, guard.userId, entryId, position))) { + reply.status(409); + return { error: `Cannot reorder entry ${entryId} (state changed)`, code: 'ENTRY_PROCESSING' }; + } } - await persistQueueEntries( - threadId, - guard.userId, - parseResult.data.positions.map(({ entryId }) => entryId), - ); await emitQueueUpdated( socketManager, @@ -1304,30 +1751,29 @@ export const queueRoutes: FastifyPluginAsync = async (app, o const guard = await guardThreadOwnership(request, reply, threadStore, threadId); if (!guard) return; - if (invocationQueue.hasUnsettledExactSteerReservation(threadId, guard.userId)) { - reply.status(409); - return { error: 'Steer 正在抢占,暂无法清空队列', code: 'ENTRY_STEERING' }; - } - - // Explicit clear supersedes a deferred Batch Steer. Restore its original - // snapshots first so the existing per-entry withdrawal CAS remains valid. - invocationQueue.releaseAllExactUserBatches(threadId, guard.userId); - - // Withdraw one exact snapshot at a time. This preserves processing entries and lets a - // durable-store failure stop before any later entry is removed from actionable custody. + // Claim one exact ledger row at a time. Processing rows are the only business-level block. const cleared: QueueEntry[] = []; const candidates = invocationQueue.list(threadId, guard.userId); - for (const [candidateIndex, candidate] of candidates.entries()) { + for (const candidate of candidates) { + if (!isPublicQueueEntry(candidate)) continue; const current = invocationQueue.getEntrySnapshot(threadId, guard.userId, candidate.id); if (!current || current.status === 'processing') continue; - if (!invocationQueue.removeEntrySnapshotIfUnchanged(current)) continue; + const claimed = await invocationQueue.claimQueuedEntryForWithdrawal(threadId, guard.userId, current.id); + if (!claimed) continue; try { - await opts.queueCustodyCoordinator?.withdrawEntry(current); + const messageIds = queueEntryMessageIds(claimed); + const remaining = invocationQueue.list(threadId, guard.userId).filter((entry) => entry.id !== claimed.id); + for (const messageId of messageIds) { + const hasSibling = remaining.some((entry) => queueEntryMessageIds(entry).includes(messageId)); + if (!hasSibling) await messageStore?.markCanceled(messageId); + } + const removed = await invocationQueue.commitClaimedWithdrawal(threadId, claimed.id); + if (!removed) throw new Error('Queue clear claim changed before commit'); } catch (err) { - invocationQueue.restoreDurableEntry(current, { beforeEntryId: candidates[candidateIndex + 1]?.id }); + await invocationQueue.restoreClaimedEntries(threadId, [claimed.id]); request.log.error( - { err, entryId: current.id, threadId, clearedCount: cleared.length }, + { err, entryId: claimed.id, threadId, clearedCount: cleared.length }, 'durable Queue clear stopped; unsettled entries retained', ); const remaining = invocationQueue.list(threadId, guard.userId); @@ -1344,18 +1790,20 @@ export const queueRoutes: FastifyPluginAsync = async (app, o }; } + queueProcessor.registerCallerDispatchQueueWithdrawal(claimed); + try { - await retireWithdrawnManagedWake(opts, current); + await retireWithdrawnManagedWake(opts, claimed); } catch (err) { request.log.error( - { err, entryId: current.id, threadId }, + { err, entryId: claimed.id, threadId }, 'managed wake producer retirement deferred to recovery sweep', ); } - queueProcessor.unregisterEntryCompleteHook?.(current.id); - await queueProcessor.finalizeRemovedEntry?.(current, 'user_cancel'); - cleared.push(current); + queueProcessor.unregisterEntryCompleteHook?.(claimed.id); + await queueProcessor.finalizeRemovedEntry?.(claimed, 'user_cancel'); + cleared.push(claimed); } await emitQueueUpdated( socketManager, @@ -1364,7 +1812,6 @@ export const queueRoutes: FastifyPluginAsync = async (app, o invocationQueue.list(threadId, guard.userId), messageStore, 'cleared', - { receiptMessageIds: cleared.flatMap(queueEntryMessageIds) }, ); return { cleared: cleared.map(projectPublicQueueEntry) }; @@ -1403,7 +1850,7 @@ export const queueRoutes: FastifyPluginAsync = async (app, o await opts.invocationRecordStore.update(orphanRecord.id, { status: 'canceled' }); // P2-1 + P2 (codex 第4轮 a5e8eea2): the WHOLE record is being canceled, so broadcast - // done + clear pause + release slot for EVERY targetCat — not just the requested one. + // done + release slot for EVERY targetCat — not just the requested one. // Otherwise sibling cats in a multi-cat orphan record stay stuck in the client's active // state and their processingSlots leak; and since the record is no longer running, // force-reset can't rediscover those siblings via listRunningByThread. @@ -1420,7 +1867,6 @@ export const queueRoutes: FastifyPluginAsync = async (app, o } } for (const c of terminalOrphanCats) { - queueProcessor.clearPause(threadId, c); queueProcessor.releaseSlot(threadId, c); } return { ok: true, cancelled: true }; @@ -1434,7 +1880,6 @@ export const queueRoutes: FastifyPluginAsync = async (app, o for (const m of buildCancelMessages({ cancelled: true, catIds: [catId] })) { socketManager.broadcastAgentMessage(m, threadId); } - queueProcessor.clearPause(threadId, catId); queueProcessor.releaseSlot(threadId, catId); return { ok: true, cancelled: true }; } @@ -1457,8 +1902,7 @@ export const queueRoutes: FastifyPluginAsync = async (app, o for (const m of buildCancelMessages(scopedResult)) { socketManager.broadcastAgentMessage(m, threadId); } - queueProcessor.clearPause(threadId, catId); - queueProcessor.releaseSlot(threadId, catId); + releaseSlotUnlessExecutionOwnsRetirement(threadId, catId); } return { ok: true, cancelled: cancelResult.cancelled }; @@ -1470,7 +1914,7 @@ export const queueRoutes: FastifyPluginAsync = async (app, o // could leave the thread in a permanently stuck state that users could not recover from. // This endpoint provides a last-resort manual reset: // 1. invocationTracker.cancelAll — aborts all active controllers + clears tracker slots - // 2. queueProcessor.releaseThread — clears all in-memory processingSlots + // 2. queueProcessor.releaseSlot — clears only the canceled cats' in-memory slots // 3. listRunningByThread + update canceled — marks all persistent running records done // Returns { ok: true, canceledRecords: N } app.post<{ Params: { threadId: string } }>('/api/threads/:threadId/force-reset', async (request, reply) => { @@ -1526,20 +1970,24 @@ export const queueRoutes: FastifyPluginAsync = async (app, o .list(threadId, guard.userId) .filter((entry) => queueEntryMessageIds(entry).some((messageId) => managedMessageIds.has(messageId))); for (const carrier of managedCarriers) { - const current = invocationQueue.getEntrySnapshot(threadId, guard.userId, carrier.id); - if (!current || !invocationQueue.removeEntrySnapshotIfUnchanged(current)) continue; + const claimed = await invocationQueue.claimQueuedEntryForWithdrawal(threadId, guard.userId, carrier.id); + if (!claimed) continue; try { - await opts.queueCustodyCoordinator?.withdrawEntry(current); + const withdrawn = await invocationQueue.commitClaimedWithdrawal(threadId, carrier.id); + if (!withdrawn) { + await invocationQueue.restoreClaimedEntries(threadId, [carrier.id]); + continue; + } } catch (err) { - invocationQueue.restoreDurableEntry(current); + await invocationQueue.restoreClaimedEntries(threadId, [carrier.id]); request.log.error( - { err, entryId: current.id, threadId }, + { err, entryId: carrier.id, threadId }, 'force-reset could not terminalize managed wake carrier; producer remains retired', ); continue; } - queueProcessor.unregisterEntryCompleteHook?.(current.id); - await queueProcessor.finalizeRemovedEntry?.(current, 'user_cancel'); + queueProcessor.unregisterEntryCompleteHook?.(claimed.id); + await queueProcessor.finalizeRemovedEntry?.(claimed, 'user_cancel'); } await emitQueueUpdated( socketManager, @@ -1595,7 +2043,7 @@ export const queueRoutes: FastifyPluginAsync = async (app, o queueProcessor.canReleaseSlotForUser(threadId, catId, guard.userId), ); - // Broadcast cancel + clear pause + release processingSlot for EVERY still-owned cat in + // Broadcast cancel + release processingSlot for EVERY still-owned cat in // terminalCatIds. P2 (opus-4.6 cross-cat // review): broadcasting only cancelledCatIds left stale records' cats without a done broadcast, // so the frontend "正在回复中" never cleared after force-reset (user had to F5). Doing all three @@ -1610,9 +2058,11 @@ export const queueRoutes: FastifyPluginAsync = async (app, o // Force-reset means stop, not "retry in ten seconds". Fence both Queue // cleanup and a late connector wake before releasing the slot. Terminal // consumption is restricted to executions this reset actually canceled. - queueProcessor.suppressAutoResume(threadId, cid, [...(canceledExecutionIdsByCatId.get(cid) ?? [])]); - queueProcessor.clearPause(threadId, cid); - queueProcessor.releaseSlot(threadId, cid); + const canceledExecutionIds = [...(canceledExecutionIdsByCatId.get(cid) ?? [])]; + if (canceledExecutionIds.length > 0) { + queueProcessor.suppressAutoResume(threadId, cid, canceledExecutionIds); + } + releaseSlotUnlessExecutionOwnsRetirement(threadId, cid); } return { ok: true, canceledRecords }; diff --git a/packages/api/src/routes/session-handoff-approve-routes.ts b/packages/api/src/routes/session-handoff-approve-routes.ts index c9fa1fc753..8e7e61419d 100644 --- a/packages/api/src/routes/session-handoff-approve-routes.ts +++ b/packages/api/src/routes/session-handoff-approve-routes.ts @@ -39,7 +39,7 @@ export interface SessionHandoffApproveRoutesOptions { handoffProposalStore: ISessionHandoffProposalStore; sessionChainStore: Pick; sessionSealer: Pick; - invocationQueue: Pick; + invocationQueue: Pick; queueProcessor?: Pick; socketManager: SocketManager; /** @@ -114,12 +114,13 @@ export const sessionHandoffApproveRoutes: FastifyPluginAsync { - const enq = invocationQueue.enqueue({ + const enq = await invocationQueue.enqueueDurable({ + from: { kind: 'agent', catId: input.catId }, threadId: input.threadId, userId, + kind: 'private_input', ownerAuthProvenance, content: HANDOFF_CONTINUATION_PROMPT, - source: 'agent', sourceCategory: 'continuation', targetCats: [input.catId], intent: 'session_handoff_continuation', diff --git a/packages/api/src/routes/signal-podcast-routes.ts b/packages/api/src/routes/signal-podcast-routes.ts index c395122c9a..ba13b2237f 100644 --- a/packages/api/src/routes/signal-podcast-routes.ts +++ b/packages/api/src/routes/signal-podcast-routes.ts @@ -1,11 +1,9 @@ import { readFile } from 'node:fs/promises'; import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; +import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; -import type { AgentRouter } from '../domains/cats/services/agents/routing/AgentRouter.js'; -import type { InvocationTracker } from '../domains/cats/services/index.js'; import type { AnyMessageStore } from '../domains/cats/services/stores/factories/MessageStoreFactory.js'; -import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { resolveSignalPaths } from '../domains/signals/config/sources-loader.js'; import { SignalArticleQueryService } from '../domains/signals/services/article-query-service.js'; @@ -24,10 +22,8 @@ const podcastBodySchema = z.object({ export interface PodcastRouteOptions { messageStore: AnyMessageStore; threadStore: IThreadStore; - router: AgentRouter; - invocationRecordStore: IInvocationRecordStore; - invocationTracker: InvocationTracker; - queueProcessor: Pick; + invocationQueue: InvocationQueue; + queueProcessor: Pick; } /** @@ -65,10 +61,7 @@ export const signalPodcastRoutes: FastifyPluginAsync = asyn const studyMeta = new StudyMetaService(); const threadDeps: ThreadInvokeDeps = { - messageStore: opts.messageStore, - router: opts.router, - invocationRecordStore: opts.invocationRecordStore, - invocationTracker: opts.invocationTracker, + invocationQueue: opts.invocationQueue, queueProcessor: opts.queueProcessor, }; diff --git a/packages/api/src/routes/thread-branch.ts b/packages/api/src/routes/thread-branch.ts index dbad3f638b..5478828ed5 100644 --- a/packages/api/src/routes/thread-branch.ts +++ b/packages/api/src/routes/thread-branch.ts @@ -8,6 +8,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; +import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; @@ -169,17 +170,23 @@ export const threadBranchRoutes: FastifyPluginAsync = for (let i = 0; i < messagesToCopy.length; i++) { const src = messagesToCopy[i]!; const isLast = i === messagesToCopy.length - 1; - const content = isLast && editedContent !== undefined ? editedContent : src.content; + const isEdited = isLast && editedContent !== undefined; + const content = isEdited ? editedContent : src.content; + const from = isEdited ? ({ kind: 'user', userId } as const) : messageFrom(src); + const pluginMessage = from.kind === 'plugin' ? src.extra?.pluginMessage : undefined; + if (from.kind === 'plugin' && !pluginMessage) { + throw new Error(`Cannot branch plugin message ${src.id}: canonical plugin payload is missing`); + } await messageStore.append({ - userId: src.userId, - catId: src.catId, + from, + userId: isEdited ? userId : src.userId, content, - ...(src.contentBlocks && !(isLast && editedContent !== undefined) - ? { contentBlocks: src.contentBlocks } - : {}), + ...(src.contentBlocks && !isEdited ? { contentBlocks: src.contentBlocks } : {}), ...(src.metadata ? { metadata: src.metadata } : {}), ...(src.origin ? { origin: src.origin } : {}), + ...(src.source && !isEdited ? { source: src.source } : {}), + ...(pluginMessage ? { extra: { pluginMessage: structuredClone(pluginMessage) } } : {}), mentions: [...src.mentions], timestamp: src.timestamp, threadId: newThread.id, diff --git a/packages/api/src/routes/thread-cats.ts b/packages/api/src/routes/thread-cats.ts index c0e43b8bd6..c4d776ad95 100644 --- a/packages/api/src/routes/thread-cats.ts +++ b/packages/api/src/routes/thread-cats.ts @@ -31,6 +31,8 @@ export interface ThreadCatsRoutesOptions { getCatDisplayName: (catId: string) => string; getAllCatIds: () => string[]; isCatAvailable: (catId: string) => boolean; + /** Read-only projection from the same head-time resolver used by Queue drain. */ + resolveConversationFallbackTarget?: (threadId: string) => Promise; } export const threadCatsRoutes: FastifyPluginAsync = async (app, opts) => { @@ -64,9 +66,18 @@ export const threadCatsRoutes: FastifyPluginAsync = asy getCatDisplayName, isCatAvailable, }); + let fallbackTargetCatId: string | null = null; + try { + fallbackTargetCatId = (await opts.resolveConversationFallbackTarget?.(id)) ?? null; + } catch (error) { + // Participants remain useful even when the exact Queue fallback read is + // temporarily unavailable. Return no fallback so the UI cannot guess. + request.log.warn({ error, threadId: id }, 'Failed to project exact conversation fallback target'); + } return { ...result, + fallbackTargetCatId, routingPolicy: thread.routingPolicy ? `v${thread.routingPolicy.v}` : null, }; }); diff --git a/packages/api/src/routes/thread-context-envelope.ts b/packages/api/src/routes/thread-context-envelope.ts index 985f41fd32..a5ae8290c5 100644 --- a/packages/api/src/routes/thread-context-envelope.ts +++ b/packages/api/src/routes/thread-context-envelope.ts @@ -37,6 +37,9 @@ export interface ThreadContextEnvelopeCandidate; } @@ -109,9 +112,11 @@ function buildPayload>( ) { return { threadId: base.threadId, + contextScope: base.contextScope, messages, hasMore, ...(nextCursor ? { nextCursor } : {}), + ...(base.situation ? { situation: base.situation } : {}), ...(base.scanCapped === undefined ? {} : { scanCapped: base.scanCapped }), ...(base.workflowSop ? { workflowSop: base.workflowSop } : {}), }; diff --git a/packages/api/src/routes/threads.ts b/packages/api/src/routes/threads.ts index 65c80fc08f..d17be9d1cb 100644 --- a/packages/api/src/routes/threads.ts +++ b/packages/api/src/routes/threads.ts @@ -1023,21 +1023,20 @@ export const threadsRoutes: FastifyPluginAsync = async (ap return { error: 'Freshness retry unavailable' }; } const nextEpoch = closure.retryEpoch + 1; - const enqueue = opts.invocationQueue.enqueue({ + const enqueue = await opts.invocationQueue.enqueueDurable({ + from: { kind: 'agent', catId: closure.catId }, threadId: id, userId, + kind: 'private_input', ownerAuthProvenance: 'strict', content: `[Freshness Catch Closure ${closure.id}] 显式重试;正文由执行前 closure truth 注入。`, - source: 'agent', sourceCategory: 'freshness', targetCats: [closure.catId], - callerCatId: closure.catId, autoExecute: true, priority: 'normal', intent: 'execute', idempotencyKey: `freshness-closure:${closure.id}:retry:${nextEpoch}`, freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, }); if (enqueue.outcome === 'full') { reply.status(409); @@ -1059,7 +1058,7 @@ export const threadsRoutes: FastifyPluginAsync = async (ap }, id, ); - void opts.queueProcessor.tryAutoExecute(id); + void opts.queueProcessor.requestDrain(id); reply.status(202); return { closure: projection }; }); diff --git a/packages/api/src/routes/votes.ts b/packages/api/src/routes/votes.ts index f23c34fb11..4cc0f9c083 100644 --- a/packages/api/src/routes/votes.ts +++ b/packages/api/src/routes/votes.ts @@ -92,8 +92,8 @@ export async function closeVoteInternal( if (messageStore) { try { const stored = await messageStore.append({ + from: { kind: 'system', service: 'vote' }, userId: votingState.createdBy, - catId: null, content: `投票结果: ${votingState.question}`, mentions: [], timestamp: Date.now(), @@ -290,8 +290,8 @@ export const voteRoutes: FastifyPluginAsync = async (app, opt if (messageStore) { try { const stored = await messageStore.append({ + from: { kind: 'system', service: 'vote' }, userId: 'system', - catId: null, content: `投票结果: ${votingState.question}`, mentions: [], timestamp: Date.now(), @@ -399,8 +399,8 @@ export const voteRoutes: FastifyPluginAsync = async (app, opt if (messageStore) { try { const stored = await messageStore.append({ + from: { kind: 'system', service: 'vote' }, userId: result.createdBy, - catId: null, content: `投票结果: ${result.question}`, mentions: [], timestamp: Date.now(), diff --git a/packages/api/src/scripts/f254-withheld-message-recovery/core.ts b/packages/api/src/scripts/f254-withheld-message-recovery/core.ts index 67139df43b..75b3c0ee19 100644 --- a/packages/api/src/scripts/f254-withheld-message-recovery/core.ts +++ b/packages/api/src/scripts/f254-withheld-message-recovery/core.ts @@ -130,9 +130,9 @@ export function toRecoveryAppendInput( recoveredAt: number, ): AppendMessageInput { return { + from: { kind: 'agent', catId: entry.catId as CatId }, userId: entry.userId, threadId: entry.threadId, - catId: entry.catId as CatId, content: entry.content, mentions: [], timestamp: entry.timestamp, diff --git a/packages/api/src/scripts/f282-proactive-memory-replay.ts b/packages/api/src/scripts/f282-proactive-memory-replay.ts index 44e8c468c5..51cf302f43 100644 --- a/packages/api/src/scripts/f282-proactive-memory-replay.ts +++ b/packages/api/src/scripts/f282-proactive-memory-replay.ts @@ -225,8 +225,8 @@ async function replayEpisode(fixture: ReplayEpisodeFixture): Promise // invocationId. Previous hint sent users to debugRef — wrong place, killed UX value. hint: 'CLI 进程正常退出,但没有可显示的文字输出(事件流没有 text event,或 plain-text stdout 为空)。建议:换一只猫试同样 prompt;换 model;或直接在终端跑 CLI 看 raw output 判断是 upstream / auth / profile 还是 prompt 问题。展开下方"详细诊断"查看 event 类型/数量、model、session 前缀等结构化证据;debugRef.invocationId 可用于后端日志检索。', }, - active_writer_recovery: { - summary: '原生会话仍绑定原 writer host', - hint: '原 Session 已保留,本轮没有新建或封存会话。正在等待原 host 释放;若持续不恢复,重启对应 runtime 后重试。', - }, // F212 Phase H (Sol runtime forensics 2026-07-09, archive 97449e4b): upstream provider // policy engine (Codex 0.98+) rejected content ("flagged for possible cybersecurity risk"). // NOT a Clowder AI bug — the upstream policy layer made the call. User needs actionable @@ -158,20 +154,6 @@ const REASON_TEXT: Record }, }; -export function buildActiveWriterRecoveryDiagnostic(input: { - state: CliActiveWriterRecoveryState; - debugRef: CliDiagnostics['debugRef']; -}): CliDiagnostics { - const text = REASON_TEXT.active_writer_recovery; - return { - reasonCode: 'active_writer_recovery', - publicSummary: text.summary, - publicHint: text.hint, - activeWriterRecovery: { state: input.state }, - debugRef: input.debugRef, - }; -} - const UNKNOWN_TEXT = { summary: '未识别的 CLI 错误', // Legacy fallback (callers without stderrEmpty signal). Phase F adds split-by-stderrEmpty diff --git a/packages/api/src/utils/queue-enrichment.ts b/packages/api/src/utils/queue-enrichment.ts index 0c205e66ea..ee88a0befe 100644 --- a/packages/api/src/utils/queue-enrichment.ts +++ b/packages/api/src/utils/queue-enrichment.ts @@ -8,20 +8,13 @@ * pointers; the enrichment layer joins persisted message data at emit time. */ -import type { - MessageContent, - QueueMessageReceipt, - QueueMessageReceiptProjection, - QueueRecoveryAction, -} from '@cat-cafe/shared'; -import type { QueueEntry } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; +import type { CatRoutingError, MessageContent, MessageFrom, QueueAuthorIntentReceipt } from '@cat-cafe/shared'; import { - canSteerQueueSources, - projectUnconsumedQueueCarrier, - readQueueCarrierMessages, -} from '../domains/cats/services/agents/invocation/QueueCarrierSourceProjection.js'; -import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; -import { projectQueueReceipt } from '../domains/cats/services/stores/ports/queued-message-receipt.js'; + type QueueEntry, + queueEntryOwnerId, + queueEntryTargetCats, +} from '../domains/cats/services/agents/invocation/InvocationQueue.js'; +import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; /** Projection of StoredMessage fields useful for QueuePanel / recall-edit. */ @@ -30,24 +23,39 @@ export interface QueueEntryMessagePreview { replyTo?: string; } -/** QueueEntry enriched with message preview for frontend consumption. */ -export interface EnrichedQueueEntry extends Omit { - targetStates: Record; +/** Stable browser DTO. The durable ledger remains nested and is never leaked to clients. */ +export interface EnrichedQueueEntry { + id: string; + threadId: string; + userId: string; + content: string; + messageId: string | null; + mergedMessageIds: string[]; + from: MessageFrom; + targetCats: string[]; + routingWarnings?: readonly CatRoutingError[]; + intent: string; + status: 'queued'; + /** Pending-target delivery preference; actual delivery lives in History dispatchRefs. */ + authorIntentByTarget?: Record; + createdAt: number; + autoExecute: boolean; + priority: QueueEntry['priority']; + sourceCategory?: QueueEntry['sourceCategory']; + continuationKey?: string; + position?: number; messagePreview?: QueueEntryMessagePreview; - queueReceipt?: QueueMessageReceipt; - /** Canonical executable escape hatches. Browser code must not reconstruct these from raw state. */ - recoveryActions: QueueRecoveryAction[]; } -export interface QueueUpdatePublicationOptions { - receiptMessageIds?: readonly string[]; -} - -type EnrichedTargetState = EnrichedQueueEntry['targetStates'][string]; type QueueUpdateEmitter = Pick; const QUEUE_ENRICHMENT_TIMEOUT_MS = 2_000; +/** RFC #1356 private inputs are execution custody, never user-visible Queue rows. */ +export function isPublicQueueEntry(entry: Pick): boolean { + return entry.kind !== 'private_input'; +} + /** * Queue updates are full-state replacements in the browser. Keep one ordered * publication tail for each runtime/thread/user scope so a slow older preview @@ -69,410 +77,114 @@ function publicationTailsFor(socketManager: QueueUpdateEmitter): Map; - steering: ReadonlySet; - failed: ReadonlySet; - seen: ReadonlySet; - awakened: ReadonlySet; - notified: ReadonlySet; - }, -): EnrichedTargetState { - if (state.handled.has(catId)) return 'handled'; - if (state.steering.has(catId)) return 'steering'; - if (state.failed.has(catId)) return 'failed'; - if (state.seen.has(catId)) return 'seen'; - if (state.awakened.has(catId)) return 'awakened'; - if (state.notified.has(catId)) return 'notified'; - return 'queued'; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; -} - -function latestQueueEntryExposure( - entry: QueueEntry, - catId: string, - invocationId?: string, -): NonNullable[number] | undefined { - let latest: NonNullable[number] | undefined; - for (const exposure of entry.queuedBodyExposures ?? []) { - if (exposure.targetCatId !== catId) continue; - if (invocationId !== undefined && exposure.invocationId !== invocationId) continue; - if (!latest || exposure.seenAt >= latest.seenAt) latest = exposure; - } - return latest; -} - -function hasAgentLiveReceiptEvidence(entry: QueueEntry): boolean { - return ( - entry.source === 'agent' && - Boolean(entry.queuedSeenByCatIds?.length || Object.keys(entry.queuedAwakenedInvocationIdByCatId ?? {}).length) - ); -} - -/** - * Agent/A2A work has a QueueEntry lifecycle but may not have a stored-message - * queueCustody record. Project its existing per-target truth into the same DTO - * consumed by QueuePanel; do not let the browser read raw QueueEntry maps. - * - * A live seen row requires both the recorded child id and its matching body - * exposure. Legacy/mismatched evidence stays receipt-visible but without an id, - * so the UI retains its fail-closed recovery path. - */ -function projectAgentAwakenedTarget(entry: QueueEntry, catId: string): QueueMessageReceipt['targets'][number] { - const invocationId = entry.queuedAwakenedInvocationIdByCatId?.[catId]; - const awakenedAt = entry.queuedAwakenedAtByCatId?.[catId]; - return { - catId, - state: 'awakened', - ...(isNonEmptyString(invocationId) ? { invocationId } : {}), - ...(awakenedAt !== undefined ? { awakenedAt } : {}), - }; -} - -function projectAgentSeenTarget(entry: QueueEntry, catId: string): QueueMessageReceipt['targets'][number] { - const invocationId = entry.queuedSeenInvocationIdByCatId?.[catId]; - if (!isNonEmptyString(invocationId)) return { catId, state: 'seen' }; - const exposure = latestQueueEntryExposure(entry, catId, invocationId); - return exposure ? { catId, state: 'seen', invocationId, seenAt: exposure.seenAt } : { catId, state: 'seen' }; -} - -function projectAgentFailedTarget(entry: QueueEntry, catId: string): QueueMessageReceipt['targets'][number] { - const exposure = latestQueueEntryExposure(entry, catId); - if (exposure) return { catId, state: 'failed', invocationId: exposure.invocationId, seenAt: exposure.seenAt }; - - const invocationId = entry.queuedAwakenedInvocationIdByCatId?.[catId]; - const awakenedAt = entry.queuedAwakenedAtByCatId?.[catId]; - return { - catId, - state: 'failed', - ...(isNonEmptyString(invocationId) ? { invocationId } : {}), - ...(awakenedAt !== undefined ? { awakenedAt } : {}), - }; -} - -function projectAgentReceiptTarget( - entry: QueueEntry, - catId: string, - state: EnrichedTargetState, -): QueueMessageReceipt['targets'][number] { - if (state === 'seen') return projectAgentSeenTarget(entry, catId); - if (state === 'awakened') return projectAgentAwakenedTarget(entry, catId); - if (state === 'failed') return projectAgentFailedTarget(entry, catId); - return { catId, state }; -} - -function projectAgentQueueReceipt( - entry: QueueEntry, - targetCats: readonly string[], - targetStates: EnrichedQueueEntry['targetStates'], -): QueueMessageReceipt | undefined { - if (!hasAgentLiveReceiptEvidence(entry)) return undefined; - - return { - version: 1, - entryId: entry.id, - targets: targetCats.map((catId) => projectAgentReceiptTarget(entry, catId, targetStates[catId])), - reminderAttempts: [], - }; -} - -function encodePathSegment(value: string): string { - return encodeURIComponent(value); -} - -/** - * Stable optimistic-concurrency fence for a failed Queue target. The fence is - * derived only from Queue's canonical failure facts, so it survives durable - * restore and changes whenever a later attempt fails again. - */ -export function failedQueueRecoveryActionId(entry: QueueEntry, catId: string): string { - const failedAt = entry.queuedFailureAtByCatId?.[catId] ?? 'legacy'; - const reason = entry.queuedFailureReasonByCatId?.[catId] ?? 'unknown'; - const attemptId = entry.queuedAttemptIdByCatId?.[catId] ?? 'initial'; - return ['queue-retry', entry.id, encodeURIComponent(catId), failedAt, reason, encodeURIComponent(attemptId)].join( - ':', +function projectAuthorIntents(entry: QueueEntry): Record | undefined { + const projected = Object.fromEntries( + entry.targets.flatMap((targetId) => { + const intent = entry.delivery.authorIntentByTarget?.[targetId]; + if (!intent) return []; + return [[targetId, { ...intent, effective: intent.fallbackAt ? 'next_work' : intent.requested }]]; + }), ); -} - -export function projectQueueRecoveryActions(entry: QueueEntry): QueueRecoveryAction[] { - if (!entry.id || !entry.threadId) return []; - const threadPath = encodePathSegment(entry.threadId); - const entryPath = encodePathSegment(entry.id); - if (entry.status === 'processing') { - return [ - { - id: `queue-force-reset:${entry.id}:${entry.processingStartedAt ?? entry.createdAt}`, - entryId: entry.id, - kind: 'force_reset', - request: { method: 'POST', path: `/api/threads/${threadPath}/force-reset` }, - }, - ]; - } - - const failed = new Set(entry.queuedFailedByCatIds ?? []); - const targetCats = Array.isArray(entry.targetCats) ? entry.targetCats : []; - const actions: QueueRecoveryAction[] = [...failed] - .filter((catId) => targetCats.includes(catId)) - .map((catId) => { - const id = failedQueueRecoveryActionId(entry, catId); - return { - id, - entryId: entry.id, - kind: 'retry_target' as const, - targetCatId: catId, - request: { - method: 'POST' as const, - path: `/api/threads/${threadPath}/queue/${entryPath}/targets/${encodePathSegment(catId)}/retry`, - body: { recoveryActionId: id }, - }, - }; - }); - if (targetCats.some((catId) => !failed.has(catId))) { - actions.unshift({ - id: `queue-steer:${entry.id}`, - entryId: entry.id, - kind: 'steer', - request: { method: 'POST', path: `/api/threads/${threadPath}/queue/${entryPath}/steer` }, - }); - } - actions.push({ - id: `queue-withdraw:${entry.id}`, - entryId: entry.id, - kind: 'withdraw', - request: { method: 'DELETE', path: `/api/threads/${threadPath}/queue/${entryPath}` }, - }); - return actions; + return Object.keys(projected).length > 0 ? projected : undefined; } export function projectPublicQueueEntry(entry: QueueEntry): EnrichedQueueEntry { - const { - ownerAuthProvenance: _internalOwnerAuthProvenance, - exactSteerBatch: _internalExactSteerBatch, - ...publicEntry - } = entry; - const notified = new Set(entry.queuedNotifiedByCatIds ?? []); - const awakened = new Set(Object.keys(entry.queuedAwakenedInvocationIdByCatId ?? {})); - const seen = new Set(entry.queuedSeenByCatIds ?? []); - const failed = new Set(entry.queuedFailedByCatIds ?? []); - const handled = new Set(entry.queuedHandledByCatIds ?? []); - const steering = new Set([ - ...(entry.steerRequestedByCatIds ?? []), - ...Object.keys(entry.steeredInvocationIdByCatId ?? {}), - ]); - const targetCats = Array.isArray(entry.allTargetCats) - ? entry.allTargetCats - : [...new Set([...(Array.isArray(entry.targetCats) ? entry.targetCats : []), ...handled])]; - const targetStates = Object.fromEntries( - targetCats.map((catId) => [ - catId, - resolveTargetState(catId, { handled, steering, failed, seen, awakened, notified }), - ]), - ) as EnrichedQueueEntry['targetStates']; - const queueReceipt = projectAgentQueueReceipt(entry, targetCats, targetStates); + const targetCats = queueEntryTargetCats(entry); + const authorIntentByTarget = projectAuthorIntents(entry); return { - ...publicEntry, - targetStates, - recoveryActions: projectQueueRecoveryActions(entry), - ...(queueReceipt ? { queueReceipt } : {}), + id: entry.id, + threadId: entry.threadId, + userId: queueEntryOwnerId(entry), + content: entry.payload.content, + messageId: entry.payload.messageId ?? null, + mergedMessageIds: [], + from: structuredClone(entry.from), + targetCats, + ...(entry.payload.routingWarnings ? { routingWarnings: structuredClone(entry.payload.routingWarnings) } : {}), + intent: entry.execution.intent, + status: 'queued', + ...(authorIntentByTarget ? { authorIntentByTarget } : {}), + createdAt: entry.enqueuedAt, + autoExecute: entry.execution.autoExecute, + priority: entry.priority, + ...(entry.sourceCategory ? { sourceCategory: entry.sourceCategory } : {}), + ...(entry.sourceCategory === 'continuation' ? { continuationKey: entry.payload.sourceRecordId } : {}), + ...(entry.position !== undefined ? { position: entry.position } : {}), }; } -/** Collect all message IDs associated with a queue entry (primary + merged). */ -function collectMessageIds(entry: Pick): string[] { - return [entry.messageId, ...(entry.mergedMessageIds ?? [])].filter( - (id): id is string => typeof id === 'string' && id.length > 0, - ); -} - -type DurableRetryEntry = Pick; - -/** - * Resolve the durable attempt that makes one message-backed Retry executable. - * GET projection and POST execution share this static custody gate; live retry - * authority is still revalidated atomically by the command route. - */ -export function resolveDurableRetryAttemptId( - entry: DurableRetryEntry, - message: StoredMessage, - targetCatId: string, -): string | undefined { - if (message.userId !== entry.userId || message.threadId !== entry.threadId || !message.queueCustody) { - return undefined; - } - const targetCarrier = message.queueCustody.carrierByTargetCatId?.[targetCatId]; - const carrierEntryId = targetCarrier?.entryId ?? message.queueCustody.entryId; - if (carrierEntryId !== entry.id) return undefined; - const target = projectQueueReceipt(message.queueCustody).targets.find((candidate) => candidate.catId === targetCatId); - const latest = target?.attempts?.at(-1); - if ( - target?.state !== 'failed' || - target.retryable === false || - !latest || - (latest.state !== 'failed' && !(latest.state === 'cancelled' && latest.terminalReason === 'invocation_cancelled')) - ) { - return undefined; - } - return latest.id; -} - -function failClosedMessageBackedRetry(entry: EnrichedQueueEntry): EnrichedQueueEntry { - if (collectMessageIds(entry).length === 0) return entry; - return { - ...entry, - recoveryActions: entry.recoveryActions.filter( - (action) => action.kind !== 'retry_target' && action.kind !== 'steer', - ), - }; +/** One source entry references at most one History message. */ +function collectMessageIds(entry: Pick): string[] { + return entry.messageId ? [entry.messageId] : []; } /** Build a message preview by aggregating content from all related messages. */ async function buildMessageEnrichment( - entry: EnrichedQueueEntry, msgIds: string[], messageStore: IMessageStore, -): Promise<{ - messagePreview?: QueueEntryMessagePreview; - queueReceipt?: QueueMessageReceipt; - retryableTargetCatIds: ReadonlySet; -}> { +): Promise<{ messagePreview: QueueEntryMessagePreview } | null> { const blocks: MessageContent[] = []; let replyTo: string | undefined; - let queueReceipt: QueueMessageReceipt | undefined; - const retryableTargetCatIds = new Set(); - - const mergeQueueReceipt = (projected: QueueMessageReceipt): void => { - if (queueReceipt && JSON.stringify(queueReceipt) !== JSON.stringify(projected)) { - throw new Error(`queue receipt projections diverged for entry ${projected.entryId}`); - } - queueReceipt = projected; - }; for (const msgId of msgIds) { const msg = await messageStore.getById(msgId); if (!msg) continue; if (msg.contentBlocks) blocks.push(...msg.contentBlocks); if (!replyTo && msg.replyTo) replyTo = msg.replyTo; - if (msg.queueCustody) mergeQueueReceipt(projectQueueReceipt(msg.queueCustody)); - for (const action of entry.recoveryActions) { - if (action.kind === 'retry_target' && resolveDurableRetryAttemptId(entry, msg, action.targetCatId)) { - retryableTargetCatIds.add(action.targetCatId); - } - } } + if (blocks.length === 0 && !replyTo) return null; return { - ...(blocks.length > 0 || replyTo - ? { - messagePreview: { - ...(blocks.length > 0 ? { contentBlocks: blocks } : {}), - ...(replyTo ? { replyTo } : {}), - }, - } - : {}), - ...(queueReceipt ? { queueReceipt } : {}), - retryableTargetCatIds, + messagePreview: { + ...(blocks.length > 0 ? { contentBlocks: blocks } : {}), + ...(replyTo ? { replyTo } : {}), + }, }; } /** * Enrich queue entries with message previews from the message store. * - * For entries with messageId (and mergedMessageIds), aggregates contentBlocks - * from all associated messages. Returns entries unchanged when messageStore - * is null or when no messageId is available. + * For entries with a messageId, projects its rich History preview. Returns + * entries unchanged when messageStore is null or no messageId is available. */ export async function enrichQueueEntries( entries: QueueEntry[], messageStore: IMessageStore | null | undefined, ): Promise { - return enrichProjectedQueueEntries(entries, messageStore); + const projected = entries.filter(isPublicQueueEntry).map(projectPublicQueueEntry); + return enrichProjectedQueueEntries(projected, messageStore); } async function enrichProjectedQueueEntries( - entries: QueueEntry[], + projected: EnrichedQueueEntry[], messageStore: IMessageStore | null | undefined, ): Promise { - if (entries.length === 0) return []; - if (!messageStore) return entries.map(projectPublicQueueEntry).map(failClosedMessageBackedRetry); - - const enriched = await Promise.all( - entries.map(async (raw): Promise => { - let entry = projectPublicQueueEntry(raw); - const msgIds = collectMessageIds(entry); - if (msgIds.length === 0) return entry; + if (!messageStore || projected.length === 0) return projected; - try { - const messages = await readQueueCarrierMessages(raw, messageStore); - const live = projectUnconsumedQueueCarrier(raw, messages); - if (!live) return null; - entry = projectPublicQueueEntry(live); - const { retryableTargetCatIds, ...enrichment } = await buildMessageEnrichment( - entry, - collectMessageIds(entry), - messageStore, - ); - return { - ...entry, - ...enrichment, - recoveryActions: entry.recoveryActions.filter( - (action) => - (action.kind !== 'retry_target' || retryableTargetCatIds.has(action.targetCatId)) && - (action.kind !== 'steer' || - live.targetCats.some((catId) => - canSteerQueueSources( - live, - messages.filter((message) => collectMessageIds(entry).includes(message.id)), - catId, - ), - )), - ), - }; - } catch { - // Preview failures do not suppress Queue publication, but custody-dependent - // Retry must fail closed until the store can prove the exact action again. - return failClosedMessageBackedRetry(entry); - } - }), - ); - return enriched.filter((entry): entry is EnrichedQueueEntry => entry !== null); -} - -async function projectMessageReceipts( - messageIds: readonly string[], - messageStore: IMessageStore, -): Promise { - const uniqueMessageIds = [...new Set(messageIds.filter((messageId) => messageId.length > 0))]; - const projections = await Promise.all( - uniqueMessageIds.map(async (messageId): Promise => { - try { - const message = await messageStore.getById(messageId); - return message?.queueCustody - ? { messageId, queueReceipt: projectQueueReceipt(message.queueCustody) } - : undefined; - } catch { - // Socket projection is recoverable from history hydration. One unavailable - // message must not suppress the ordered Queue snapshot or sibling receipts. - return undefined; - } - }), - ); - return projections.filter((projection): projection is QueueMessageReceiptProjection => projection !== undefined); + try { + return await Promise.all( + projected.map(async (entry) => { + const msgIds = collectMessageIds(entry); + if (msgIds.length === 0) return entry; + + const enrichment = await buildMessageEnrichment(msgIds, messageStore); + return enrichment ? { ...entry, ...enrichment } : entry; + }), + ); + } catch { + // Presentation-layer enrichment must not break queue mutations. + // Fall back to raw entries on any messageStore error. + return projected; + } } async function buildQueueUpdateProjectionWithinDeadline( entries: QueueEntry[], messageStore: IMessageStore | null | undefined, - receiptMessageIds: readonly string[], -): Promise<{ queue: EnrichedQueueEntry[]; messageReceipts?: QueueMessageReceiptProjection[] }> { - const projected = entries.map(projectPublicQueueEntry); - const failClosedProjection = projected.map(failClosedMessageBackedRetry); - if (!messageStore) return { queue: failClosedProjection }; - if (projected.length === 0 && receiptMessageIds.length === 0) return { queue: projected }; +): Promise<{ queue: EnrichedQueueEntry[] }> { + const projected = entries.filter(isPublicQueueEntry).map(projectPublicQueueEntry); + if (!messageStore || projected.length === 0) return { queue: projected }; let timer: ReturnType | undefined; const deadline = new Promise((resolve) => { @@ -480,14 +192,8 @@ async function buildQueueUpdateProjectionWithinDeadline( timer.unref?.(); }); try { - const update = Promise.all([ - enrichProjectedQueueEntries(entries, messageStore), - projectMessageReceipts(receiptMessageIds, messageStore), - ]).then(([queue, messageReceipts]) => ({ - queue, - ...(messageReceipts.length > 0 ? { messageReceipts } : {}), - })); - return (await Promise.race([update, deadline])) ?? { queue: failClosedProjection }; + const update = enrichProjectedQueueEntries(projected, messageStore).then((queue) => ({ queue })); + return (await Promise.race([update, deadline])) ?? { queue: projected }; } finally { if (timer) clearTimeout(timer); } @@ -506,15 +212,13 @@ export function emitQueueUpdated( entries: QueueEntry[], messageStore: IMessageStore | null | undefined, action: string, - options: QueueUpdatePublicationOptions = {}, ): Promise { const snapshot = freezeQueueSnapshot(entries); - const receiptMessageIds = [...new Set(options.receiptMessageIds ?? [])]; const scopeKey = JSON.stringify([threadId, userId]); const tails = publicationTailsFor(socketManager); const previous = tails.get(scopeKey) ?? Promise.resolve(); const publication = previous.then(async () => { - const payload = await buildQueueUpdateProjectionWithinDeadline(snapshot, messageStore, receiptMessageIds); + const payload = await buildQueueUpdateProjectionWithinDeadline(snapshot, messageStore); socketManager.emitToUser(userId, 'queue_updated', { threadId, ...payload, diff --git a/packages/api/src/utils/sanitize-cli-stderr.ts b/packages/api/src/utils/sanitize-cli-stderr.ts index 398ab658b1..c915f19ed1 100644 --- a/packages/api/src/utils/sanitize-cli-stderr.ts +++ b/packages/api/src/utils/sanitize-cli-stderr.ts @@ -20,6 +20,13 @@ export interface CliStderrSanitizerOptions { additionalHomePaths?: readonly string[]; } +export interface SanitizedStderrExcerptOptions extends CliStderrSanitizerOptions { + /** Which side of the sanitized diagnostic remains visible. */ + edge: 'head' | 'tail'; + /** Maximum number of UTF-16 code units returned to the caller. */ + maxLength: number; +} + /** * Rough Shannon-style entropy estimate (unique chars over total). * High-entropy threshold tuned empirically: a 32-char string with ≥50% unique chars @@ -95,3 +102,13 @@ export function sanitizeCliStderr(input: string, options: CliStderrSanitizerOpti return out; } + +/** + * Produce a bounded user-visible diagnostic without allowing truncation to + * split a secret before the sanitizer sees its identifying prefix. + */ +export function excerptSanitizedStderr(input: string, options: SanitizedStderrExcerptOptions): string { + const { edge, maxLength, additionalHomePaths } = options; + const sanitized = sanitizeCliStderr(input, { additionalHomePaths }).trim(); + return edge === 'head' ? sanitized.slice(0, maxLength) : sanitized.slice(-maxLength); +} diff --git a/packages/api/test/a2a-coalesce.test.js b/packages/api/test/a2a-coalesce.test.js deleted file mode 100644 index ad3cd06b8f..0000000000 --- a/packages/api/test/a2a-coalesce.test.js +++ /dev/null @@ -1,733 +0,0 @@ -/** - * F-coalesce: A2A 同一只猫连发两次 at 的去重/合并 bug - * - * 现象(co-creator 2026-05-30 报):cat 在一个 turn 内对同一只猫连发两条 - * post_message / cross_post(都 @ 同一只猫),期望"合并在一起 at 出来" - * 或"去重,后者才是真实意图"。实际:第一条先被独立执行(可能是错误行动), - * 第二条之后又独立执行 —— 队友被误导 / 先跑错第一步。 - * - * 根因: - * 1. A2A dedup 守卫 hasQueuedAgentForCat 只检查 status==='queued'(故意的, - * 让 processing 时还能排新 handoff)。 - * 2. 但 A2A entry autoExecute:true → enqueueA2ATargets enqueue 后立即 - * tryAutoExecute → 第一条几乎瞬间从 queued 变 processing。 - * 3. 第二条到达时第一条已 processing 不是 queued → dedup 失效 → 第二条 - * 照常 enqueue → 两条独立 invocation 串行跑。 - * - * 对照:用户消息(operator 连发两条)走 collectUserBatch → content 拼接合并。 - * agent A2A 路径完全没有 coalescing。 - * - * 修复(coalesce-or-supersede): - * - 第一条还 queued(没开跑)→ 合并 content(operator 同款,无竞态,不丢信息) - * - 第一条 fresh processing(已开跑)→ abort 正在跑的 + enqueue 第二条 - * (last-wins,避免重跑已部分执行的第一条;满足"后者才是真实意图") - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -const QUEUE_PATH = '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -const TRIGGER_PATH = '../dist/routes/callback-a2a-trigger.js'; - -function agentEntryInput(overrides = {}) { - return { - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'system', - content: 'first handoff', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['antig-opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - ...overrides, - }; -} - -describe('InvocationQueue.findInFlightAgentEntry (F-coalesce)', () => { - test('finds a QUEUED agent entry for the cat', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const r = q.enqueue(agentEntryInput({ content: 'first' })); - - const found = q.findInFlightAgentEntry('t1', 'antig-opus'); - assert.ok(found, 'should find the in-flight agent entry'); - assert.equal(found.id, r.entry.id); - assert.equal(found.status, 'queued'); - assert.equal(found.content, 'first'); - assert.equal(found.userId, 'system'); - }); - - test('finds a FRESH PROCESSING agent entry for the cat', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const r = q.enqueue(agentEntryInput()); - assert.equal(q.markProcessingById('t1', r.entry.id), true); - - const found = q.findInFlightAgentEntry('t1', 'antig-opus'); - assert.ok(found, 'should find the processing agent entry'); - assert.equal(found.status, 'processing'); - }); - - test('returns null for a DIFFERENT cat', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - q.enqueue(agentEntryInput({ targetCats: ['antig-opus'] })); - assert.equal(q.findInFlightAgentEntry('t1', 'codex'), null); - }); - - test('returns null for a USER-source entry (only agent entries coalesce)', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - q.enqueue(agentEntryInput({ source: 'user', sourceCategory: undefined, targetCats: ['antig-opus'] })); - assert.equal(q.findInFlightAgentEntry('t1', 'antig-opus'), null); - }); - - // 云端 codex R4 P1: continuation entries are ALSO source:'agent' — must NOT be coalescible, - // otherwise an A2A handoff would merge into the cat's self-continuation prompt. - test('returns null for a CONTINUATION agent entry (sourceCategory mismatch)', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - q.enqueue(agentEntryInput({ sourceCategory: 'continuation', targetCats: ['antig-opus'] })); - assert.equal( - q.findInFlightAgentEntry('t1', 'antig-opus'), - null, - 'A2A handoff must not coalesce into a self-continuation entry', - ); - }); - - test('picks the a2a entry, NOT a continuation, when both exist for the cat', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - q.enqueue( - agentEntryInput({ sourceCategory: 'continuation', content: 'self-continuation', targetCats: ['antig-opus'] }), - ); - const a2a = q.enqueue( - agentEntryInput({ sourceCategory: 'a2a', content: 'real handoff', targetCats: ['antig-opus'] }), - ); - const found = q.findInFlightAgentEntry('t1', 'antig-opus'); - assert.ok(found, 'should find the a2a entry'); - assert.equal(found.id, a2a.entry.id, 'must return the a2a entry, not the continuation'); - assert.equal(found.content, 'real handoff'); - }); - - test('returns null when nothing is in flight', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - assert.equal(q.findInFlightAgentEntry('t1', 'antig-opus'), null); - }); -}); - -describe('PR7 failed A2A target ordinary eligibility', () => { - test('dedup, coalescing, auto-execute, and continuation dequeue all skip the failed target', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const { entry } = q.enqueue(agentEntryInput({ content: 'failed handoff', messageId: 'message-failed' })); - q.markQueuedFailedForCatAcrossUsers( - 't1', - 'antig-opus', - 'invocation-failed', - new Set([entry.id]), - 'invocation_failed', - 200, - ); - - assert.equal(q.findInFlightAgentEntry('t1', 'antig-opus', 'opus'), null, 'dedup must ignore failed target'); - assert.equal( - q.coalesceContentIntoQueuedAgent('t1', 'system', entry.id, 'ordinary sibling handoff', 'message-new'), - false, - 'ordinary coalescing must not reopen failed custody', - ); - assert.deepEqual(q.listAutoExecute('t1'), [], 'auto-execute must not replay failed target'); - assert.equal(q.peekNextQueued('t1', 'system'), null, 'per-user continuation dequeue must skip failed target'); - assert.equal(q.markProcessingAcrossUsers('t1'), null, 'cross-user continuation dequeue must skip failed target'); - assert.equal(q.markProcessingById('t1', entry.id), false, 'entry-id auto-execute race guard must recheck failure'); - assert.equal(q.hasQueuedAgentForCat('t1', 'antig-opus'), false); - assert.equal(q.hasActiveOrQueuedAgentForCat('t1', 'antig-opus'), false); - assert.equal(q.hasPendingForCat('t1', 'antig-opus'), false); - assert.equal(q.hasQueuedOrProcessingForCat('t1', 'antig-opus'), false); - - const stillFailed = q.getEntrySnapshot('t1', 'system', entry.id); - assert.deepEqual(stillFailed.queuedFailedByCatIds, ['antig-opus']); - assert.equal(stillFailed.content, 'failed handoff'); - - const fresh = q.enqueue( - agentEntryInput({ content: 'later fresh handoff', messageId: 'message-fresh', targetCats: ['antig-opus'] }), - ).entry; - const advanced = q.markProcessingAcrossUsers('t1'); - assert.equal(advanced.id, fresh.id, 'a failed head must not starve later eligible work'); - assert.deepEqual(q.getEntrySnapshot('t1', 'system', entry.id).queuedFailedByCatIds, ['antig-opus']); - - const retry = q.retryFailedTarget('t1', 'system', entry.id, 'antig-opus'); - assert.ok(retry, 'the explicit retry transition remains the sole reopening path'); - assert.deepEqual( - q.listAutoExecute('t1').map((candidate) => candidate.id), - [entry.id], - ); - }); - - test('a failed target cannot hide or re-enter through an eligible same-carrier sibling', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const { entry } = q.enqueue( - agentEntryInput({ - content: 'mixed handoff', - messageId: 'message-mixed', - targetCats: ['antig-opus', 'codex'], - }), - ); - q.markQueuedFailedForCatAcrossUsers( - 't1', - 'antig-opus', - 'invocation-failed', - new Set([entry.id]), - 'invocation_failed', - 200, - ); - - assert.equal(q.findInFlightAgentEntry('t1', 'antig-opus'), null); - assert.equal( - q.coalesceContentIntoQueuedAgent( - 't1', - 'system', - entry.id, - 'must not reopen opus', - 'message-opus-repeat', - undefined, - undefined, - undefined, - 'antig-opus', - ), - false, - ); - assert.equal(q.findInFlightAgentEntry('t1', 'codex')?.id, entry.id); - assert.deepEqual( - q.listAutoExecute('t1').map((candidate) => candidate.id), - [entry.id], - ); - }); -}); - -describe('PR7 fan-out custody admission fence', () => { - test('keeps staged carriers pending but nonselectable until the full group commits', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const admissionId = 'fanout-admission-1'; - const { entry } = q.enqueue( - agentEntryInput({ - content: 'staged handoff', - messageId: 'message-staged', - queueCustodyAdmissionId: admissionId, - }), - ); - - assert.equal(q.countAgentEntriesForThread('t1'), 1, 'depth accounting must retain in-flight admission'); - assert.equal(q.hasQueuedAgentForCat('t1', 'antig-opus'), true, 'dedup must still see pending ownership'); - assert.equal(q.hasPendingForCat('t1', 'antig-opus'), true, 'liveness must still see pending ownership'); - assert.deepEqual(q.listAutoExecute('t1'), [], 'auto-execute must not publish a staged carrier'); - assert.equal(q.peekNextQueued('t1', 'system'), null, 'continuation dequeue must not publish a staged carrier'); - assert.equal(q.markProcessingById('t1', entry.id), false, 'entry-id races must recheck the fence'); - assert.equal(q.commitQueueCustodyAdmission('t1', 'system', 'wrong-admission', [entry.id]), false); - assert.equal(q.getEntrySnapshot('t1', 'system', entry.id).queueCustodyAdmissionId, admissionId); - - assert.equal(q.commitQueueCustodyAdmission('t1', 'system', admissionId, [entry.id]), true); - assert.deepEqual( - q.listAutoExecute('t1').map((candidate) => candidate.id), - [entry.id], - ); - }); -}); - -describe('InvocationQueue.coalesceContentIntoQueuedAgent (F-coalesce sourceCategory guard)', () => { - // 云端 codex R4 P1 defense-in-depth: even if a caller passes a non-a2a entryId, coalesce must refuse. - test('refuses to coalesce into a CONTINUATION entry', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const cont = q.enqueue( - agentEntryInput({ sourceCategory: 'continuation', content: 'self-work', targetCats: ['antig-opus'] }), - ); - const ok = q.coalesceContentIntoQueuedAgent('t1', 'system', cont.entry.id, 'handoff content', 'm2'); - assert.equal(ok, false, 'must not splice a handoff into a continuation entry'); - const entry = q.list('t1', 'system').find((e) => e.id === cont.entry.id); - assert.equal(entry.content, 'self-work', 'continuation content must stay untouched'); - }); -}); - -// F216 c0 (砚砚 GPT-5.5 review P1): findInFlightAgentEntry must scope by callerCatId. -// Without it, cat A's queued handoff to antig-opus gets coalesced/superseded by cat B's later -// same-turn handoff to the same target — cross-caller串味. Only the SAME caller's repeated -// same-turn handoffs are semantically mergeable. -describe('InvocationQueue.findInFlightAgentEntry — caller scope (F216 c0)', () => { - test('does NOT match an entry from a DIFFERENT caller', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - // cat A (opus) enqueued a handoff to antig-opus - q.enqueue(agentEntryInput({ callerCatId: 'opus', content: 'A says do X', targetCats: ['antig-opus'] })); - // cat B (gemini) now looks for an in-flight entry to coalesce its OWN handoff into - const found = q.findInFlightAgentEntry('t1', 'antig-opus', 'gemini'); - assert.equal(found, null, "B's handoff must NOT find A's entry — cross-caller coalesce is串味"); - }); - - test('DOES match an entry from the SAME caller (legit same-turn repeat)', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const r = q.enqueue(agentEntryInput({ callerCatId: 'opus', content: 'first', targetCats: ['antig-opus'] })); - const found = q.findInFlightAgentEntry('t1', 'antig-opus', 'opus'); - assert.ok(found, 'same caller repeated handoff should coalesce'); - assert.equal(found.id, r.entry.id); - }); - - test('does NOT match when entry.callerCatId is undefined (no任意-caller adoption)', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - q.enqueue(agentEntryInput({ callerCatId: undefined, content: 'orphan', targetCats: ['antig-opus'] })); - const found = q.findInFlightAgentEntry('t1', 'antig-opus', 'opus'); - assert.equal(found, null, 'undefined-caller entry must not be adopted by an arbitrary caller'); - }); - - test('coalesceContentIntoQueuedAgent refuses cross-caller merge', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const r = q.enqueue(agentEntryInput({ callerCatId: 'opus', content: 'A work', targetCats: ['antig-opus'] })); - // B tries to merge into A's entry by id — must refuse - const ok = q.coalesceContentIntoQueuedAgent('t1', 'system', r.entry.id, 'B content', 'm2', 'gemini'); - assert.equal(ok, false, 'cross-caller coalesce must be refused'); - const entry = q.list('t1', 'system').find((e) => e.id === r.entry.id); - assert.equal(entry.content, 'A work', "A's content must stay untouched by B"); - }); -}); - -describe('InvocationQueue.coalesceContentIntoQueuedAgent (F-coalesce)', () => { - test('merges new content + messageId into a queued agent entry', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const r = q.enqueue(agentEntryInput({ content: 'do task X', messageId: 'm1' })); - - const ok = q.coalesceContentIntoQueuedAgent( - 't1', - 'system', - r.entry.id, - 'actually, stop — answer 3 questions', - 'm2', - ); - assert.equal(ok, true); - - const entry = q.list('t1', 'system').find((e) => e.id === r.entry.id); - assert.match(entry.content, /do task X/, 'original content retained'); - assert.match(entry.content, /answer 3 questions/, 'new content appended'); - assert.ok(entry.mergedMessageIds.includes('m2'), 'new messageId tracked for delivery'); - }); - - test('returns false when the target entry is already processing (cannot merge in-flight)', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - const r = q.enqueue(agentEntryInput()); - q.markProcessingById('t1', r.entry.id); - - assert.equal(q.coalesceContentIntoQueuedAgent('t1', 'system', r.entry.id, 'late', 'm2'), false); - }); - - test('returns false for an unknown entry id', async () => { - const { InvocationQueue } = await import(QUEUE_PATH); - const q = new InvocationQueue(); - assert.equal(q.coalesceContentIntoQueuedAgent('t1', 'system', 'nope', 'x'), false); - }); -}); - -describe('enqueueA2ATargets coalesce/supersede (F-coalesce integration)', () => { - // Helper: build deps with a real InvocationQueue + spy tracker. - // emitCalls captures socketManager.emitToUser so tests can assert queue_updated emission. - async function buildDeps(queue, trackerOverrides = {}, emitCalls = [], queueProcessorOverrides = {}) { - return { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser(userId, event, data) { - emitCalls.push({ userId, event, data }); - }, - }, - invocationTracker: { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - ...trackerOverrides, - }, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - return Promise.resolve(); - }, - clearPause() {}, - releaseSlot() {}, - ...queueProcessorOverrides, - }, - invocationQueue: queue, - log: { info() {}, warn() {}, error() {} }, - }; - } - - test('SECOND handoff merges into a QUEUED first handoff — no duplicate entry', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - // First handoff sits queued (slot busy, not yet auto-executed) - queue.enqueue(agentEntryInput({ content: 'do task X', messageId: 'm1' })); - - const deps = await buildDeps(queue); - const result = await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'actually, stop — answer 3 questions first', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm2', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'opus', - }); - - // No new entry — merged into the existing queued one - const entries = queue.list('t1', 'system').filter((e) => e.source === 'agent'); - assert.equal(entries.length, 1, 'must NOT create a duplicate agent entry'); - assert.match(entries[0].content, /do task X/, 'original content kept'); - assert.match(entries[0].content, /answer 3 questions/, 'second handoff merged in'); - // A coalesce is NOT a new route — it must NOT appear in `enqueued` (callbacks.ts derives - // body.routed from enqueued; reporting it would falsely claim "已路由" for a merge). - assert.deepEqual(result.enqueued, [], 'merge is not a new route — enqueued stays empty'); - assert.deepEqual(result.coalesced, ['antig-opus'], 'cat handled via coalesce, reported separately'); - }); - - test('parallel selves with the same catId but different parent invocations stay independent', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - queue.enqueue( - agentEntryInput({ - callerCatId: 'codex-sol', - a2aParentInvocationId: 'invocation-from-thread-a', - content: 'old handoff from thread A', - messageId: 'm-old', - }), - ); - - const deps = await buildDeps(queue); - const result = await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'independent handoff from thread B', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm-new', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'codex-sol', - parentInvocationId: 'invocation-from-thread-b', - }); - - const entries = queue - .list('t1', 'system') - .filter((entry) => entry.source === 'agent' && entry.targetCats.includes('antig-opus')); - assert.equal(entries.length, 2, 'parallel invocations must enqueue independent handoffs'); - assert.equal(entries[0].content, 'old handoff from thread A', 'new content must not leak into the old invocation'); - assert.equal(entries[1].content, 'independent handoff from thread B'); - assert.equal(entries[1].a2aParentInvocationId, 'invocation-from-thread-b'); - assert.deepEqual(result.enqueued, ['antig-opus']); - assert.deepEqual(result.coalesced, []); - }); - - test('repeated handoffs from the same parent invocation still coalesce', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - queue.enqueue( - agentEntryInput({ - callerCatId: 'codex-sol', - a2aParentInvocationId: 'same-parent-invocation', - content: 'first same-turn handoff', - messageId: 'm-first', - }), - ); - - const deps = await buildDeps(queue); - const result = await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'second same-turn handoff', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm-second', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'codex-sol', - parentInvocationId: 'same-parent-invocation', - }); - - const entries = queue - .list('t1', 'system') - .filter((entry) => entry.source === 'agent' && entry.targetCats.includes('antig-opus')); - assert.equal(entries.length, 1, 'one parent invocation should keep same-turn coalescing'); - assert.match(entries[0].content, /first same-turn handoff/); - assert.match(entries[0].content, /second same-turn handoff/); - assert.deepEqual(result.enqueued, []); - assert.deepEqual(result.coalesced, ['antig-opus']); - }); - - // 云端 codex R4 P2: coalesce mutates entry.content, which the web QueueEntryRow renders. The - // backend MUST emit queue_updated on coalesce too, else the user sees the stale pre-merge handoff. - test('coalesce emits queue_updated so the frontend re-renders merged content', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - queue.enqueue(agentEntryInput({ content: 'do task X', messageId: 'm1' })); - - const emitCalls = []; - const deps = await buildDeps(queue, {}, emitCalls); - await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'actually, answer 3 questions first', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm2', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'opus', - }); - - const queueUpdated = emitCalls.filter((c) => c.event === 'queue_updated'); - assert.equal(queueUpdated.length, 1, 'pure coalesce must still emit queue_updated for frontend re-render'); - assert.equal(queueUpdated[0].userId, 'system'); - // F216 AC-D7: action must be 'coalesced' (not 'enqueued') when content was merged into - // an existing entry without creating a new queue entry — semantically accurate for observability. - assert.equal(queueUpdated[0].data.action, 'coalesced', 'pure coalesce action must be "coalesced" not "enqueued"'); - // The emitted queue list must carry the merged content (not the stale pre-merge text). - const mergedEntry = queueUpdated[0].data.queue.find((e) => e.targetCats.includes('antig-opus')); - assert.ok(mergedEntry, 'merged entry present in emitted queue'); - assert.match(mergedEntry.content, /answer 3 questions/, 'emitted queue carries merged content'); - }); - - // F216 c3: when the first handoff is already PROCESSING, the second same-turn handoff from the - // same caller→target SUPERSEDES it (last-wins): abort the running invocation and restart with the - // follow-up. The superseded first handoff must NOT continue and must NOT re-run. This reuses the - // force-send abort-resume coordinate system (cancelInvocation + clearPause + releaseSlot) so we do - // NOT fork a second abort path that races the QueueProcessor processingSlots mutex (LL-064). The - // follow-up is enqueued (fall-through) and restarted by tryAutoExecute once the slot frees. - test('SUPERSEDE: second handoff to a PROCESSING cat aborts the running one and restarts with the follow-up', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - // First handoff is already processing (autoExecute kicked it off) - const r1 = queue.enqueue(agentEntryInput({ content: 'do task X' })); - queue.markProcessingById('t1', r1.entry.id); - const firstEntryId = r1.entry.id; - - // Spy the abort-resume coordinate system — supersede MUST drive all three. - const controller = new AbortController(); - const cancelCalls = []; - const clearPauseCalls = []; - const releaseSlotCalls = []; - const deps = await buildDeps( - queue, - { - has: () => true, - getController: () => controller, - cancelInvocation: (threadId, cats, userId, reason) => { - cancelCalls.push({ threadId, cats, userId, reason }); - controller.abort(reason); - return cats; // cancelledCatIds - }, - }, - [], - { - clearPause: (threadId, catId) => clearPauseCalls.push({ threadId, catId }), - releaseSlot: (threadId, catId) => releaseSlotCalls.push({ threadId, catId }), - }, - ); - - const result = await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'STOP — answer 3 questions first', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm2', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'opus', - }); - - // 1. The running handoff is aborted (last-wins). - assert.equal(controller.signal.aborted, true, 'supersede MUST abort the running handoff'); - // 2. cancelInvocation called once for the target cat with the 'preempted' reason (force-send model). - assert.equal(cancelCalls.length, 1, 'cancelInvocation called exactly once'); - assert.deepEqual(cancelCalls[0].cats, ['antig-opus']); - assert.equal(cancelCalls[0].reason, 'preempted'); - // 3. clearPause + releaseSlot called — drop the stale pause and free the mutex so the follow-up restarts. - assert.equal(clearPauseCalls.length, 1, 'clearPause called to drop the stale pause'); - assert.equal(releaseSlotCalls.length, 1, 'releaseSlot called to free the mutex for restart'); - // 4. The superseded first handoff is removed — it must NOT re-run. - const firstStillPresent = queue.list('t1', 'system').some((e) => e.id === firstEntryId); - assert.equal(firstStillPresent, false, 'superseded first handoff removed — must not re-run'); - // 5. The follow-up is enqueued as the only executable next entry. - const queued = queue.list('t1', 'system').filter((e) => e.source === 'agent' && e.status === 'queued'); - assert.equal(queued.length, 1, 'follow-up enqueued as the next entry'); - assert.match(queued[0].content, /answer 3 questions/, 'follow-up carries the second handoff intent'); - assert.deepEqual(result.enqueued, ['antig-opus']); - }); - - // F216 c3 pre-start window: when markProcessing happened but tracker.startAll hasn't - // registered yet (the await invocationRecordStore.create() gap), cancelInvocation would return - // empty. The trigger uses removeProcessed as a TOMBSTONE signal — QueueProcessor.executeEntry - // checks entry presence after startAll and self-aborts if removed. Trigger must NOT - // releaseSlot/clearPause (slot freed by executeEntry's .then chain after self-abort). - test('SUPERSEDE pre-start window: tracker not registered → tombstone removal, no releaseSlot, follow-up queued', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - // First handoff marked processing (by QueueProcessor) but startAll not yet called - const r1 = queue.enqueue(agentEntryInput({ content: 'do task X' })); - queue.markProcessingById('t1', r1.entry.id); - const firstEntryId = r1.entry.id; - - // Tracker has() returns false = pre-start window (startAll not yet reached) - const controller = new AbortController(); - const cancelCalls = []; - const releaseSlotCalls = []; - const deps = await buildDeps( - queue, - { - has: () => false, // <-- pre-start window: tracker not registered - getController: () => controller, - cancelInvocation: (threadId, cats, userId, reason) => { - cancelCalls.push({ threadId, cats, userId, reason }); - return []; // would return empty since not registered - }, - }, - [], - { - clearPause: () => {}, - releaseSlot: (threadId, catId) => releaseSlotCalls.push({ threadId, catId }), - }, - ); - - const result = await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'STOP — answer 3 questions first', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm2', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'opus', - }); - - // 1. Controller NOT aborted (cannot abort via tracker — not registered yet) - assert.equal(controller.signal.aborted, false, 'pre-start window: must NOT abort via tracker'); - // 2. cancelInvocation NOT called (tracker unregistered, would return empty) - assert.equal(cancelCalls.length, 0, 'cancelInvocation not called in pre-start window'); - // 3. releaseSlot NOT called (slot freed by executeEntry's self-abort → finally → .then chain) - assert.equal(releaseSlotCalls.length, 0, 'releaseSlot NOT called — executeEntry handles slot release'); - // 4. First entry REMOVED from queue (tombstone signal for QueueProcessor.executeEntry guard — - // executeEntry checks entry presence after startAll and self-aborts if removed) - const firstStillPresent = queue.list('t1', 'system').some((e) => e.id === firstEntryId); - assert.equal(firstStillPresent, false, 'first entry removed as tombstone — executeEntry will self-abort'); - // 5. Follow-up still enqueued (not lost — will run after onInvocationComplete) - const queued = queue.list('t1', 'system').filter((e) => e.source === 'agent' && e.status === 'queued'); - assert.equal(queued.length, 1, 'follow-up enqueued for deferred execution'); - assert.match(queued[0].content, /answer 3 questions/, 'follow-up carries second handoff intent'); - assert.deepEqual(result.enqueued, ['antig-opus']); - }); - - test('THIRD same-turn handoff coalesces into the queued follow-up (no unbounded duplicates)', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - // First processing, second enqueued as follow-up (queued) - const r1 = queue.enqueue(agentEntryInput({ content: 'task A' })); - queue.markProcessingById('t1', r1.entry.id); - queue.enqueue(agentEntryInput({ content: 'task B (follow-up)' })); - - const deps = await buildDeps(queue, { has: () => true, getController: () => new AbortController() }); - - await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'task C (final intent)', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm3', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'opus', - }); - - // Third coalesces into the queued follow-up — still exactly one queued entry, not two - const queued = queue.list('t1', 'system').filter((e) => e.source === 'agent' && e.status === 'queued'); - assert.equal(queued.length, 1, 'third handoff must merge into the queued follow-up, not add a duplicate'); - assert.match(queued[0].content, /task B/, 'follow-up retains earlier queued content'); - assert.match(queued[0].content, /task C/, 'follow-up gains the final intent'); - }); - - test('does NOT coalesce across different cats (only same-cat)', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - // antig-opus has a queued entry; new handoff targets codex → independent, no merge - queue.enqueue(agentEntryInput({ targetCats: ['antig-opus'], content: 'for antig' })); - const deps = await buildDeps(queue); - - await enqueueA2ATargets(deps, { - targetCats: ['codex'], - content: 'review please', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'm2', mentions: ['codex'], content: 'test' }, - callerCatId: 'opus', - }); - - const antigEntries = queue.list('t1', 'system').filter((e) => e.targetCats.includes('antig-opus')); - const codexEntries = queue.list('t1', 'system').filter((e) => e.targetCats.includes('codex')); - assert.equal(antigEntries.length, 1, 'antig-opus entry untouched'); - assert.ok(!/review please/.test(antigEntries[0].content), 'codex content must NOT leak into antig entry'); - assert.equal(codexEntries.length, 1, 'codex handoff enqueued independently'); - }); - - // F216 P1-1 (砚砚 review): the PRODUCTION lookup must be caller-scoped, not just the merge guard. - // When A and B BOTH have a queued handoff to the same target, B's repeat must coalesce into B's - // OWN entry. Without callerCatId at the lookup, findInFlightAgentEntry returns A's entry first; - // the merge guard refuses (cross-caller) and the repeat falls through to a 3rd duplicate entry. - test('F216 P1-1: B repeat coalesces into B own entry when A and B both queued to same target', async () => { - const { enqueueA2ATargets } = await import(TRIGGER_PATH); - const { InvocationQueue } = await import(QUEUE_PATH); - const queue = new InvocationQueue(); - - // A (opus) and B (gemini) BOTH have a queued handoff to antig-opus. - queue.enqueue(agentEntryInput({ content: 'A: do X', callerCatId: 'opus', messageId: 'mA' })); - queue.enqueue(agentEntryInput({ content: 'B: do Y', callerCatId: 'gemini', messageId: 'mB' })); - - const deps = await buildDeps(queue); - const result = await enqueueA2ATargets(deps, { - targetCats: ['antig-opus'], - content: 'B: actually do Z', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'mB2', mentions: ['antig-opus'], content: 'test' }, - callerCatId: 'gemini', - }); - - const entries = queue - .list('t1', 'system') - .filter((e) => e.source === 'agent' && e.targetCats.includes('antig-opus')); - assert.equal(entries.length, 2, "B's repeat must merge into B's own entry, not create a 3rd duplicate"); - const bEntry = entries.find((e) => e.callerCatId === 'gemini'); - assert.ok(bEntry, "B's entry present"); - assert.match(bEntry.content, /do Y/, "B's original content retained"); - assert.match(bEntry.content, /do Z/, "B's repeat merged into B's own entry"); - const aEntry = entries.find((e) => e.callerCatId === 'opus'); - assert.ok(aEntry, "A's entry present"); - assert.match(aEntry.content, /do X/, "A's entry retained"); - assert.ok(!/do Z/.test(aEntry.content), "B's content must NOT leak into A's entry"); - assert.deepEqual(result.coalesced, ['antig-opus'], 'B repeat reported as coalesced, not a new route'); - }); -}); diff --git a/packages/api/test/a2a-dispatch-disposition-contention.test.js b/packages/api/test/a2a-dispatch-disposition-contention.test.js deleted file mode 100644 index 20508bc5ad..0000000000 --- a/packages/api/test/a2a-dispatch-disposition-contention.test.js +++ /dev/null @@ -1,164 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { createCatId } from '@cat-cafe/shared'; -import { buildHandedEvent, buildInvocationHeartbeatEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; -import { - createA2ADispositionAuth as auth, - createA2ADispositionHarness as harness, -} from './helpers/a2a-dispatch-disposition-harness.js'; - -const SUBJECT = 'ball:thread:thread-1'; -const heartbeat = (at) => - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: 'inv-1', - catId: 'codex-sol', - draftUpdatedAt: at, - }); -const dispositions = async (h) => - (await h.eventLog.read(SUBJECT)).filter((event) => event.kind === 'ball.dispatch_dispositioned'); - -test('#1371 a heartbeat race completes the same dispatch after fresh validation', async () => { - let attempts = 0; - const warnings = []; - const h = await harness({ - log: { warn: (fields) => warnings.push(fields) }, - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - if (attempts === 1) await ingest.record(heartbeat(1_500)); - }, - }); - - const result = await h.service.complete(auth(h), 'handled'); - - assert.equal(result.outcome, 'applied'); - assert.equal(result.sourceMessageId, h.source.id); - assert.equal(result.invocationId, 'inv-1'); - assert.equal(result.retired, false); - assert.equal(attempts, 2); - assert.equal((await dispositions(h)).length, 1); - assert.equal((await h.projectionStore.get(SUBJECT)).state, 'resolved'); - assert.equal(warnings.length, 1); - assert.equal(warnings[0].expectedSequence, 1); - assert.equal(warnings[0].actualSequence, 2); - assert.equal(warnings[0].invocationId, 'inv-1'); - assert.equal(warnings[0].sourceMessageId, h.source.id); - assert.deepEqual(warnings[0].interveningEvents, [ - { sourceEventId: 'inv:inv-1:hb:1500', kind: 'invocation.heartbeat', at: 1_500 }, - ]); - assert.equal((await h.service.complete(auth(h), 'handled')).outcome, 'replayed'); - assert.equal(attempts, 2, 'replay must not attempt another append'); -}); - -test('#1371 sustained contention remains a bounded failure with no terminal', async () => { - let attempts = 0; - const warnings = []; - const h = await harness({ - log: { warn: (fields) => warnings.push(fields) }, - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - await ingest.record(heartbeat(1_500 + attempts)); - }, - }); - - await assert.rejects(h.service.complete(auth(h), 'handled'), { code: 'a2a_dispatch_disposition_fence_conflict' }); - - assert.equal(attempts, 2, 'one callback may try the append at most twice'); - assert.equal((await dispositions(h)).length, 0); - assert.equal((await h.projectionStore.get(SUBJECT)).state, 'active'); - assert.deepEqual( - warnings.map(({ expectedSequence, actualSequence }) => [expectedSequence, actualSequence]), - [ - [1, 2], - [2, 3], - ], - ); -}); - -test('#1371 a verified successor arriving during CAS returns its replacement pointer', async () => { - let attempts = 0; - let successor; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - successor = h.messageStore.append({ - userId: 'user-1', - threadId: 'thread-1', - catId: createCatId('codex-sol'), - content: '@opus continue this exact source', - mentions: [createCatId('opus')], - replyTo: h.source.id, - timestamp: 1_500, - }); - await ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'codex-sol', - toCatId: 'opus', - messageId: successor.id, - at: 1_500, - }), - ); - }, - }); - - await assert.rejects(h.service.complete(auth(h), 'completed'), (error) => { - assert.equal(error.code, 'a2a_dispatch_disposition_replaced'); - assert.equal(error.replacement.sourceMessageId, successor.id); - assert.equal(error.replacement.toCatId, 'opus'); - return true; - }); - assert.equal(attempts, 1, 'fresh lineage validation rejects before a second append'); - assert.equal((await dispositions(h)).length, 0); - assert.equal((await h.projectionStore.get(SUBJECT)).holder, 'opus'); -}); - -test('#1371 a replaced invocation cannot use the contention retry', async () => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - h.setLatest(false); - await ingest.record(heartbeat(1_500)); - }, - }); - - await assert.rejects(h.service.complete(auth(h), 'handled'), { code: 'a2a_dispatch_disposition_stale_invocation' }); - assert.equal(attempts, 1); - assert.equal((await dispositions(h)).length, 0); -}); - -test('#1371 the retry re-resolves a source that became unavailable', async () => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - h.messageStore.getById = async () => null; - await ingest.record(heartbeat(1_500)); - }, - }); - - await assert.rejects(h.service.complete(auth(h), 'handled'), { code: 'a2a_dispatch_disposition_source_mismatch' }); - assert.equal(attempts, 1); - assert.equal((await dispositions(h)).length, 0); -}); - -test('#1371 conflict diagnostics bound event metadata and never include event payloads', async () => { - let attempts = 0; - const warnings = []; - const h = await harness({ - log: { warn: (fields) => warnings.push(fields) }, - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - if (attempts !== 1) return; - for (let index = 0; index < 12; index += 1) await ingest.record(heartbeat(1_500 + index)); - }, - }); - - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'applied'); - assert.equal(warnings.length, 1); - assert.equal(warnings[0].actualSequence, 13); - assert.equal(warnings[0].interveningEvents.length, 8); - assert.equal(warnings[0].omittedEventCount, 4); - assert.ok(warnings[0].interveningEvents.every((event) => !('payload' in event))); -}); diff --git a/packages/api/test/a2a-dispatch-disposition-invocation-binding.test.js b/packages/api/test/a2a-dispatch-disposition-invocation-binding.test.js deleted file mode 100644 index b08634b4da..0000000000 --- a/packages/api/test/a2a-dispatch-disposition-invocation-binding.test.js +++ /dev/null @@ -1,187 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import Fastify from 'fastify'; -import './helpers/setup-cat-registry.js'; - -import { createCatId } from '@cat-cafe/shared'; -import { createRedisClient } from '@cat-cafe/shared/utils'; -import { buildInvocationHeartbeatEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; -import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; -import { invokeSingleCat } from '../dist/domains/cats/services/agents/invocation/invoke-single-cat.js'; -import { RedisAuthInvocationBackend } from '../dist/domains/cats/services/agents/invocation/RedisAuthInvocationBackend.js'; -import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; -import { callbacksRoutes } from '../dist/routes/callbacks.js'; -import { createA2ADispositionHarness as harness } from './helpers/a2a-dispatch-disposition-harness.js'; - -function sessionManager() { - return { - get: async () => undefined, - getOrCreate: async () => ({}), - store: async () => {}, - delete: async () => {}, - resolveWorkingDirectory: () => '/tmp/test', - }; -} - -async function createInvocation(registry, source, overrides = {}) { - const service = { - async *invoke() { - yield { type: 'done', catId: 'codex-sol', timestamp: Date.now() }; - }, - }; - let invocationId; - for await (const message of invokeSingleCat( - { - registry, - sessionManager: sessionManager(), - threadStore: null, - apiUrl: 'http://127.0.0.1:3004', - }, - { - catId: createCatId('codex-sol'), - service, - prompt: source.content, - userId: 'user-1', - ownerAuthProvenance: 'strict', - threadId: 'thread-1', - parentInvocationId: 'parent-a2a-1', - a2aTriggerMessageId: source.id, - isLastCat: true, - ...overrides, - }, - )) { - if (message.type !== 'system_info' || !message.content) continue; - const payload = JSON.parse(message.content); - if (payload.type === 'invocation_created') invocationId = payload.invocationId; - } - assert.ok(invocationId, 'the production path must create one callback-auth principal'); - const record = await registry.getRecord(invocationId); - assert.ok(record); - return record; -} - -async function createCallbackApp(registry, a2aDispatchDispositionService) { - const app = Fastify(); - await app.register(callbacksRoutes, { - registry, - messageStore: { - async getMessagesForThread() { - return []; - }, - }, - socketManager: { - broadcastAgentMessage() {}, - getMessages() { - return []; - }, - }, - threadStore: new ThreadStore(), - evidenceStore: { - async store() {}, - async search() { - return []; - }, - }, - markerQueue: { enqueue() {} }, - reflectionService: { async run() {} }, - holdBallDeps: { registry, a2aDispatchDispositionService }, - }); - return app; -} - -test('normal create and continuation bind the server-derived exact A2A source', async () => { - const registry = new InvocationRegistry(); - const h = await harness({ registry }); - const initial = await createInvocation(registry, h.source); - const continuation = await createInvocation(registry, h.source, { - executionCausal: { triggerMessageId: 'queue-envelope-must-not-replace-exact-source' }, - }); - - for (const record of [initial, continuation]) { - assert.equal(record.a2aTriggerMessageId, h.source.id); - assert.equal(record.originTriggerMessageId, h.source.id); - } - await assert.rejects( - () => h.service.complete(initial, 'handled'), - /^A2ADispatchDispositionError: a2a_dispatch_disposition_stale_invocation$/, - ); - assert.equal((await h.service.complete(continuation, 'completed')).outcome, 'applied'); - assert.equal((await h.service.complete(continuation, 'completed')).outcome, 'replayed'); -}); - -test('the real callback completes and replays its bound source despite a heartbeat race', async (t) => { - const registry = new InvocationRegistry(); - let record; - let attempts = 0; - const h = await harness({ - registry, - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - if (attempts === 1) { - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: record.invocationId, - catId: 'codex-sol', - draftUpdatedAt: Date.now(), - }), - ); - } - }, - }); - record = await createInvocation(registry, h.source); - const app = await createCallbackApp(registry, h.service); - t.after(() => app.close()); - const origin = await app.listen({ host: '127.0.0.1', port: 0 }); - const complete = () => - fetch(`${origin}/api/callbacks/complete-a2a-dispatch`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-invocation-id': record.invocationId, - 'x-callback-token': record.callbackToken, - }, - body: JSON.stringify({ disposition: 'completed' }), - }); - const response = await complete(); - - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.outcome, 'applied'); - assert.equal(body.disposition, 'completed'); - assert.equal(body.invocationId, record.invocationId); - assert.equal(body.sourceMessageId, h.source.id); - const replay = await complete(); - assert.equal(replay.status, 200); - assert.equal((await replay.json()).outcome, 'replayed'); - assert.equal(attempts, 2, 'two HTTP calls commit one terminal after a single internal retry'); -}); - -const redisUrl = process.env.REDIS_URL; -test( - 'restart rehydrates the exact A2A source before disposition', - { skip: !redisUrl || redisUrl.includes(':6399') ? 'requires isolated Redis, never 6399' : false }, - async () => { - const keyPrefix = `cat-cafe-test:a2a-source:${process.pid}:`; - let redis = createRedisClient({ url: redisUrl, keyPrefix }); - let activeRegistry = new InvocationRegistry({ backend: new RedisAuthInvocationBackend(redis) }); - const h = await harness({ registry: { isLatest: (invocationId) => activeRegistry.isLatest(invocationId) } }); - const record = await createInvocation(activeRegistry, h.source); - await redis.quit(); - - redis = createRedisClient({ url: redisUrl, keyPrefix }); - activeRegistry = new InvocationRegistry({ backend: new RedisAuthInvocationBackend(redis) }); - try { - const recovered = await activeRegistry.getRecord(record.invocationId); - assert.ok(recovered); - assert.equal(recovered.a2aTriggerMessageId, h.source.id); - assert.equal(recovered.originTriggerMessageId, h.source.id); - assert.equal((await h.service.complete(recovered, 'completed')).outcome, 'applied'); - } finally { - const keys = await redis.keys(`${keyPrefix}auth:*`); - const logicalKeys = keys.map((key) => key.replace(keyPrefix, '')); - if (logicalKeys.length > 0) await redis.del(...logicalKeys); - await redis.quit(); - } - }, -); diff --git a/packages/api/test/a2a-dispatch-disposition-same-cat.test.js b/packages/api/test/a2a-dispatch-disposition-same-cat.test.js deleted file mode 100644 index 70ed6a28d8..0000000000 --- a/packages/api/test/a2a-dispatch-disposition-same-cat.test.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * F167 — same-cat cross-thread A2A disposition boundary. - * - * A catId identifies a persona, so parallel invocations require durable - * distinct-thread provenance to distinguish a legitimate handoff from an - * ordinary same-thread self mention. - */ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { - createA2ADispositionAuth as auth, - createA2ADispositionHarness as harness, -} from './helpers/a2a-dispatch-disposition-harness.js'; - -describe('F167 same-cat A2A dispatch disposition', () => { - test('cross-thread handoff terminalizes only with durable cross-post provenance', async () => { - const h = await harness({ - sourceCatId: 'codex-sol', - crossPostSourceThreadId: 'thread-source', - }); - - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'applied'); - assert.deepEqual( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.dispatch_dispositioned'), - [ - { - sourceEventId: `dispatch-disposition:inv-1:${h.source.id}`, - subjectKey: 'ball:thread:thread-1', - kind: 'ball.dispatch_dispositioned', - classification: 'state-changing', - payload: { - catId: 'codex-sol', - fromCatId: 'codex-sol', - invocationId: 'inv-1', - sourceMessageId: h.source.id, - disposition: 'completed', - }, - at: 2_000, - }, - ], - ); - }); - - test('same-thread self handoff remains rejected even with an exact handed event', async () => { - const h = await harness({ sourceCatId: 'codex-sol' }); - - await assert.rejects( - () => h.service.complete(auth(h), 'completed'), - /^A2ADispatchDispositionError: a2a_dispatch_disposition_source_mismatch$/, - ); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.dispatch_dispositioned'), - false, - ); - }); -}); diff --git a/packages/api/test/a2a-dispatch-disposition.test.js b/packages/api/test/a2a-dispatch-disposition.test.js deleted file mode 100644 index a7006f230a..0000000000 --- a/packages/api/test/a2a-dispatch-disposition.test.js +++ /dev/null @@ -1,450 +0,0 @@ -/** - * F167 — exact ordinary A2A dispatch disposition. - * - * Uses the real event log/projector/service. This is the regression for - * thread_mselq023jyelommr: an ordinary A2A turn completed real work, but the - * dispatch ball had no invocation-bound terminal producer and a routing_guard - * child was spawned. - */ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { createCatId } from '@cat-cafe/shared'; -import { buildHandedEvent, buildHeldEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; -import { TurnCustodyProjectionService } from '../dist/domains/ball-custody/TurnCustodyProjectionService.js'; -import { - createA2ADispositionAuth as auth, - createA2ADispositionWake as dispatchWake, - createA2ADispositionHarness as harness, -} from './helpers/a2a-dispatch-disposition-harness.js'; - -describe('F167 ordinary A2A dispatch disposition', () => { - test('only the exact invocation-bound producer terminalizes the dispatch ball once', async () => { - const h = await harness(); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: h.projectionStore, - ballCustodyEventLog: h.eventLog, - }); - const opened = await gate.open(dispatchWake(h)); - - assert.equal((await gate.close(opened)).shouldBlock, true); - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'applied'); - assert.deepEqual(await gate.close(opened), { - state: 'covered_active', - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'dispatch_dispositioned', - dispatchDisposition: 'completed', - dispatchDispositionEventId: `dispatch-disposition:inv-1:${h.source.id}`, - dispatchDispositionAt: 2_000, - evidenceRefs: [`dispatch:ball:thread:thread-1`, `route:${h.source.id}:codex-sol`], - }); - - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'replayed'); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.dispatch_dispositioned') - .length, - 1, - ); - }); - - test('handled remains distinguishable from completed at the stop-gate decision boundary', async () => { - const h = await harness(); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: h.projectionStore, - ballCustodyEventLog: h.eventLog, - }); - const opened = await gate.open(dispatchWake(h)); - - await h.service.complete(auth(h), 'handled'); - - assert.deepEqual(await gate.close(opened), { - state: 'covered_active', - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'dispatch_dispositioned', - dispatchDisposition: 'handled', - dispatchDispositionEventId: `dispatch-disposition:inv-1:${h.source.id}`, - dispatchDispositionAt: 2_000, - evidenceRefs: [`dispatch:ball:thread:thread-1`, `route:${h.source.id}:codex-sol`], - }); - }); - - test('terminalizes the exact dispatch without stealing an unrelated cat hold', async () => { - const h = await harness(); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: h.projectionStore, - ballCustodyEventLog: h.eventLog, - }); - const opened = await gate.open(dispatchWake(h)); - await h.ingest.record( - buildHeldEvent({ - threadId: 'thread-1', - catId: 'opus', - fireAt: 99_000, - at: 1_500, - }), - ); - - const result = await h.service.complete(auth(h), 'completed'); - - assert.equal(result.outcome, 'applied'); - assert.equal(result.retired, true, 'the exact child terminal is inert on the unrelated subject holder'); - const disposition = (await h.eventLog.read('ball:thread:thread-1')).find( - (event) => event.kind === 'ball.dispatch_dispositioned', - ); - assert.equal(disposition.payload.retired, true); - const projection = await h.projectionStore.get('ball:thread:thread-1'); - assert.equal(projection.state, 'active'); - assert.equal(projection.holder, 'opus'); - assert.equal(projection.heldUntil, 99_000); - assert.equal(projection.lastEventAt, 2_000); - assert.equal((await gate.close(opened)).shouldBlock, false, 'the exact dispatch stop gate observes its terminal'); - }); - - test('concurrent conflicting dispositions linearize to one event and reject the loser', async () => { - const h = await harness(); - const results = await Promise.allSettled([ - h.service.complete(auth(h), 'handled'), - h.service.complete(auth(h), 'completed'), - ]); - - assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); - assert.equal(results.filter((result) => result.status === 'rejected').length, 1); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.dispatch_dispositioned') - .length, - 1, - ); - }); - - test('a holder race retires only the exact dispatch after revalidation', async () => { - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - await ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'codex-sol', - toCatId: 'opus', - messageId: 'successor-message', - at: 1_500, - }), - ); - }, - }); - - // This handoff has no verified causal message. A fresh read may retire the - // old dispatch, but must not resolve the unrelated holder's active work. - const result = await h.service.complete(auth(h), 'completed'); - assert.equal(result.retired, true); - const projection = await h.projectionStore.get('ball:thread:thread-1'); - assert.equal(projection.state, 'active'); - assert.equal(projection.holder, 'opus'); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter( - (event) => event.kind === 'ball.dispatch_dispositioned' && event.payload.retired === true, - ).length, - 1, - ); - }); - - test('an unrelated reverse handoff cannot retire a live inbound A2A carrier', async () => { - const h = await harness({ sourceCatId: 'opus' }); - const unrelated = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('codex-sol'), - content: '@fable-5 take unrelated work', - mentions: [createCatId('fable-5')], - timestamp: 1_500, - threadId: 'thread-1', - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'codex-sol', - toCatId: 'fable-5', - messageId: unrelated.id, - at: 1_500, - }), - ); - - const result = await h.service.complete(auth(h), 'completed'); - - assert.equal(result.outcome, 'applied'); - assert.equal(result.retired, true); - }); - - test('a hold without message lineage cannot replace a live inbound A2A carrier', async () => { - const h = await harness(); - await h.ingest.record( - buildHeldEvent({ - threadId: 'thread-1', - catId: 'codex-sol', - fireAt: 99_000, - at: 1_500, - }), - ); - - const inspection = await h.service.inspectHandoff({ - threadId: 'thread-1', - catId: 'codex-sol', - sourceMessageId: h.source.id, - }); - const result = await h.service.complete(auth(h), 'completed'); - - assert.equal(inspection.outcome, 'live'); - assert.equal(result.outcome, 'applied'); - }); - - test('wrong source/invocation/thread/holder/from-cat plus stale and replaced calls fail closed', async () => { - const mutations = [ - (h) => auth(h, { a2aTriggerMessageId: 'other-message', originTriggerMessageId: 'other-message' }), - (h) => auth(h, { invocationId: 'other-invocation' }), - (h) => auth(h, { threadId: 'other-thread' }), - (h) => auth(h, { catId: createCatId('opus') }), - (h) => { - h.source.catId = createCatId('codex-sol'); - return auth(h); - }, - (h) => { - h.setLatest(false); - return auth(h); - }, - async (h) => { - const replacement = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('codex-sol'), - content: '@opus continue the exact source', - mentions: [createCatId('opus')], - timestamp: 1_500, - threadId: 'thread-1', - replyTo: h.source.id, - extra: { causal: { kind: 'invocation_reply', triggerMessageId: h.source.id } }, - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'codex-sol', - toCatId: 'opus', - messageId: replacement.id, - at: 1_500, - }), - ); - return auth(h); - }, - ]; - - for (const mutate of mutations) { - const h = await harness(); - const attempt = await mutate(h); - await assert.rejects(() => h.service.complete(attempt, 'handled'), /a2a_dispatch_disposition_/); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.dispatch_dispositioned'), - false, - ); - } - }); - - test('replaced dispositions identify the latest verified successor coordination', async () => { - const h = await harness(); - const successor = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('opus'), - content: '@codex-sol continue the replacement coordination', - mentions: [createCatId('codex-sol')], - timestamp: 1_500, - threadId: 'thread-1', - extra: { - causal: { kind: 'invocation_reply', triggerMessageId: h.source.id }, - coordination: { - id: 'coord-successor', - phase: 'active', - hop: 1, - subjectRef: 'pr:zts212653/cat-cafe#3710', - }, - }, - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: successor.id, - at: 1_500, - }), - ); - - await assert.rejects( - () => h.service.complete(auth(h), 'completed'), - (error) => { - assert.equal(error.code, 'a2a_dispatch_disposition_replaced'); - assert.deepEqual(error.replacement, { - kind: 'handed', - sourceEventId: `route:${successor.id}:codex-sol`, - sourceMessageId: successor.id, - fromCatId: 'opus', - toCatId: 'codex-sol', - coordination: { - id: 'coord-successor', - phase: 'active', - hop: 1, - subjectRef: 'pr:zts212653/cat-cafe#3710', - }, - }); - return true; - }, - ); - }); - - test('a foreign-thread event cannot retire the live source', async () => { - const h = await harness(); - const foreign = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('opus'), - content: '@codex-sol forged successor coordinates', - mentions: [createCatId('codex-sol')], - timestamp: 1_500, - threadId: 'other-thread', - extra: { - coordination: { id: 'coord-foreign', phase: 'active', hop: 1 }, - }, - }); - await h.eventLog.append( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: foreign.id, - at: 1_500, - }), - ); - - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'applied'); - }); - - test('reports the successor even when replacement handed custody away from the caller', async () => { - const h = await harness(); - const successor = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('codex-sol'), - content: '@opus take the successor', - mentions: [createCatId('opus')], - timestamp: 1_500, - threadId: 'thread-1', - replyTo: h.source.id, - extra: { - causal: { kind: 'invocation_reply', triggerMessageId: h.source.id }, - coordination: { id: 'coord-outbound', phase: 'active', hop: 2 }, - }, - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'codex-sol', - toCatId: 'opus', - messageId: successor.id, - at: 1_500, - }), - ); - - await assert.rejects( - () => h.service.complete(auth(h), 'completed'), - (error) => { - assert.equal(error.code, 'a2a_dispatch_disposition_replaced'); - assert.equal(error.replacement.sourceMessageId, successor.id); - assert.equal(error.replacement.coordination.id, 'coord-outbound'); - assert.equal(error.replacement.toCatId, 'opus'); - return true; - }, - ); - }); - - test('coordination replacement requires the same stable id and subject lineage', async () => { - const coordination = { - id: 'coord-stable-lineage', - phase: 'active', - hop: 1, - subjectRef: 'pr:zts212653/cat-cafe#4099', - }; - const matching = await harness({ sourceExtra: { coordination } }); - const matchingSuccessor = matching.messageStore.append({ - userId: 'user-1', - catId: createCatId('opus'), - content: '@codex-sol continue the same coordination', - mentions: [createCatId('codex-sol')], - timestamp: 1_500, - threadId: 'thread-1', - extra: { coordination: { ...coordination, hop: 2 } }, - }); - await matching.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: matchingSuccessor.id, - at: 1_500, - }), - ); - await assert.rejects( - () => matching.service.complete(auth(matching), 'completed'), - /a2a_dispatch_disposition_replaced/, - ); - - const divergent = await harness({ sourceExtra: { coordination } }); - const divergentSuccessor = divergent.messageStore.append({ - userId: 'user-1', - catId: createCatId('opus'), - content: '@codex-sol same id but a different subject', - mentions: [createCatId('codex-sol')], - timestamp: 1_500, - threadId: 'thread-1', - extra: { - coordination: { - ...coordination, - hop: 2, - subjectRef: 'pr:zts212653/cat-cafe#4100', - }, - }, - }); - await divergent.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: divergentSuccessor.id, - at: 1_500, - }), - ); - assert.equal((await divergent.service.complete(auth(divergent), 'completed')).outcome, 'applied'); - }); - - test('unrelated task, command, merge, and another coordination terminal never close this dispatch', async () => { - const h = await harness(); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: h.projectionStore, - ballCustodyEventLog: h.eventLog, - }); - const opened = await gate.open(dispatchWake(h)); - for (const event of [ - { - sourceEventId: 'task:other:done', - subjectKey: 'ball:thread:thread-1', - kind: 'task.done', - classification: 'state-changing', - payload: { taskId: 'other' }, - at: 1_100, - }, - { - sourceEventId: 'route:coord-terminal', - subjectKey: 'ball:thread:thread-1', - kind: 'ball.handed_cvo', - classification: 'state-changing', - payload: { fromCatId: 'fable5', intent: 'done_notify' }, - at: 1_200, - }, - ]) { - await h.eventLog.append(event); - } - assert.equal((await gate.close(opened)).shouldBlock, true); - }); -}); diff --git a/packages/api/test/a2a-dispatch-replacement-enrichment.test.js b/packages/api/test/a2a-dispatch-replacement-enrichment.test.js deleted file mode 100644 index 8761a13666..0000000000 --- a/packages/api/test/a2a-dispatch-replacement-enrichment.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { createCatId } from '@cat-cafe/shared'; -import { buildHandedCvoEvent, buildHandedEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; -import { - createA2ADispositionAuth as auth, - createA2ADispositionHarness as harness, -} from './helpers/a2a-dispatch-disposition-harness.js'; - -function throwForSuccessor(messageStore, successorId, failure) { - const getById = messageStore.getById.bind(messageStore); - messageStore.getById = async (messageId) => { - if (messageId === successorId) throw new Error(failure); - return getById(messageId); - }; -} - -describe('F167 A2A replacement metadata enrichment', () => { - test('unreadable successor metadata cannot manufacture a handed replacement verdict', async () => { - const h = await harness(); - const successor = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('opus'), - content: '@codex-sol continue despite unavailable enrichment', - mentions: [createCatId('codex-sol')], - timestamp: 1_500, - threadId: 'thread-1', - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: successor.id, - at: 1_500, - }), - ); - throwForSuccessor(h.messageStore, successor.id, 'successor metadata unavailable'); - - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'applied'); - }); - - test('unreadable successor metadata cannot manufacture a handed-to-operator replacement verdict', async () => { - const h = await harness(); - const successor = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('codex-sol'), - content: '@co-creator successor coordination', - mentions: [], - timestamp: 1_500, - threadId: 'thread-1', - }); - await h.ingest.record( - buildHandedCvoEvent({ - threadId: 'thread-1', - fromCatId: 'codex-sol', - messageId: successor.id, - intent: 'handoff', - at: 1_500, - }), - ); - throwForSuccessor(h.messageStore, successor.id, 'operator successor metadata unavailable'); - - const result = await h.service.complete(auth(h), 'completed'); - assert.equal(result.outcome, 'applied'); - assert.equal(result.retired, true); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'parked'); - }); -}); diff --git a/packages/api/test/a2a-invariant-holders.test.js b/packages/api/test/a2a-invariant-holders.test.js deleted file mode 100644 index f02e5744f7..0000000000 --- a/packages/api/test/a2a-invariant-holders.test.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * F086/F216 — INVARIANT HOLDER tests (replaces the source-text tripwire). - * - * 砚砚 R5 correctly killed `a2a-routing-projection-boundary.test.js`: it scanned one file and - *判 provenance by whether a variable name contained "admitted", so naming the requested list - * `admittedRequested` passed it. A guard that a rename defeats is worse than none — my own words, - * applied to my own guard. - * - * These are behaviour tests over the two invariants, parameterised by ENTRY POINT, because every - * round of this PR was lost to an entry point nobody had enumerated: - * INV-1 the projection's claimed schedule == the schedule that was actually admitted - * INV-2 every admitted target converges, and every group settles without waiting for a timeout - * - * ADDING AN ENTRY POINT MEANS ADDING A ROW HERE. That is the enforcement — not a regex. - */ - -import './helpers/setup-cat-registry.js'; -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { catRegistry } from '@cat-cafe/shared'; - -const REPO_TEMPLATE_PATH = fileURLToPath(new URL('../../../cat-template.json', import.meta.url)); - -async function loadRealRoster() { - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - const runtimeConfigs = toAllCatConfigs(loadCatConfig(REPO_TEMPLATE_PATH)); - catRegistry.reset(); - for (const [id, config] of Object.entries(runtimeConfigs)) catRegistry.register(id, config); -} - -function createService(catId, text) { - return { - async *invoke() { - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} - -function createMockDeps(services) { - let counter = 0; - return { - services, - invocationDeps: { - registry: { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { getOrCreate: async () => ({}), resolveWorkingDirectory: () => '/tmp/test' }, - threadStore: null, - apiUrl: 'http://127.0.0.1:3004', - }, - messageStore: { - append: async () => ({ id: `msg-${counter}`, userId: '', catId: null, content: '', mentions: [], timestamp: 0 }), - getById: (id) => ({ - id, - threadId: '', - userId: '', - catId: 'opus', - content: 'trigger body', - mentions: [], - timestamp: 0, - }), - getRecent: () => [], - getMentionsFor: () => [], - getBefore: () => [], - getByThread: () => [], - getByThreadAfter: () => [], - getByThreadBefore: () => [], - }, - }; -} - -describe('INV-1: the projection never claims a schedule that was not admitted', { concurrency: false }, () => { - /** - * ENTRY POINT: callback `pushToWorklist` — the one that broke my "unreachable" verdict. - * It writes straight into the WorklistRegistry with NO push-time slot claim, so admission only - * happens later, inside the handoff loop. 砚砚 R5 reproduced exactly this: two targets pushed, - * the second loses its slot, one leg runs, pill still said "串行 1/2". - */ - test('callback-pushed targets: a leg that loses its slot never inflates index/total', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { pushToWorklist } = await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - - const threadId = 'thread-inv1-callback-push'; - const deps = createMockDeps({ - opus: { - async *invoke() { - // Tool-only turn: no text, so the worklist grows purely via the callback push below. - pushToWorklist(threadId, ['codex', 'gemini'], 'opus', undefined, 'trigger-msg-1'); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: createService('codex', '接了'), - gemini: createService('gemini', '接了'), - }); - - const events = []; - for await (const msg of routeSerial(deps, ['opus'], 'callback push', 'user1', threadId, { - invocationController: new AbortController(), - // gemini's slot is held elsewhere → durably deferred, pruned from the worklist. - trackA2ASlot: (_t, catId) => catId !== 'gemini', - completeA2ASlots: () => {}, - deferA2AEnqueue: () => ({ outcome: 'enqueued', entry: { id: 'deferred-1' } }), - thinkingMode: 'play', - })) { - events.push(msg); - } - - const handoffs = events.filter((e) => e.type === 'a2a_handoff'); - const announced = handoffs.map((h) => h.targetCatId); - assert.ok(!announced.includes('gemini'), 'a deferred leg must not be announced as running'); - for (const h of handoffs) { - assert.strictEqual( - h.routing.total, - announced.length, - `announced group size must equal the admitted set — got total=${h.routing.total} ` + - `while only [${announced.join(',')}] were admitted (砚砚 R5: "串行 1/2" with one real leg)`, - ); - assert.ok(h.routing.index <= h.routing.total, 'index must fall inside the announced group'); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); -}); diff --git a/packages/api/test/a2a-late-push-probe.test.js b/packages/api/test/a2a-late-push-probe.test.js deleted file mode 100644 index 67c3f92077..0000000000 --- a/packages/api/test/a2a-late-push-probe.test.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * F086/F216 regression guards for callback pushes that arrive while routeSerial is suspended - * at a user-visible yield. Both windows must preserve admission-before-start/projection. - */ - -import './helpers/setup-cat-registry.js'; -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { catRegistry } from '@cat-cafe/shared'; - -const REPO_TEMPLATE_PATH = fileURLToPath(new URL('../../../cat-template.json', import.meta.url)); - -describe('A2A admission across re-entrant callback windows', { concurrency: false }, () => { - test('a target pushed after prune must not be announced without a claim', async () => { - const original = catRegistry.getAllConfigs(); - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - const runtimeConfigs = toAllCatConfigs(loadCatConfig(REPO_TEMPLATE_PATH)); - catRegistry.reset(); - for (const [id, config] of Object.entries(runtimeConfigs)) catRegistry.register(id, config); - - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { pushToWorklist } = await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - - const threadId = 'thread-late-push-probe'; - const claims = []; - const starts = []; - let counter = 0; - const mkService = (catId) => ({ - async *invoke() { - starts.push(catId); - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }); - const deps = { - services: { - opus: { - async *invoke() { - starts.push('opus'); - pushToWorklist(threadId, ['codex'], 'opus', undefined, 'trigger-1'); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: mkService('codex'), - gemini: mkService('gemini'), - }, - invocationDeps: { - registry: { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { - get: async () => null, - getOrCreate: async () => ({}), - resolveWorkingDirectory: () => '/tmp/test', - }, - threadStore: null, - apiUrl: 'http://127.0.0.1:3004', - }, - messageStore: { - append: async () => ({ - id: `m-${counter}`, - userId: '', - catId: null, - content: '', - mentions: [], - timestamp: 0, - }), - getById: (id) => ({ id, threadId, userId: '', catId: 'opus', content: 'body', mentions: [], timestamp: 0 }), - getRecent: () => [], - getMentionsFor: () => [], - getBefore: () => [], - getByThread: () => [], - getByThreadAfter: () => [], - getByThreadBefore: () => [], - }, - }; - - let latePushed = false; - const events = []; - for await (const msg of routeSerial(deps, ['opus'], 'late push', 'user1', threadId, { - invocationController: new AbortController(), - trackA2ASlot: (_t, catId) => { - claims.push(catId); - return true; - }, - completeA2ASlots: () => {}, - deferA2AEnqueue: () => ({ outcome: 'enqueued', entry: { id: 'deferred' } }), - thinkingMode: 'play', - })) { - events.push(msg); - // Inject the late push at the first handoff yield — the reentrancy window. - if (!latePushed && msg.type === 'a2a_handoff') { - latePushed = true; - pushToWorklist(threadId, ['gemini'], 'opus', undefined, 'trigger-2'); - } - } - - const announced = events.filter((e) => e.type === 'a2a_handoff').map((e) => e.targetCatId); - for (const cat of announced) { - assert.ok( - claims.includes(cat), - `announced/started without a slot claim: ${cat}. claims=[${claims.join(',')}] ` + - `announced=[${announced.join(',')}] starts=[${starts.join(',')}]`, - ); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); - - test('a target pushed after the final admission drain cannot start before slot admission', async () => { - const original = catRegistry.getAllConfigs(); - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - const runtimeConfigs = toAllCatConfigs(loadCatConfig(REPO_TEMPLATE_PATH)); - catRegistry.reset(); - for (const [id, config] of Object.entries(runtimeConfigs)) catRegistry.register(id, config); - - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { pushToWorklist } = await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - - const threadId = 'thread-terminal-admission-window'; - const timeline = []; - let counter = 0; - const mkService = (catId) => ({ - async *invoke() { - timeline.push(`start:${catId}`); - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }); - const deps = { - services: { - opus: mkService('opus'), - gemini: mkService('gemini'), - }, - invocationDeps: { - registry: { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { - get: async () => null, - getOrCreate: async () => ({}), - resolveWorkingDirectory: () => '/tmp/test', - }, - threadStore: null, - apiUrl: 'http://127.0.0.1:3004', - }, - messageStore: { - append: async () => ({ - id: `m-${counter}`, - userId: '', - catId: null, - content: '', - mentions: [], - timestamp: 0, - }), - getById: (id) => ({ id, threadId, userId: '', catId: 'opus', content: 'body', mentions: [], timestamp: 0 }), - getRecent: () => [], - getMentionsFor: () => [], - getBefore: () => [], - getByThread: () => [], - getByThreadAfter: () => [], - getByThreadBefore: () => [], - }, - }; - - let injected = false; - const events = []; - for await (const msg of routeSerial(deps, ['opus'], 'terminal window', 'user1', threadId, { - invocationController: new AbortController(), - trackA2ASlot: (_t, catId) => { - timeline.push(`claim:${catId}`); - return false; - }, - completeA2ASlots: () => {}, - deferA2AEnqueue: () => ({ outcome: 'enqueued', entry: { id: 'deferred' } }), - thinkingMode: 'play', - })) { - events.push(msg); - if (!injected && msg.type === 'done' && msg.catId === 'opus') { - injected = true; - pushToWorklist(threadId, ['gemini'], 'opus', undefined, 'trigger-terminal'); - } - } - - assert.equal(injected, true, 'precondition: push must land while the final done is yielded'); - assert.equal( - timeline.includes('start:gemini'), - false, - `a target whose slot claim is rejected must not start; timeline=${timeline.join(' -> ')}`, - ); - assert.equal( - events.some((event) => event.type === 'a2a_handoff' && event.targetCatId === 'gemini'), - false, - 'a rejected terminal-window target must not be projected as an admitted handoff', - ); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); -}); diff --git a/packages/api/test/a2a-parallel-fanout-custody.test.js b/packages/api/test/a2a-parallel-fanout-custody.test.js index bab6746876..f9bfdee463 100644 --- a/packages/api/test/a2a-parallel-fanout-custody.test.js +++ b/packages/api/test/a2a-parallel-fanout-custody.test.js @@ -1,13 +1,10 @@ /** - * F086/F216: the structured PARALLEL path must be visibly and behaviourally distinct from the - * serial worklist. + * Phase C: multi_mention is an ordinary lifecycle fan-out. * - * Requirement 2 of the field report: for an explicit parallel dispatch, every independent target - * must already hold its own invocation/Queue custody BEFORE any target reaches a terminal, and a - * partial failure must not block its siblings. - * Requirement 5: the user-visible projection must distinguish parallel fan-out from serial 第 N/M 棒 - * — before this change the parallel path emitted no routing pill at all, so the ONLY multi-target - * projection users ever saw was the sequential one. + * Every target shares one exact public Agent source and one Queue carrier before + * drain. The caller's response is only the parent lineage; the source body is the + * exact multi_mention work that every target receives. The retired + * “A ⇉ B(并行 N/M)” system-message path must stay absent. */ import assert from 'node:assert/strict'; @@ -15,119 +12,133 @@ import { afterEach, beforeEach, describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; import Fastify from 'fastify'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { registerCallbackAuthHook } from '../dist/routes/callback-auth-prehandler.js'; import { resetMultiMentionOrchestrator } from '../dist/routes/callback-multi-mention-routes.js'; +import { canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; function createMockRegistry() { const records = new Map(); return { register(catId, threadId, userId) { - const id = `inv-${records.size}`; - const token = `tok-${records.size}`; - records.set(id, { + const invocationId = `inv-${records.size}`; + const callbackToken = `tok-${records.size}`; + records.set(invocationId, { catId, threadId, userId, - invocationId: id, - callbackToken: token, + invocationId, + callbackToken, ownerAuthProvenance: 'strict', }); - return { invocationId: id, callbackToken: token }; + return { invocationId, callbackToken }; }, async verify(invocationId, callbackToken) { - const r = records.get(invocationId); - if (!r) return { ok: false, reason: 'unknown_invocation' }; - if (r.callbackToken !== callbackToken) return { ok: false, reason: 'invalid_token' }; - return { ok: true, record: r }; + const record = records.get(invocationId); + if (!record) return { ok: false, reason: 'unknown_invocation' }; + if (record.callbackToken !== callbackToken) return { ok: false, reason: 'invalid_token' }; + return { ok: true, record }; }, isLatest: () => true, claimClientMessageId: () => true, }; } -function createMockSocketManager() { - const messages = []; - return { - broadcastAgentMessage(msg, threadId) { - messages.push({ ...msg, threadId }); +function appendLifecycleSource(messageStore, creds) { + return messageStore.append({ + from: { kind: 'agent', catId: 'opus' }, + userId: 'user-1', + content: 'calling multi_mention', + mentions: [], + origin: 'stream', + timestamp: 100, + threadId: 'thread-par-1', + idempotencyKey: `message-lifecycle-response:${creds.invocationId}`, + lifecycle: { + kind: 'response', + orderKey: `100:${creds.invocationId}`, + invocationId: creds.invocationId, + targetId: 'opus', + inputEntryIds: [], + inputMessageIds: [], + status: 'processing', + startedAt: 100, }, - broadcastToRoom() {}, - getMessages: () => messages, - }; -} - -function createMockMessageStore() { - const messages = []; - return { - append(msg) { - const stored = { id: `msg-${messages.length}`, ...msg }; - messages.push(stored); - return stored; - }, - getById: (id) => messages.find((m) => m.id === id) ?? null, - getMessages: () => messages, - }; + }); } function createMockQueueProcessor() { const hooks = new Map(); - const autoExecuteCalls = []; - /** Ordered log so "custody happened before any terminal" is observed, not assumed. */ + const drains = []; const timeline = []; return { - timeline, - registerEntryCompleteHook(entryId, hook) { - hooks.set(entryId, hook); - timeline.push(`custody:${entryId}`); + registerEntryCompleteHook(entryId, hook, targetCatId) { + const key = `${entryId}:${targetCatId ?? '*'}`; + hooks.set(key, { entryId, targetCatId, hook }); + timeline.push(`custody:${key}`); }, unregisterEntryCompleteHook(entryId) { - hooks.delete(entryId); + for (const [key, registration] of hooks) { + if (registration.entryId === entryId) hooks.delete(key); + } }, - tryAutoExecute(threadId) { - autoExecuteCalls.push(threadId); + requestDrain(threadId) { + drains.push(threadId); + timeline.push(`drain:${threadId}`); return Promise.resolve(); }, + releaseSlot() {}, getHooks: () => hooks, - getAutoExecuteCalls: () => autoExecuteCalls, - simulateComplete(entryId, status, responseText) { - timeline.push(`terminal:${entryId}`); - const hook = hooks.get(entryId); - if (hook) { - hook(entryId, status, responseText); - hooks.delete(entryId); - } + getDrains: () => drains, + timeline, + simulateComplete(entryId, targetCatId, status, responseText) { + const key = `${entryId}:${targetCatId}`; + timeline.push(`terminal:${key}`); + const registration = hooks.get(key); + if (registration) registration.hook(entryId, status, responseText); + hooks.delete(key); }, }; } -describe('F086/F216: explicit parallel fan-out custody + projection', () => { +describe('Phase C multi_mention lifecycle fan-out', () => { let app; - let mockRegistry, mockSocket, mockMessageStore, invocationQueue, mockQueueProcessor; + let registry; let creds; + let callerResponse; + let messageStore; + let invocationQueue; + let queueProcessor; + let socketMessages; beforeEach(async () => { resetMultiMentionOrchestrator(); - mockRegistry = createMockRegistry(); - mockSocket = createMockSocketManager(); - mockMessageStore = createMockMessageStore(); + registry = createMockRegistry(); + creds = registry.register('opus', 'thread-par-1', 'user-1'); + messageStore = new MessageStore(); + callerResponse = appendLifecycleSource(messageStore, creds); invocationQueue = new InvocationQueue(); - mockQueueProcessor = createMockQueueProcessor(); - creds = mockRegistry.register('opus', 'thread-par-1', 'user-1'); + queueProcessor = createMockQueueProcessor(); + socketMessages = []; app = Fastify({ logger: false }); - registerCallbackAuthHook(app, mockRegistry); + registerCallbackAuthHook(app, registry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(app, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: { - async *routeExecution() { - /* queue path must be used */ + registry, + messageStore, + socketManager: { + broadcastAgentMessage(message, threadId) { + socketMessages.push({ ...message, threadId }); }, + broadcastToRoom() {}, + emitToUser() {}, }, + router: { async *routeExecution() {} }, invocationRecordStore: { create: () => ({ outcome: 'created', invocationId: 'inv-mm' }), update() {} }, invocationTracker: { + has: () => false, + cancelInvocation: () => [], start: () => new AbortController(), startAll: () => new AbortController(), tryStartThreadAll: () => new AbortController(), @@ -135,7 +146,7 @@ describe('F086/F216: explicit parallel fan-out custody + projection', () => { completeAll() {}, }, invocationQueue, - queueProcessor: mockQueueProcessor, + queueProcessor, }); await app.ready(); }); @@ -144,535 +155,125 @@ describe('F086/F216: explicit parallel fan-out custody + projection', () => { await app.close(); }); - /** - * The projection is fire-and-forget by contract (it must never sit in front of start), so any - * assertion ABOUT the pills has to let those microtasks drain first. Assertions about custody - * and start deliberately do NOT flush — that is the whole point. - */ - async function flushProjection() { - for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); - } - - async function dispatchTwoTargets() { + function dispatch(targets = ['codex', 'gemini']) { return app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, - payload: { targets: ['codex', 'gemini'], question: '独立看一眼', callbackTo: 'opus' }, + payload: { targets, question: '独立看一眼', callbackTo: 'opus' }, }); } - test('every target holds Queue custody before ANY target reaches a terminal', async () => { - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); + test('binds every target to one source and establishes hooks before drain', async () => { + const response = await dispatch(); + assert.equal(response.statusCode, 200, response.body); const entries = invocationQueue.list('thread-par-1', 'user-1'); - const targets = entries.flatMap((e) => e.targetCats); - assert.deepEqual([...targets].sort(), ['codex', 'gemini'], 'both targets must have their own Queue entry'); - - // No terminal has been produced yet, and both custody registrations already happened. - assert.equal( - mockQueueProcessor.timeline.filter((t) => t.startsWith('terminal:')).length, - 0, - 'no target may terminate before the fan-out finished admitting siblings', - ); - assert.equal(mockQueueProcessor.getHooks().size, 2, 'both targets must be tracked independently'); - - const firstTerminalIdx = mockQueueProcessor.timeline.findIndex((t) => t.startsWith('terminal:')); - const custodyCount = mockQueueProcessor.timeline.filter((t) => t.startsWith('custody:')).length; - assert.equal(custodyCount, 2); - assert.equal(firstTerminalIdx, -1); - }); - - test('one target failing does not disturb its sibling', async () => { - await dispatchTwoTargets(); - const entryIds = [...mockQueueProcessor.getHooks().keys()]; - assert.equal(entryIds.length, 2); - - mockQueueProcessor.simulateComplete(entryIds[0], 'failed', undefined); - - assert.ok(mockQueueProcessor.getHooks().has(entryIds[1]), 'sibling custody must survive a partial failure'); - const survivor = invocationQueue - .list('thread-par-1', 'user-1') - .find((e) => e.id === entryIds[1] || e.targetCats.includes('gemini')); - assert.ok(survivor, 'sibling Queue entry must not be evicted by the failed target'); - }); - - // ── 砚砚 R1 P1: projection must never run ahead of, or gate, real custody ────────────── - test('depth-limited targets are NOT projected (projection follows admission, not the request)', async () => { - // Fill the agent-entry depth budget so the fan-out cannot be admitted. - for (let i = 0; i < 10; i++) { - invocationQueue.enqueue({ - threadId: 'thread-par-1', - userId: 'user-1', - ownerAuthProvenance: 'strict', - content: `filler-${i}`, - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0].targets.toSorted(), ['codex', 'gemini']); + const sourceId = entries[0].payload.messageId; + for (const entry of entries) { + assert.equal(entry.kind, 'message_wake'); + assert.equal(entry.payload.messageId, sourceId); + assert.equal(entry.execution.a2aTriggerMessageId, sourceId); } - - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - - const admitted = invocationQueue - .list('thread-par-1', 'user-1') - .flatMap((e) => e.targetCats) - .filter((c) => c === 'codex' || c === 'gemini'); - await flushProjection(); - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - - assert.equal(admitted.length, 0, 'precondition: depth limit blocked the fan-out'); - assert.equal( - pills.length, - 0, - 'announcing a fan-out that obtained no custody is exactly the "projection ahead of fact" defect', - ); + assert.equal(queueProcessor.getHooks().size, 2); + assert.equal(queueProcessor.getDrains().length, 1); + const firstDrain = queueProcessor.timeline.findIndex((item) => item.startsWith('drain:')); + assert.equal(queueProcessor.timeline.slice(0, firstDrain).filter((item) => item.startsWith('custody:')).length, 2); + + const persistedSource = await messageStore.getById(sourceId); + assert.equal(persistedSource.id, sourceId); + assert.equal(persistedSource.content, '[Multi-Mention from opus]\n\n独立看一眼'); + assert.equal(persistedSource.replyTo, callerResponse.id); + assert.equal(persistedSource.queueCustody, undefined); }); - test('a throwing projection writer does not fail an admitted dispatch', async () => { - let appendCalls = 0; - mockMessageStore.append = () => { - appendCalls++; - throw new Error('projection store is down'); - }; - - const res = await dispatchTwoTargets(); - await flushProjection(); + test('one terminal failure does not disturb its sibling carrier', async () => { + const response = await dispatch(); + assert.equal(response.statusCode, 200, response.body); + const [entry] = invocationQueue.list('thread-par-1', 'user-1'); + queueProcessor.simulateComplete(entry.id, 'codex', 'failed', 'dispatch failed'); - assert.equal(res.statusCode, 200, 'a broken projection must not fail an admitted dispatch'); - assert.ok(appendCalls > 0, 'precondition: the projection writer really was exercised'); - const targets = invocationQueue - .list('thread-par-1', 'user-1') - .flatMap((e) => e.targetCats) - .sort(); - assert.deepEqual(targets, ['codex', 'gemini'], 'custody must survive a projection failure'); - assert.equal(mockQueueProcessor.getHooks().size, 2); - assert.ok(mockQueueProcessor.getAutoExecuteCalls().length > 0, 'start must still happen'); + assert.ok(queueProcessor.getHooks().has(`${entry.id}:gemini`)); + assert.deepEqual(invocationQueue.list('thread-par-1', 'user-1')[0].targets.toSorted(), ['codex', 'gemini']); }); - test('a NEVER-SETTLING projection writer still lets every admitted sibling start', async () => { - // 砚砚 R2 P1: the previous test only proved a synchronous throw is survivable. A slow / - // never-resolving store is the real hazard — with the projection awaited in front of - // `tryAutoExecute`, every admitted sibling would wait forever for a UI pill. - let appendCalls = 0; - mockMessageStore.append = () => { - appendCalls++; - return new Promise(() => { - /* never settles */ - }); - }; - - const timedOut = Symbol('timeout'); - const res = await Promise.race([dispatchTwoTargets(), new Promise((r) => setTimeout(() => r(timedOut), 2000))]); - - assert.notStrictEqual( - res, - timedOut, - 'the dispatch hung waiting on a projection write — projection is gating start', - ); - assert.equal(res.statusCode, 200); - assert.ok(appendCalls > 0, 'precondition: the projection writer really was entered'); - assert.ok( - mockQueueProcessor.getAutoExecuteCalls().length > 0, - 'start must have happened even though the projection never settled', - ); - assert.equal(mockQueueProcessor.getHooks().size, 2, 'both siblings keep custody'); - }); - - test('a deferred first write does not delay, reorder, or starve the sibling pill', async () => { - // 砚砚 R3 P1, with an explicit settle latch instead of a fixed number of ticks. - // Probe that produced the finding: build leg N's timestamp only after leg N-1 persisted, and a - // slow first write pushes pill2 past the first stream message — the UI then shows the fan-out - // announcement AFTER the output it announces. - let releaseFirst; - const firstWriteEntered = new Promise((resolveEntered) => { - let entered = false; - const realAppend = mockMessageStore.append.bind(mockMessageStore); - mockMessageStore.append = (msg) => { - if (msg.extra?.systemKind !== 'a2a_routing') return realAppend(msg); - if (!entered) { - entered = true; - resolveEntered(); - return new Promise((resolveWrite) => { - releaseFirst = () => resolveWrite(realAppend(msg)); - }); - } - return realAppend(msg); - }; - }); - - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - await firstWriteEntered; - - // The first sibling's write is still in flight. Stamp "the moment the target starts talking". - const firstStreamTs = Date.now(); - - // The SECOND pill must not be waiting behind the first sibling's write. - await flushProjection(); - const blockedPills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.equal( - blockedPills.length, - 1, - 'the sibling whose write already finished must be broadcast while the other write is pending', - ); - assert.equal(blockedPills[0].targetCatId, 'gemini'); - - releaseFirst(); - await flushProjection(); - - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.equal(pills.length, 2, 'the deferred write must still produce its pill once it settles'); - for (const pill of pills) { - assert.ok( - pill.timestamp <= firstStreamTs, - `every fan-out pill must be stamped before the announced output starts — ` + - `pill(${pill.targetCatId})=${pill.timestamp} vs stream=${firstStreamTs}`, + test('source-row capacity never partially rejects sibling targets', async () => { + for (let index = 0; index < 9; index += 1) { + invocationQueue.enqueueDurableNow( + canonicalTestQueueInput({ + sourceId: `partial-filler-${index}`, + kind: 'conversation_input', + threadId: 'thread-par-1', + userId: 'user-1', + ownerAuthProvenance: 'strict', + content: `filler-${index}`, + from: { kind: 'agent', catId: 'opus' }, + targetCats: ['opus'], + intent: 'execute', + autoExecute: true, + }), ); } - assert.equal(pills[0].timestamp, pills[1].timestamp, 'a true fan-out starts together — one batch snapshot'); - }); - - // ── SAME-CLASS SWEEP (operator-directed): R4 caught the LEGACY path leaving a failed admission - // unconverged, so the group hung at `partial` until the timeout. The QUEUE path has the same - // shape and was never probed: `skipped` / `depth_limited` targets only feed the action-lease - // reconciliation, never `orch.recordResponse`. - test('a queue target rejected at admission still lets the group terminate', async () => { - // Pre-occupy gemini's agent slot so its enqueue is skipped as a duplicate. - invocationQueue.enqueue({ - threadId: 'thread-par-1', - userId: 'user-1', - ownerAuthProvenance: 'strict', - content: 'gemini is already busy', - source: 'agent', - targetCats: ['gemini'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - await flushProjection(); - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.deepEqual( - pills.map((p) => p.targetCatId), - ['codex'], - 'precondition: only codex was admitted', - ); - - // codex answers; gemini never will, because it never got custody. - const codexEntry = [...mockQueueProcessor.getHooks().keys()][0]; - mockQueueProcessor.simulateComplete(codexEntry, 'succeeded', 'codex reply'); - await flushProjection(); - - const aggregated = mockMessageStore.getMessages().find((m) => m.content?.includes('Multi-Mention 结果汇总')); - assert.ok( - aggregated, - 'the group must reach done/flush on its own — a target rejected at admission can never ' + - 'produce a response, so leaving it pending strands the initiator on the timeout timer', - ); - assert.match(aggregated.content, /codex reply/); - }); - - test('partial admission: only the target that really got custody is projected', async () => { - // 9 fillers → first target takes the 10th slot, second hits MAX_MM_DEPTH. - for (let i = 0; i < 9; i++) { - invocationQueue.enqueue({ - threadId: 'thread-par-1', - userId: 'user-1', - ownerAuthProvenance: 'strict', - content: `filler-${i}`, - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - } - - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - await flushProjection(); - - const admitted = invocationQueue + const response = await dispatch(); + assert.equal(response.statusCode, 200, response.body); + assert.equal(queueProcessor.getHooks().size, 2); + const [sourceEntry] = invocationQueue .list('thread-par-1', 'user-1') - .flatMap((e) => e.targetCats) - .filter((c) => c === 'codex' || c === 'gemini'); - assert.deepEqual(admitted, ['codex'], 'precondition: exactly one target cleared the depth budget'); - - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.equal(pills.length, 1, 'the depth-limited sibling must not be announced'); - assert.equal(pills[0].targetCatId, 'codex'); - assert.deepEqual( - { mode: pills[0].routing.mode, index: pills[0].routing.index, total: pills[0].routing.total }, - { mode: 'parallel', index: 1, total: 1 }, - 'index/total must describe the ADMITTED group, not the requested one', - ); - // A fan-out that admitted exactly one target IS a single dispatch, so the label keeps the - // plain single-target shape — adding "(并行 1/1)" would invent a group that does not exist. - // The structured field still carries the mode for machine consumers. - assert.equal(pills[0].content, '布偶猫(opus) → 缅因猫(codex)'); - assert.doesNotMatch(pills[0].content, /串行|排队中/, 'a lone admitted target must not read as a queued serial leg'); - }); - - test('custody is established before the fan-out is projected', async () => { - const order = []; - const realAppend = mockMessageStore.append.bind(mockMessageStore); - mockMessageStore.append = (msg) => { - if (msg.extra?.systemKind === 'a2a_routing') order.push('projection'); - return realAppend(msg); - }; - const realRegister = mockQueueProcessor.registerEntryCompleteHook.bind(mockQueueProcessor); - mockQueueProcessor.registerEntryCompleteHook = (entryId, hook) => { - order.push('custody'); - return realRegister(entryId, hook); - }; - - await dispatchTwoTargets(); - await flushProjection(); - - assert.deepEqual( - order, - ['custody', 'custody', 'projection', 'projection'], - `every target must hold custody before anything is announced — got ${order.join(',')}`, - ); - }); - - test('parallel fan-out projects a PARALLEL pill, distinct from a serial leg', async () => { - await dispatchTwoTargets(); - await flushProjection(); - - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.equal(pills.length, 2, 'a real fan-out must be announced, not left invisible'); - - for (const pill of pills) { - assert.ok(pill.routing, 'pill must carry the structured mode'); - assert.equal(pill.routing.mode, 'parallel'); - assert.equal(pill.routing.total, 2); - assert.match(pill.content, /并行 \d\/2/); - assert.doesNotMatch(pill.content, /串行/, 'a fan-out must never render as a serial leg'); - } + .filter((entry) => entry.targets.includes('codex') && entry.targets.includes('gemini')); + const persistedSource = await messageStore.getById(sourceEntry.payload.messageId); assert.deepEqual( - pills.map((p) => p.targetCatId).sort(), + invocationQueue + .list('thread-par-1', 'user-1') + .filter((entry) => entry.payload.messageId === persistedSource.id) + .flatMap((entry) => entry.targets) + .toSorted(), ['codex', 'gemini'], - 'every fanned-out target gets its own projection', ); - - // History must project the same truth as the live stream (reload parity). - const persisted = mockMessageStore.getMessages().filter((m) => m.extra?.systemKind === 'a2a_routing'); - assert.equal(persisted.length, 2); - for (const m of persisted) { - assert.equal(m.extra.a2aRouting.routing.mode, 'parallel'); - } - }); -}); - -/** - * 砚砚 R2 P1: the legacy direct-dispatch fallback (no InvocationQueue) has NO observable admission - * point — `dispatchToTarget` is fire-and-forget and still returns without custody when the tracker - * slot is already aborted or the invocation record is a duplicate. So `admitted === requested` is - * false there, and the honest terminal state is to project nothing rather than to draw a pill for a - * target that may never get an invocation. Absence is not a lie; a false arrow is (#1291). - */ -describe('F086/F216: legacy fan-out is two-phase (admit all → project admitted → start)', () => { - let app; - let mockRegistry, mockSocket, mockMessageStore, mockRouter, creds; - let createOutcome, createInvocation, startedTargets, recordUpdates, releasedSlots, updateImpl; - - beforeEach(async () => { - resetMultiMentionOrchestrator(); - mockRegistry = createMockRegistry(); - mockSocket = createMockSocketManager(); - mockMessageStore = createMockMessageStore(); - const executions = []; - startedTargets = { onStart: undefined }; - mockRouter = { - // biome-ignore lint/correctness/useYield: legacy path only needs an async iterable - async *routeExecution(_userId, _message, _threadId, _invId, targetCats) { - startedTargets.onStart?.(targetCats[0]); - executions.push({ targetCats }); - }, - getExecutions: () => executions, - }; - createOutcome = 'created'; - recordUpdates = []; - releasedSlots = []; - updateImpl = (id, patch) => recordUpdates.push({ id, ...patch }); - let seq = 0; - createInvocation = (_input) => ({ outcome: createOutcome, invocationId: `inv-legacy-${seq++}` }); - creds = mockRegistry.register('opus', 'thread-legacy-1', 'user-1'); - - app = Fastify({ logger: false }); - registerCallbackAuthHook(app, mockRegistry); - const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - registerMultiMentionRoutes(app, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: mockRouter, - invocationRecordStore: { - create: (input) => createInvocation(input), - update: (id, patch) => updateImpl(id, patch), - }, - invocationTracker: { - start: () => new AbortController(), - startAll: () => new AbortController(), - tryStartThreadAll: () => new AbortController(), - complete(_threadId, catId) { - releasedSlots.push(catId); - }, - completeAll() {}, - }, - // NO invocationQueue / queueProcessor → legacy fallback - }); - await app.ready(); + assert.equal(persistedSource.content, '[Multi-Mention from opus]\n\n独立看一眼'); + assert.equal(persistedSource.replyTo, callerResponse.id); + assert.equal(persistedSource.queueCustody, undefined); }); - afterEach(async () => { - await app.close(); - }); - - async function dispatchTwoTargets() { - const res = await app.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, - payload: { targets: ['codex', 'gemini'], question: '独立看一眼', callbackTo: 'opus' }, - }); - for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); - return res; - } - - test('every legacy sibling is admitted before ANY of them starts', async () => { - // The discriminating shape: make the SECOND target's admission slow. With admission - // interleaved inside each fire-and-forget dispatch (the old code), the fast target starts - // while its sibling still has no invocation — requirement 2 violated. A sync mock would let - // both admissions finish first by luck and make this test pass either way (a false green), - // so the delay is what gives it discriminating power. - const order = []; - const realCreate = createInvocation; - createInvocation = async (input) => { - const catId = input.targetCats[0]; - if (catId === 'gemini') { - for (let i = 0; i < 3; i++) await new Promise((r) => setImmediate(r)); - } - order.push(`admit:${catId}`); - return realCreate(input); - }; - startedTargets.onStart = (catId) => order.push(`start:${catId}`); - - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); - - const admits = order.filter((o) => o.startsWith('admit:')); - assert.equal(admits.length, 2, 'both targets must be admitted'); - const firstStart = order.findIndex((o) => o.startsWith('start:')); - assert.notEqual(firstStart, -1, 'precondition: at least one target actually started'); + test('does not emit or persist the retired a2a_routing system row', async () => { + const response = await dispatch(); + assert.equal(response.statusCode, 200, response.body); assert.equal( - order.slice(0, firstStart).filter((o) => o.startsWith('admit:')).length, - 2, - `all siblings must hold custody before the first start — got ${order.join(',')}`, + socketMessages.some((message) => message.type === 'a2a_handoff'), + false, ); - }); - - test('a target that drops out during admission is never announced', async () => { - createOutcome = 'duplicate'; - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.equal(pills.length, 0, 'duplicate targets never get an invocation — they must not be drawn'); - const persisted = mockMessageStore.getMessages().filter((m) => m.extra?.systemKind === 'a2a_routing'); - assert.equal(persisted.length, 0, 'and nothing phantom may reach durable history either'); - assert.equal(mockRouter.getExecutions().length, 0, 'nor may an unadmitted target be started'); - }); - - test('admission failing AFTER record create converges the record and settles the group', async () => { - // 砚砚 R4 P1, reproduced from his fault injection: make codex's `queued → running` update throw. - // Extracting admission out of `dispatchToTarget` moved the happy path but left that function's - // catch behind, so the created record was orphaned at `queued`, no failure response was - // registered, and the group sat at `partial` until the timeout. - const realUpdate = (id, patch) => recordUpdates.push({ id, ...patch }); - let failedInvocationId; - const originalCreate = createInvocation; - createInvocation = (input) => { - const result = originalCreate(input); - if (input.targetCats[0] === 'codex') failedInvocationId = result.invocationId; - return result; - }; - updateImpl = (id, patch) => { - if (id === failedInvocationId && patch.status === 'running') throw new Error('running update failed'); - return realUpdate(id, patch); - }; - - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r)); - - // 1. the orphaned record is converged, not left at `queued` - const converged = recordUpdates.find((u) => u.id === failedInvocationId && u.status !== 'running'); - assert.ok(converged, `record ${failedInvocationId} must be converged, got ${JSON.stringify(recordUpdates)}`); - assert.ok(['failed', 'canceled'].includes(converged.status)); - - // 2. the tracker slot is released - assert.ok(releasedSlots.includes('codex'), 'the failed target must release its tracker slot'); - - // 3. the sibling is untouched and really runs - assert.deepEqual( - mockRouter.getExecutions().map((e) => e.targetCats[0]), - ['gemini'], - 'a failed admission must not disturb its sibling', - ); - - // 4. the failure is registered so the group can terminate on its own - const aggregated = mockMessageStore.getMessages().find((m) => m.content?.includes('Multi-Mention 结果汇总')); - assert.ok(aggregated, 'the group must reach done/flush without waiting for the timeout timer'); - assert.match( - aggregated.content, - /admission error|失败/, - 'the failed target must be reported, not silently dropped', - ); - - // 5. only the admitted sibling is projected - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.deepEqual( - pills.map((p) => p.targetCatId), - ['gemini'], + assert.equal( + messageStore + .getByThreadIncludingQueued('thread-par-1', 100, 'user-1') + .some((message) => message.extra?.systemKind === 'a2a_routing'), + false, ); }); - test('admitted legacy siblings still get a PARALLEL projection', async () => { - const res = await dispatchTwoTargets(); - assert.equal(res.statusCode, 200); - - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.equal(pills.length, 2, 'requirement 5 applies to the legacy path too once admission is observable'); - for (const pill of pills) { - assert.equal(pill.routing.mode, 'parallel'); - assert.equal(pill.routing.total, 2); - } - }); - - test('single-target legacy dispatch still works (no fan-out contract to violate)', async () => { - const res = await app.inject({ + test('fails closed when the exact lifecycle response source is absent', async () => { + const missingApp = Fastify({ logger: false }); + registerCallbackAuthHook(missingApp, registry); + const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); + registerMultiMentionRoutes(missingApp, { + registry, + messageStore: new MessageStore(), + socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, + router: { async *routeExecution() {} }, + invocationRecordStore: { create: () => ({ outcome: 'created', invocationId: 'unused' }), update() {} }, + invocationQueue: new InvocationQueue(), + queueProcessor: createMockQueueProcessor(), + }); + await missingApp.ready(); + const response = await missingApp.inject({ method: 'POST', url: '/api/callbacks/multi-mention', headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, - payload: { targets: ['codex'], question: '单独看一眼', callbackTo: 'opus' }, + payload: { targets: ['codex'], question: 'no source', callbackTo: 'opus' }, }); - for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); - - assert.equal(res.statusCode, 200, 'single-target legacy dispatch must not be collateral damage'); - assert.equal(mockRouter.getExecutions().length, 1); - const pills = mockSocket.getMessages().filter((m) => m.type === 'a2a_handoff'); - assert.equal(pills.length, 1); - assert.equal(pills[0].routing.total, 1, 'a lone target is its own group'); + assert.equal(response.statusCode, 409); + assert.equal(response.json().status, 'lifecycle_source_unavailable'); + await missingApp.close(); }); }); diff --git a/packages/api/test/a2a-routing-mode-honesty.test.js b/packages/api/test/a2a-routing-mode-honesty.test.js deleted file mode 100644 index 010b969be8..0000000000 --- a/packages/api/test/a2a-routing-mode-honesty.test.js +++ /dev/null @@ -1,375 +0,0 @@ -/** - * F086/F216 (#1291 field incident): multi-target A2A dispatch intent must be EXPLICIT and HONEST. - * - * Field evidence: a cat wrote two line-start @mentions in one reply and stated "并行,不串行" in - * prose. route-serial pushed both onto ONE ordered worklist and only the first target ever got an - * invocation — but the projection layer emitted two identical "A → B" pills at the same - * millisecond, so the reader saw a fan-out that never happened (silent semantic downgrade). - * - * Contract locked here: - * 1. Scheduling mode is carried STRUCTURALLY on every handoff — never inferred from how many - * targets appeared or in what order. - * 2. Inline line-start @mentions are serial BY CONTRACT, and the runtime SAYS SO (requirement 4: - * legacy multi-@ may not keep silently impersonating parallel). No NLP over the message body. - * 3. The user-visible projection distinguishes "serial 第 N/M 棒" from "parallel fan-out". - * 4. Serial execution really is strictly ordered: target 2 starts only after target 1 terminates. - */ - -import './helpers/setup-cat-registry.js'; -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { catRegistry } from '@cat-cafe/shared'; - -const REPO_TEMPLATE_PATH = fileURLToPath(new URL('../../../cat-template.json', import.meta.url)); - -/** Records interleaving so "started before the sibling terminated" is observable, not assumed. */ -function createTimelineService(catId, text, timeline) { - const calls = []; - return { - calls, - async *invoke(prompt) { - calls.push(prompt); - timeline.push(`start:${catId}`); - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - timeline.push(`end:${catId}`); - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} - -function createMockDeps(services) { - let counter = 0; - return { - services, - invocationDeps: { - registry: { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { - getOrCreate: async () => ({}), - resolveWorkingDirectory: () => '/tmp/test', - }, - threadStore: null, - apiUrl: 'http://127.0.0.1:3004', - }, - messageStore: { - append: async () => ({ - id: `msg-${counter}`, - userId: '', - catId: null, - content: '', - mentions: [], - timestamp: 0, - }), - getById: () => null, - getRecent: () => [], - getMentionsFor: () => [], - getBefore: () => [], - getByThread: () => [], - getByThreadAfter: () => [], - getByThreadBefore: () => [], - }, - }; -} - -function withClaimedA2ASlot(options = {}) { - return { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - ...options, - }; -} - -async function loadRealRoster() { - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - const runtimeConfigs = toAllCatConfigs(loadCatConfig(REPO_TEMPLATE_PATH)); - catRegistry.reset(); - for (const [id, config] of Object.entries(runtimeConfigs)) { - catRegistry.register(id, config); - } -} - -async function runTwoTargetTurn(threadId) { - const timeline = []; - const opusService = createTimelineService('opus', 'ok\n\n@codex\n车道1\n\n@gemini\n车道2', timeline); - const codexService = createTimelineService('codex', '接了车道1', timeline); - const geminiService = createTimelineService('gemini', '接了车道2', timeline); - const deps = createMockDeps({ opus: opusService, codex: codexService, gemini: geminiService }); - - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'two-target dispatch', - 'user1', - threadId, - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - events.push(msg); - } - return { events, timeline, opusService, codexService, geminiService }; -} - -describe('F086/F216: multi-target A2A scheduling intent is explicit and honest', { concurrency: false }, () => { - test('two line-start @mentions → structured SERIAL projection on every handoff', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { events } = await runTwoTargetTurn('thread-a2a-mode-serial'); - const handoffs = events.filter((e) => e.type === 'a2a_handoff'); - - assert.strictEqual(handoffs.length, 2, 'both dispatched targets must be announced'); - - for (const h of handoffs) { - assert.ok( - h.routing, - `handoff to ${h.targetCatId} must carry a structured routing mode — ` + - 'readers must never have to infer serial-vs-parallel from target ordering', - ); - assert.strictEqual(h.routing.mode, 'serial', 'inline line-start @mentions are serial by contract'); - assert.strictEqual(h.routing.total, 2, 'group size must be explicit'); - } - - const [first, second] = handoffs; - assert.strictEqual(first.routing.index, 1, 'first announced leg is 第 1 棒'); - assert.strictEqual(second.routing.index, 2, 'second announced leg is 第 2 棒'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); - - test('user-visible pill distinguishes the running leg from the queued leg', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { events } = await runTwoTargetTurn('thread-a2a-mode-pill'); - const [first, second] = events.filter((e) => e.type === 'a2a_handoff'); - - assert.match(first.content, /串行 1\/2/, 'the running leg must say which leg it is'); - assert.match(second.content, /串行 2\/2/, 'the queued leg must say which leg it is'); - assert.match(second.content, /排队中/, 'the queued leg must not read as already dispatched'); - - // The regression that shipped: two identical "→" arrows at the same millisecond. - assert.notStrictEqual( - first.content.replace(/[^→⇢⇉]/gu, ''), - second.content.replace(/[^→⇢⇉]/gu, ''), - 'a queued serial leg must not draw the same arrow as the leg that actually started', - ); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); - - test('legacy multi-@ is normalized to serial OUT LOUD (no silent downgrade, no NLP on body)', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { events } = await runTwoTargetTurn('thread-a2a-mode-notice'); - const notice = events.find( - (e) => - e.type === 'system_info' && - typeof e.content === 'string' && - e.content.includes('a2a_multi_target_serialized'), - ); - assert.ok(notice, 'multi-target inline @ must emit an explicit serialization notice'); - - const payload = JSON.parse(notice.content); - assert.strictEqual(payload.mode, 'serial'); - assert.deepStrictEqual(payload.order, ['codex', 'gemini'], 'the notice states the real serial order'); - assert.match(payload.message, /cat_cafe_multi_mention/, 'the structured parallel escape hatch must be named'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); - - // ── 砚砚 R1 P1: a notice with no registered visible/persistent consumer is not a notice ── - // (live readability is covered in packages/web/src/hooks/__tests__/system-info-visible.test.ts) - test('the serialization notice survives refresh (persisted as a user-facing notice)', async () => { - const { isUserFacingSystemInfoContent } = await import( - '../dist/domains/cats/services/agents/routing/route-helpers.js' - ); - const { persistUserFacingSystemInfoNotices } = await import( - '../dist/domains/cats/services/agents/routing/persist-system-info-warnings.js' - ); - const payload = JSON.stringify({ - type: 'a2a_multi_target_serialized', - fromCatId: 'opus', - mode: 'serial', - order: ['codex', 'gemini'], - message: '本回合有 2 个行首 @ 目标,已按 串行(serial) 调度', - }); - assert.ok(isUserFacingSystemInfoContent(payload), 'payload must be on the user-facing whitelist'); - - const appended = []; - await persistUserFacingSystemInfoNotices({ - messageStore: { - append: async (m) => { - appended.push(m); - return { id: 'm1', ...m }; - }, - }, - threadId: 't1', - catId: 'opus', - contents: [payload], - }); - assert.equal(appended.length, 1, 'the notice must be written to history, not only streamed'); - assert.match(appended[0].content, /串行/); - assert.equal(appended[0].source.meta.presentation, 'system_notice'); - }); - - test('a real two-target turn persists the notice (both emit sites wired)', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const appended = []; - const timeline = []; - const opusService = createTimelineService('opus', 'ok\n\n@codex\n车道1\n\n@gemini\n车道2', timeline); - const deps = createMockDeps({ - opus: opusService, - codex: createTimelineService('codex', '接了', timeline), - gemini: createTimelineService('gemini', '接了', timeline), - }); - let n = 0; - deps.messageStore.append = async (m) => { - appended.push(m); - return { id: `msg-${n++}`, userId: '', catId: null, content: '', mentions: [], timestamp: 0, ...m }; - }; - - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - for await (const _msg of routeSerial( - deps, - ['opus'], - 'persisted notice', - 'user1', - 'thread-a2a-notice-persist', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - /* drain */ - } - - const persistedNotice = appended.find((m) => m.source?.connector === 'a2a-routing-mode'); - assert.ok(persistedNotice, 'the live notice must also land in durable history (F5 parity)'); - assert.match(persistedNotice.content, /cat_cafe_multi_mention/); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); - - // ── SAME-CLASS SWEEP (operator-directed), NEGATIVE RESULT + characterization guard. - // Hypothesis: the serial handoff loop computes index/total from `worklist.length` while a later - // leg can still be spliced out for losing its slot — i.e. the R1 "announced before admitted" - // defect mirrored onto the path 砚砚 never probed. - // It does NOT reproduce: `claimOrDeferA2ATarget` gates entry at worklist.push time and caches the - // claim in `activeTrackedA2ASlots`, so the handoff-loop re-check is always a cache hit and the - // splice branch is unreachable for A2A targets. A first version of this test "passed" both before - // and after a speculative fix — a false green — which is what exposed the hypothesis as wrong. - // The fix was reverted; this test stays as the guard that pins the mechanism actually relied on: - // a target that fails slot admission never inflates the announced group size. - test('a leg that fails slot admission never enters the announced group size', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const timeline = []; - const deps = createMockDeps({ - opus: createTimelineService('opus', 'ok\n\n@codex\n车道1\n\n@gemini\n车道2', timeline), - codex: createTimelineService('codex', '接了', timeline), - gemini: createTimelineService('gemini', '接了', timeline), - }); - - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'one leg loses its slot', - 'user1', - 'thread-a2a-splice', - withClaimedA2ASlot({ - thinkingMode: 'play', - // gemini's slot is already owned elsewhere → it is durably deferred and spliced out of - // the worklist. The defer channel must exist, otherwise custody admission throws instead - // of taking the branch under test. - trackA2ASlot: (_threadId, catId) => catId !== 'gemini', - deferA2AEnqueue: () => ({ outcome: 'enqueued', entry: { id: 'deferred-1' } }), - }), - )) { - events.push(msg); - } - - const handoffs = events.filter((e) => e.type === 'a2a_handoff'); - assert.strictEqual(handoffs.length, 1, 'precondition: only the admitted leg is announced'); - assert.strictEqual(handoffs[0].targetCatId, 'codex'); - assert.strictEqual( - handoffs[0].routing.total, - 1, - 'a leg that never obtained a slot must not inflate the announced group size — ' + - '"串行 1/2" with only one real leg is the same lie as #1291, one path over', - ); - assert.strictEqual(handoffs[0].routing.index, 1); - assert.doesNotMatch(handoffs[0].content, /1\/2/); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); - - test('declared serial really is serial: leg 2 starts only after leg 1 terminates', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { timeline, codexService, geminiService } = await runTwoTargetTurn('thread-a2a-mode-order'); - assert.strictEqual(codexService.calls.length, 1); - assert.strictEqual(geminiService.calls.length, 1); - assert.ok( - timeline.indexOf('end:codex') < timeline.indexOf('start:gemini'), - `serial contract violated — timeline was ${timeline.join(' | ')}`, - ); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) catRegistry.register(id, config); - } - }); -}); - -describe('F086/F216: routing projection labels', { concurrency: false }, () => { - test('parallel fan-out and serial worklist render as different shapes', async () => { - const { formatA2AHandoffContent } = await import( - '../dist/domains/cats/services/agents/routing/a2a-handoff-label.js' - ); - const serial2 = formatA2AHandoffContent('a', 'b', undefined, undefined, { - mode: 'serial', - index: 2, - total: 2, - }); - const parallel2 = formatA2AHandoffContent('a', 'b', undefined, undefined, { - mode: 'parallel', - index: 2, - total: 2, - }); - assert.notStrictEqual(serial2, parallel2, 'serial and parallel must not render identically'); - assert.match(parallel2, /并行 2\/2/); - assert.match(serial2, /串行 2\/2/); - - // Single-target handoffs keep the historical shape (no false precision). - assert.strictEqual(formatA2AHandoffContent('a', 'b'), 'a → b'); - assert.strictEqual( - formatA2AHandoffContent('a', 'b', undefined, undefined, { mode: 'serial', index: 1, total: 1 }), - 'a → b', - ); - }); - - test('isRoutingProjectionStartingNow separates announced from actually-started', async () => { - const { isRoutingProjectionStartingNow } = await import('@cat-cafe/shared'); - assert.strictEqual(isRoutingProjectionStartingNow({ mode: 'serial', index: 1, total: 2 }), true); - assert.strictEqual(isRoutingProjectionStartingNow({ mode: 'serial', index: 2, total: 2 }), false); - assert.strictEqual(isRoutingProjectionStartingNow({ mode: 'parallel', index: 2, total: 2 }), true); - }); -}); diff --git a/packages/api/test/a2a-routing-persist.test.js b/packages/api/test/a2a-routing-persist.test.js deleted file mode 100644 index 75d75129a2..0000000000 --- a/packages/api/test/a2a-routing-persist.test.js +++ /dev/null @@ -1,287 +0,0 @@ -/** - * Regression tests for A2A routing message persistence (#648). - * - * Covers: - * 1. persistA2ARoutingMessage helper stores system message and returns messageId - * 2. safeParseExtra preserves systemKind through Redis-style round-trip - */ - -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { safeParseExtra } from '../dist/domains/cats/services/stores/redis/redis-message-parsers.js'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); - -async function waitFor(predicate, timeoutMs = 1000, intervalMs = 10) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return true; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - return predicate(); -} - -describe('A2A routing message persistence (#648)', () => { - describe('safeParseExtra preserves systemKind through round-trip', () => { - it('preserves systemKind: a2a_routing', () => { - const raw = JSON.stringify({ systemKind: 'a2a_routing' }); - const parsed = safeParseExtra(raw); - assert.equal(parsed?.systemKind, 'a2a_routing'); - }); - - it('drops unknown systemKind values', () => { - const raw = JSON.stringify({ systemKind: 'unknown_kind' }); - const parsed = safeParseExtra(raw); - assert.equal(parsed, undefined); - }); - - it('preserves systemKind alongside other extra fields', () => { - const raw = JSON.stringify({ - systemKind: 'a2a_routing', - stream: { invocationId: 'inv-123' }, - a2aRouting: { fromCatId: 'codex', targetCatId: 'opus-47', invocationId: 'inv-123' }, - }); - const parsed = safeParseExtra(raw); - assert.equal(parsed?.systemKind, 'a2a_routing'); - assert.equal(parsed?.stream?.invocationId, 'inv-123'); - assert.deepEqual(parsed?.a2aRouting, { - fromCatId: 'codex', - targetCatId: 'opus-47', - invocationId: 'inv-123', - }); - }); - - it('survives JSON serialize → parse cycle (simulates Redis storage)', () => { - const original = { systemKind: 'a2a_routing' }; - const serialized = JSON.stringify(original); - const deserialized = safeParseExtra(serialized); - assert.equal(deserialized?.systemKind, 'a2a_routing'); - }); - }); - - describe('A2A handoff message storage contract', () => { - it('persists a2a_handoff as system message with correct shape', () => { - const store = new MessageStore(); - const result = store.append({ - userId: 'system', - catId: null, - content: '布偶猫 → 缅因猫', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - extra: { - systemKind: 'a2a_routing', - a2aRouting: { fromCatId: 'codex', targetCatId: 'opus-47', invocationId: 'inv-123' }, - }, - }); - - assert.ok(result.id, 'stored message should have an id'); - - const messages = store.getByThread('thread-1'); - const stored = messages.find((m) => m.id === result.id); - assert.ok(stored, 'message should be retrievable from store'); - assert.equal(stored.userId, 'system'); - assert.equal(stored.catId, null); - assert.equal(stored.content, '布偶猫 → 缅因猫'); - assert.deepEqual(stored.extra, { - systemKind: 'a2a_routing', - a2aRouting: { fromCatId: 'codex', targetCatId: 'opus-47', invocationId: 'inv-123' }, - }); - }); - - it('stored messageId can be attached to broadcast payload', () => { - const store = new MessageStore(); - const result = store.append({ - userId: 'system', - catId: null, - content: '布偶猫 → 缅因猫', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - extra: { systemKind: 'a2a_routing' }, - }); - - const broadcastPayload = { - type: 'a2a_handoff', - content: '布偶猫 → 缅因猫', - messageId: result.id, - }; - - assert.ok(broadcastPayload.messageId, 'broadcast payload should carry stored messageId'); - assert.equal(typeof broadcastPayload.messageId, 'string'); - }); - }); -}); - -/** - * Integration tests: drive the real streaming route with a2a_handoff events - * and verify both persistence and broadcast carry the stored messageId. - * - * Pattern mirrors messages-delivery-mode.test.js (buildDeps + Fastify inject). - */ - -function buildDeps(overrides = {}) { - const invocationQueue = new InvocationQueue(); - let nextStoredId = 0; - return { - registry: new InvocationRegistry(), - messageStore: { - append: mock.fn(async (msg) => ({ - id: msg.extra?.systemKind === 'a2a_routing' ? 'msg-a2a-routing' : `msg-${++nextStoredId}`, - ...msg, - })), - getByThread: mock.fn(async () => []), - getByThreadBefore: mock.fn(async () => []), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - router: { - resolveTargetsAndIntent: mock.fn(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - })), - routeExecution: mock.fn(async function* () { - yield { - type: 'a2a_handoff', - catId: 'codex', - targetCatId: 'opus-47', - content: '缅因猫(codex) → 布偶猫(Opus 4.7)', - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - route: mock.fn(async function* () { - yield { type: 'done' }; - }), - }, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - tryStartThread: mock.fn(() => new AbortController()), - tryStartThreadAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - cancel: mock.fn(() => ({ cancelled: true, catIds: ['opus'] })), - isDeleting: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'created', - invocationId: 'inv-a2a-test', - })), - update: mock.fn(async () => {}), - }, - invocationQueue, - queueProcessor: { - clearPause: mock.fn(), - onInvocationComplete: mock.fn(async () => {}), - enqueueContinuation: mock.fn(() => ({ outcome: 'enqueued' })), - }, - threadStore: { - get: mock.fn(async () => ({ - id: 'thread-1', - title: 'Test Thread', - createdBy: 'test-user', - })), - updateTitle: mock.fn(async () => {}), - }, - ...overrides, - }; -} - -describe('Integration: a2a_handoff persistence through streaming route (#648)', () => { - let app; - let deps; - - beforeEach(async () => { - deps = buildDeps(); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('persists a2a_handoff as system message and attaches messageId to broadcast', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: 'test routing', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - - const processed = await waitFor(() => - deps.socketManager.broadcastAgentMessage.mock.calls.some((c) => c.arguments[0]?.type === 'a2a_handoff'), - ); - assert.equal(processed, true, 'background route should broadcast a2a_handoff'); - - // 1. messageStore.append stores system message with extra.systemKind = 'a2a_routing' - const appendCalls = deps.messageStore.append.mock.calls; - const a2aAppend = appendCalls.find( - (c) => c.arguments[0]?.userId === 'system' && c.arguments[0]?.extra?.systemKind === 'a2a_routing', - ); - assert.ok(a2aAppend, 'messageStore.append should be called with systemKind: a2a_routing'); - assert.equal(a2aAppend.arguments[0].catId, null); - assert.equal(a2aAppend.arguments[0].content, '缅因猫(codex) → 布偶猫(Opus 4.7)'); - assert.equal(a2aAppend.arguments[0].threadId, 'thread-1'); - assert.deepEqual(a2aAppend.arguments[0].extra.a2aRouting, { - fromCatId: 'codex', - targetCatId: 'opus-47', - invocationId: 'inv-a2a-test', - }); - - // 2. broadcastAgentMessage receives the a2a_handoff event with stored messageId - const broadcastCalls = deps.socketManager.broadcastAgentMessage.mock.calls; - const a2aBroadcast = broadcastCalls.find((c) => c.arguments[0]?.type === 'a2a_handoff'); - assert.ok(a2aBroadcast, 'should broadcast a2a_handoff event'); - assert.equal(a2aBroadcast.arguments[0].messageId, 'msg-a2a-routing'); - assert.equal(a2aBroadcast.arguments[0].invocationId, 'inv-a2a-test'); - assert.equal(a2aBroadcast.arguments[0].targetCatId, 'opus-47'); - }); - - it('skips persistence when a2a_handoff has no content', async () => { - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { type: 'a2a_handoff', catId: 'opus', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: 'test no content', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - const processed = await waitFor(() => - deps.socketManager.broadcastAgentMessage.mock.calls.some((c) => c.arguments[0]?.type === 'a2a_handoff'), - ); - assert.equal(processed, true, 'background route should broadcast a2a_handoff'); - - // No system message should be appended (only user message) - const appendCalls = deps.messageStore.append.mock.calls; - const a2aAppend = appendCalls.find( - (c) => c.arguments[0]?.userId === 'system' && c.arguments[0]?.extra?.systemKind === 'a2a_routing', - ); - assert.equal(a2aAppend, undefined, 'should not persist a2a_handoff without content'); - - // Broadcast still happens, but without messageId - const broadcastCalls = deps.socketManager.broadcastAgentMessage.mock.calls; - const a2aBroadcast = broadcastCalls.find((c) => c.arguments[0]?.type === 'a2a_handoff'); - assert.ok(a2aBroadcast, 'should still broadcast a2a_handoff event'); - assert.equal(a2aBroadcast.arguments[0].messageId, undefined, 'no messageId when content is empty'); - }); -}); diff --git a/packages/api/test/a2a-settle-holder.test.js b/packages/api/test/a2a-settle-holder.test.js index 53ff3296d7..1c34518093 100644 --- a/packages/api/test/a2a-settle-holder.test.js +++ b/packages/api/test/a2a-settle-holder.test.js @@ -9,8 +9,6 @@ * Parameterised by ENTRY POINT, because entry points are exactly what kept getting missed: * - completion hook (a queued target finishes) * - queue all-rejected (nothing could be admitted) - * - legacy all-failed (every admission threw) - * - legacy dispatch error (an admitted target crashes while executing) * ADDING A SETTLE TRIGGER MEANS ADDING A ROW HERE. */ @@ -19,8 +17,15 @@ import { afterEach, beforeEach, describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; import Fastify from 'fastify'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { registerCallbackAuthHook } from '../dist/routes/callback-auth-prehandler.js'; import { resetMultiMentionOrchestrator } from '../dist/routes/callback-multi-mention-routes.js'; +import { + adaptInvocationQueue, + adaptMessageStore, + appendTestLifecycleResponseSource, + canonicalTestQueueInput, +} from './helpers/message-from-fixtures.js'; function createMockRegistry() { const records = new Map(); @@ -67,7 +72,7 @@ async function withRejectionWatch(fn) { describe('INV-2: the single settle exit swallows no group and leaks no rejection', () => { let app; - let mockRegistry, creds, invocationQueue, appendImpl, queueProcessor, legacyCreateImpl, legacyRouteError, useQueue; + let mockRegistry, creds, invocationQueue, appendImpl, persistedAppend, queueProcessor, messageStore; const buildApp = async () => { app = Fastify({ logger: false }); @@ -75,18 +80,8 @@ describe('INV-2: the single settle exit swallows no group and leaks no rejection const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(app, { registry: mockRegistry, - messageStore: { - append: (msg) => appendImpl(msg), - getById: () => null, - }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {} }, - router: { - // biome-ignore lint/correctness/useYield: only an async iterable is required here - async *routeExecution() { - if (legacyRouteError) throw legacyRouteError; - }, - }, - invocationRecordStore: { create: (input) => legacyCreateImpl(input), update() {} }, + messageStore, + socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, invocationTracker: { start: () => new AbortController(), startAll: () => new AbortController(), @@ -94,7 +89,8 @@ describe('INV-2: the single settle exit swallows no group and leaks no rejection complete() {}, completeAll() {}, }, - ...(useQueue ? { invocationQueue, queueProcessor } : {}), + invocationQueue, + queueProcessor, }); await app.ready(); }; @@ -102,16 +98,16 @@ describe('INV-2: the single settle exit swallows no group and leaks no rejection beforeEach(() => { resetMultiMentionOrchestrator(); mockRegistry = createMockRegistry(); - invocationQueue = new InvocationQueue(); - appendImpl = (msg) => ({ id: 'm', ...msg }); - legacyCreateImpl = () => ({ outcome: 'created', invocationId: `inv-${Math.random()}` }); - legacyRouteError = undefined; - useQueue = true; + invocationQueue = adaptInvocationQueue(new InvocationQueue()); + messageStore = adaptMessageStore(new MessageStore()); + persistedAppend = messageStore.append.bind(messageStore); + appendImpl = (msg) => persistedAppend(msg); + messageStore.append = (msg) => appendImpl(msg); const hooks = new Map(); queueProcessor = { registerEntryCompleteHook: (id, hook) => hooks.set(id, hook), unregisterEntryCompleteHook: (id) => hooks.delete(id), - tryAutoExecute: () => Promise.resolve(), + requestDrain: () => Promise.resolve(), getHooks: () => hooks, simulateComplete: (id, status, text) => { const hook = hooks.get(id); @@ -122,6 +118,11 @@ describe('INV-2: the single settle exit swallows no group and leaks no rejection }, }; creds = mockRegistry.register('opus', 'thread-settle', 'user-1'); + appendTestLifecycleResponseSource(messageStore, { + ...creds, + threadId: 'thread-settle', + userId: 'user-1', + }); }); afterEach(async () => { @@ -141,7 +142,7 @@ describe('INV-2: the single settle exit swallows no group and leaks no rejection if (typeof msg.content === 'string' && msg.content.includes('Multi-Mention 结果汇总')) { return Promise.reject(new Error('flush store unavailable')); } - return { id: 'm', ...msg }; + return persistedAppend(msg); }; }; @@ -160,17 +161,20 @@ describe('INV-2: the single settle exit swallows no group and leaks no rejection await buildApp(); // Saturate the agent-entry depth budget so nothing can be admitted. for (let i = 0; i < 10; i++) { - invocationQueue.enqueue({ - threadId: 'thread-settle', - userId: 'user-1', - ownerAuthProvenance: 'strict', - content: `filler-${i}`, - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + kind: 'private_input', + threadId: 'thread-settle', + userId: 'user-1', + ownerAuthProvenance: 'strict', + content: `filler-${i}`, + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + autoExecute: true, + callerCatId: 'opus', + }), + ); } const rejections = await withRejectionWatch(async () => { failTheSummaryWrite(); @@ -179,41 +183,4 @@ describe('INV-2: the single settle exit swallows no group and leaks no rejection }); assert.deepEqual(rejections, [], `settle must own its failure policy, got: ${rejections.join(' | ')}`); }); - - test('ENTRY: legacy all-failed admission — a failing summary write does not leak a rejection', async () => { - useQueue = false; - legacyCreateImpl = () => { - throw new Error('record store down'); - }; - await buildApp(); - const rejections = await withRejectionWatch(async () => { - failTheSummaryWrite(); - const res = await dispatch(['codex', 'gemini']); - assert.equal(res.statusCode, 200); - }); - assert.deepEqual(rejections, [], `settle must own its failure policy, got: ${rejections.join(' | ')}`); - }); - - test('ENTRY: legacy dispatch error — the terminal group persists its summary immediately', async () => { - useQueue = false; - legacyRouteError = new Error('runtime dispatch failed'); - const summaries = []; - appendImpl = (msg) => { - if (typeof msg.content === 'string' && msg.content.includes('Multi-Mention 结果汇总')) { - summaries.push(msg); - } - return { id: 'm', ...msg }; - }; - await buildApp(); - - let response; - const rejections = await withRejectionWatch(async () => { - response = await dispatch(['codex']); - }); - - assert.equal(response.statusCode, 200); - assert.equal(summaries.length, 1, 'a terminal dispatch error must flush without waiting for timeout'); - assert.match(summaries[0].content, /dispatch error: runtime dispatch failed/); - assert.deepEqual(rejections, [], `settle must own its failure policy, got: ${rejections.join(' | ')}`); - }); }); diff --git a/packages/api/test/action-successor-carrier-recovery.test.js b/packages/api/test/action-successor-carrier-recovery.test.js index ae172947f9..cbd9c9231f 100644 --- a/packages/api/test/action-successor-carrier-recovery.test.js +++ b/packages/api/test/action-successor-carrier-recovery.test.js @@ -10,19 +10,19 @@ import { import { reconcileActionSuccessorEnqueue } from '../dist/domains/ball-custody/reconcile-action-successor-enqueue.js'; const terminalPredicate = canonicalizeActionTerminalPredicate({ - actionFamily: 'implement', - subjectRef: 'subject:task:task-4058', - predicate: { kind: 'task_done' }, + actionFamily: 'review', + subjectRef: 'pr:owner/repo#4058', + predicate: { kind: 'review_delivered', headSha: 'a'.repeat(40) }, }); function lease(overrides = {}) { return { leaseId: 'lease-review-4058', - key: 'user-1|subject:task:task-4058|implement|implementer', + key: 'user-1|pr:owner/repo#4058|review|reviewer', tenantScope: 'user-1', - subjectRef: 'subject:task:task-4058', - actionFamily: 'implement', - successorSlot: 'implementer', + subjectRef: 'pr:owner/repo#4058', + actionFamily: 'review', + successorSlot: 'reviewer', mode: 'single', holderCatIds: ['opus5'], dispatchId: 'cross-post:review-4058-old', @@ -57,126 +57,55 @@ function request(overrides = {}) { evidenceRef: 'callback:new-invocation:review-4058-reentry', now: 200, action: { - subjectRef: 'subject:task:task-4058', - actionFamily: 'implement', - successorSlot: 'implementer', + subjectRef: 'pr:owner/repo#4058', + actionFamily: 'review', + successorSlot: 'reviewer', mode: 'single', - terminalPredicate: { kind: 'task_done' }, + terminalPredicate: { kind: 'review_delivered', headSha: 'a'.repeat(40) }, }, ...overrides, }; } -function messageForTarget(targetCatId, state, overrides = {}) { - const currentLease = overrides.lease ?? lease({ holderCatIds: [targetCatId] }); +function ledgerEntryForTargets(targets, overrides = {}) { + const currentLease = overrides.lease ?? lease({ holderCatIds: targets }); const fence = overrides.fence ?? buildActionSuccessorFence(currentLease, currentLease.dispatchId); - const interrupted = state === 'interrupted'; - const handled = state === 'handled'; - const withdrawn = state === 'withdrawn'; - const failed = state === 'failed'; - const live = ['queued', 'notified', 'awakened', 'seen', 'steering'].includes(state); + const sourceRecordId = overrides.id ?? `message-${targets.join('-')}`; return { - id: overrides.id ?? `message-${targetCatId}-${state}`, + version: 2, + id: overrides.id ?? `entry-${targets.join('-')}`, threadId: currentLease.holderThreadId, - userId: currentLease.tenantScope, - catId: currentLease.predecessorCatId, - content: 'Implement task', - mentions: [targetCatId], - timestamp: 100, - deliveryStatus: live ? 'queued' : 'delivered', - queueCustody: { - version: 1, - entryId: `entry-${targetCatId}`, - revision: 2, - intent: 'review', - status: live ? 'queued' : 'terminal', - allTargetCats: [targetCatId], - pendingTargetCats: live ? [targetCatId] : [], - notifiedByCatIds: ['notified', 'awakened', 'seen', 'steering'].includes(state) ? [targetCatId] : [], - ...(state === 'awakened' || state === 'seen' || state === 'steering' - ? { awakenedInvocationIdByCatId: { [targetCatId]: `invocation-${targetCatId}` } } - : {}), - ...(state === 'awakened' || state === 'seen' || state === 'steering' - ? { awakenedAtByCatId: { [targetCatId]: 110 } } - : {}), - seenByCatIds: state === 'seen' || state === 'steering' ? [targetCatId] : [], - seenInvocationIdByCatId: - state === 'seen' || state === 'steering' ? { [targetCatId]: `invocation-${targetCatId}` } : {}, - ...(state === 'steering' ? { steeredInvocationIdByCatId: { [targetCatId]: `invocation-${targetCatId}` } } : {}), - failedByCatIds: interrupted || failed ? [targetCatId] : [], - ...(withdrawn ? { withdrawnByCatIds: [targetCatId], withdrawnAtByCatId: { [targetCatId]: 120 } } : {}), - handledByCatIds: handled ? [targetCatId] : [], - carrierByTargetCatId: { - [targetCatId]: { - entryId: `entry-${targetCatId}`, - idempotencyKey: `action:${fence.leaseId}:${fence.generation}:${targetCatId}`, - actionSuccessorFence: fence, - source: 'agent', - sourceCategory: 'a2a', - callerCatId: currentLease.predecessorCatId, - a2aTriggerMessageId: overrides.id ?? `message-${targetCatId}-${state}`, - autoExecute: true, - createdAt: 100, - }, - }, - ...(live ? { carrierStateByTargetCatId: { [targetCatId]: { status: 'queued' } } } : {}), - targetAttempts: [ - { - id: `entry-${targetCatId}:${targetCatId}:1`, - targetCatId, - sequence: 1, - state: interrupted ? 'interrupted' : failed ? 'failed' : handled ? 'handled' : live ? 'queued' : 'cancelled', - ...(interrupted ? { terminalReason: 'runtime_restart', invocationId: `invocation-${targetCatId}` } : {}), - ...(failed ? { terminalReason: 'invocation_failed' } : {}), - ...(withdrawn ? { terminalReason: 'source_withdrawn' } : {}), - ...(handled ? { invocationId: `invocation-${targetCatId}` } : {}), - createdAt: 100, - updatedAt: 120, - }, - ], - priority: 'normal', - createdAt: 100, - updatedAt: 120, + owner: { kind: 'user', userId: currentLease.tenantScope }, + kind: 'conversation_input', + from: { kind: 'agent', catId: currentLease.predecessorCatId }, + targets, + payload: { + sourceRecordId, + content: 'Review exact HEAD', + messageId: sourceRecordId, }, + execution: { intent: 'review', ownerAuthProvenance: 'strict', autoExecute: true, actionSuccessorFence: fence }, + delivery: {}, + status: 'queued', + enqueuedAt: 100, + priority: 'normal', + sourceCategory: 'a2a', }; } describe('direct action successor carrier recovery', () => { - test('keeps safe_wait only when every exact-fence holder has live durable custody', () => { + test('keeps safe_wait only when every exact-fence holder remains pending in Queue', () => { const current = lease(); - for (const state of ['queued', 'notified', 'awakened', 'seen', 'steering']) { - assert.deepEqual(classifyDirectActionSuccessorCarrier(current, [messageForTarget('opus5', state)]), { - disposition: 'live', - fence: buildActionSuccessorFence(current, current.dispatchId), - }); - } + assert.deepEqual(classifyDirectActionSuccessorCarrier(current, [ledgerEntryForTargets(['opus5'])]), { + disposition: 'live', + fence: buildActionSuccessorFence(current, current.dispatchId), + }); }); test('recognizes a complete pre-CAS admission as live durable custody', () => { const current = lease(); const fence = buildActionSuccessorFence(current, current.dispatchId); - const admission = { - id: 'message-admission', - threadId: current.holderThreadId, - userId: current.tenantScope, - catId: current.predecessorCatId, - content: 'Implement task', - mentions: ['opus5'], - timestamp: 100, - deliveryStatus: 'queued', - queueCustodyAdmission: { - version: 1, - admissionId: 'admission-review', - ownerUserId: current.tenantScope, - ownerAuthProvenance: 'strict', - intent: 'review', - targetCats: ['opus5'], - requestedTargetCats: ['opus5'], - actionSuccessorFence: fence, - priority: 'normal', - createdAt: 100, - }, - }; + const admission = ledgerEntryForTargets(['opus5'], { id: 'message-admission', fence }); assert.deepEqual(classifyDirectActionSuccessorCarrier(current, [admission]), { disposition: 'live', @@ -184,39 +113,19 @@ describe('direct action successor carrier recovery', () => { }); }); - test('recovers only when every exact holder carrier was interrupted by runtime restart', () => { - const current = lease(); - assert.deepEqual(classifyDirectActionSuccessorCarrier(current, [messageForTarget('opus5', 'interrupted')]), { - disposition: 'restart_interrupted', - fence: buildActionSuccessorFence(current, current.dispatchId), - }); - }); - - test('fails closed for missing, terminal, failed, mixed, or wrong-fence custody', () => { + test('fails closed for missing, partially delivered, or wrong-fence custody', () => { const single = lease(); assert.equal(classifyDirectActionSuccessorCarrier(single, []).disposition, 'unavailable'); - for (const state of ['handled', 'withdrawn', 'failed']) { - assert.equal( - classifyDirectActionSuccessorCarrier(single, [messageForTarget('opus5', state)]).disposition, - 'unavailable', - ); - } - const parallel = lease({ - mode: 'parallel', - holderCatIds: ['opus5', 'kimi'], - parallelIntent: 'independent implementation', - }); + const parallel = lease({ mode: 'parallel', holderCatIds: ['opus5', 'kimi'], parallelIntent: 'independent review' }); assert.equal( - classifyDirectActionSuccessorCarrier(parallel, [ - messageForTarget('opus5', 'interrupted', { lease: parallel }), - messageForTarget('kimi', 'queued', { lease: parallel }), - ]).disposition, + classifyDirectActionSuccessorCarrier(parallel, [ledgerEntryForTargets(['kimi'], { lease: parallel })]) + .disposition, 'unavailable', ); assert.equal( classifyDirectActionSuccessorCarrier(single, [ - messageForTarget('opus5', 'interrupted', { + ledgerEntryForTargets(['opus5'], { fence: { ...buildActionSuccessorFence(single, single.dispatchId), generation: 2 }, }), ]).disposition, @@ -236,7 +145,7 @@ describe('direct action successor carrier recovery', () => { request({ action: { ...request().action, - subjectRef: 'subject:task:other-task', + terminalPredicate: { kind: 'review_delivered', headSha: 'b'.repeat(40) }, }, }), ), @@ -248,8 +157,8 @@ describe('direct action successor carrier recovery', () => { const decision = await resolveDirectActionSuccessorCarrier({ lease: lease(), admissionInput: request(), - messageStore: { - async getByThreadAfter() { + invocationQueue: { + async listAllDurable() { throw new Error('store unavailable'); }, }, diff --git a/packages/api/test/action-successor-index-wiring.test.js b/packages/api/test/action-successor-index-wiring.test.js index 460c3d5021..7a2ed20419 100644 --- a/packages/api/test/action-successor-index-wiring.test.js +++ b/packages/api/test/action-successor-index-wiring.test.js @@ -51,7 +51,7 @@ describe('F167 successor runtime wiring', () => { }); it('reconciles pending return carriers at boot and periodically through the existing queue', () => { - const queueWiring = block('const invocationQueue = new InvocationQueue();', 'const onReconciledZombie ='); + const queueWiring = block('const invocationQueue = new InvocationQueue(', 'const onReconciledZombie ='); assert.match(queueWiring, /new ActionSuccessorRecoverySweep\(/); assert.match(queueWiring, /idempotencyKey: carrier\.idempotencyKey/); assert.match(queueWiring, /actionSuccessorFence: carrier\.fence/); @@ -64,14 +64,15 @@ describe('F167 successor runtime wiring', () => { assert.match(queueWiring, /setInterval\(runActionSuccessorRecovery, 30_000\)/); }); - it('admits the exact approved carrier into durable Queue custody before reporting delivery', () => { + it('keeps approved speech published and proves durable Queue custody before reporting delivery', () => { const deliveryWiring = block( 'const deliverApprovedActionCarrier = async (', 'if (actionSuccessorLeaseStore && actionSubjectTruthResolver) {', ); - assert.match(deliveryWiring, /deliveryStatus: 'queued'/); - assert.match(deliveryWiring, /classifyApprovedActionCarrier\(proposal, storedMsg\)/); - assert.match(deliveryWiring, /classifyApprovedActionCarrier\(proposal, admittedMessage\)/); + assert.doesNotMatch(deliveryWiring, /deliveryStatus: 'queued'/); + assert.match(deliveryWiring, /appendA2ASourceWithLedgerAdmission\(/); + assert.match(deliveryWiring, /enqueueA2ATargets\(/); + assert.match(deliveryWiring, /classifyApprovedActionCarrier\(proposal, message, entries, fence\)/); const custodyProof = deliveryWiring.indexOf("if (admittedState.outcome !== 'admitted')"); const visibleBroadcast = deliveryWiring.indexOf('actionSocketManager.broadcastAgentMessage('); const deliverySuccess = deliveryWiring.lastIndexOf( diff --git a/packages/api/test/action-successor-projection-retirement.test.js b/packages/api/test/action-successor-projection-retirement.test.js index 99bbb6dbee..b8f9413898 100644 --- a/packages/api/test/action-successor-projection-retirement.test.js +++ b/packages/api/test/action-successor-projection-retirement.test.js @@ -115,16 +115,6 @@ describe('#1371 fenced terminal projection retirement', () => { const lease = completedLease(); const taskStore = new TaskStore(); const task = await createTrackingTask(taskStore); - const queueCustodyCoordinator = { - retireActionSuccessorFence: mock.fn(async () => ({ - changed: true, - messageId: 'message-1391', - threadId: 'thread_mp3ab0r9xqxrkrc5', - userId: 'default-user', - entryIds: ['queue-1391'], - targetCatIds: ['codex-sol'], - })), - }; const invocationQueue = { listActionSuccessorFence: mock.fn(() => [ { @@ -134,7 +124,7 @@ describe('#1371 fenced terminal projection retirement', () => { messageIds: ['message-1391'], }, ]), - retireActionSuccessorFence: mock.fn(() => [ + retireActionSuccessorFenceDurable: mock.fn(async () => [ { entryId: 'queue-1391', threadId: 'thread_mp3ab0r9xqxrkrc5', @@ -145,7 +135,6 @@ describe('#1371 fenced terminal projection retirement', () => { }; const publishQueue = mock.fn(async () => undefined); const service = new ActionSuccessorProjectionRetirementService({ - queueCustodyCoordinator, invocationQueue, taskStore, publishQueue, @@ -158,8 +147,7 @@ describe('#1371 fenced terminal projection retirement', () => { assert.equal(retired.status, 'done'); assert.equal(retired.automationState.await, undefined); assert.equal(retired.automationState.ci.headSha, HEAD_A); - assert.equal(queueCustodyCoordinator.retireActionSuccessorFence.mock.calls.length, 2); - assert.equal(invocationQueue.retireActionSuccessorFence.mock.calls.length, 2); + assert.equal(invocationQueue.retireActionSuccessorFenceDurable.mock.calls.length, 2); assert.deepEqual(publishQueue.mock.calls[0].arguments[0], { threadId: 'thread_mp3ab0r9xqxrkrc5', userId: 'default-user', @@ -175,19 +163,6 @@ describe('#1371 fenced terminal projection retirement', () => { } = completedLease(); const events = []; const taskStore = new TaskStore(); - const queueCustodyCoordinator = { - retireActionSuccessorFence: mock.fn(async (messageId) => { - events.push(`durable:${messageId}`); - return { - changed: true, - messageId, - threadId: 'thread_mp3ab0r9xqxrkrc5', - userId: 'default-user', - entryIds: ['queue-direct'], - targetCatIds: ['codex-sol'], - }; - }), - }; const invocationQueue = { listActionSuccessorFence: mock.fn(() => { events.push('process:list'); @@ -200,13 +175,19 @@ describe('#1371 fenced terminal projection retirement', () => { }, ]; }), - retireActionSuccessorFence: mock.fn(() => { - events.push('process:retire'); - return []; + retireActionSuccessorFenceDurable: mock.fn(async () => { + events.push('durable:retire'); + return [ + { + entryId: 'queue-direct', + threadId: 'thread_mp3ab0r9xqxrkrc5', + userId: 'default-user', + messageIds: ['message-direct'], + }, + ]; }), }; const service = new ActionSuccessorProjectionRetirementService({ - queueCustodyCoordinator, invocationQueue, taskStore, publishQueue: async () => events.push('publish'), @@ -214,21 +195,19 @@ describe('#1371 fenced terminal projection retirement', () => { await service.retire(lease); - assert.deepEqual(events, ['process:list', 'durable:message-direct', 'process:retire', 'publish']); + assert.deepEqual(events, ['process:list', 'durable:retire', 'publish']); }); it('does not retire fresh-HEAD tracking while converging the old fenced projection', async () => { const lease = completedLease(); const taskStore = new TaskStore(); const task = await createTrackingTask(taskStore, HEAD_B); - const queueCustodyCoordinator = { retireActionSuccessorFence: mock.fn(async () => null) }; const invocationQueue = { listActionSuccessorFence: mock.fn(() => []), - retireActionSuccessorFence: mock.fn(() => []), + retireActionSuccessorFenceDurable: mock.fn(async () => []), }; const publishQueue = mock.fn(async () => undefined); const service = new ActionSuccessorProjectionRetirementService({ - queueCustodyCoordinator, invocationQueue, taskStore, publishQueue, diff --git a/packages/api/test/action-successor-queue-fence.test.js b/packages/api/test/action-successor-queue-fence.test.js index 80b35fb8ff..6e6e09fe64 100644 --- a/packages/api/test/action-successor-queue-fence.test.js +++ b/packages/api/test/action-successor-queue-fence.test.js @@ -1,12 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it, mock } from 'node:test'; +import { adaptInvocationQueue, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); function depsWithStore(store, router = null) { return { - queue: new InvocationQueue(), + queue: adaptInvocationQueue(new InvocationQueue()), actionSuccessorLeaseStore: store, invocationTracker: { start: mock.fn(() => new AbortController()), @@ -42,25 +43,34 @@ function depsWithStore(store, router = null) { }; } -function enqueueActionEntry(deps, overrides = {}) { - const result = deps.queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-a', - userId: 'user-1', - content: 'review PR', - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - autoExecute: false, - actionSuccessorFence: { - leaseId: 'lease-1', - generation: 1, - dispatchId: 'multi-mention:req-1', - terminalPredicateDigest: 'predicate-digest-1', - }, - ...overrides, +async function enqueueActionEntry(deps, overrides = {}) { + const result = deps.queue.enqueue( + canonicalTestQueueInput({ + kind: overrides.messageId || overrides.a2aTriggerMessageId ? 'message_wake' : 'private_input', + ownerAuthProvenance: 'unknown', + threadId: 'thread-a', + userId: 'user-1', + content: 'review PR', + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + autoExecute: false, + actionSuccessorFence: { + leaseId: 'lease-1', + generation: 1, + dispatchId: 'multi-mention:req-1', + terminalPredicateDigest: 'predicate-digest-1', + }, + ...overrides, + }), + ); + const targetCatId = result.entry.targets[0]; + assert.ok(targetCatId, 'action successor fixture must admit one target'); + const claimed = await deps.queue.markProcessingDurable('thread-a', 'user-1', { + entryId: result.entry.id, + targetCats: [targetCatId], }); - return deps.queue.markProcessing('thread-a', 'user-1') ?? result.entry; + return claimed ?? result.entry; } describe('QueueProcessor action successor generation fence', () => { @@ -98,7 +108,7 @@ describe('QueueProcessor action successor generation fence', () => { const firstDeps = depsWithStore(store); firstDeps.invocationRecordStore = invocationRecordStore; const firstProcessor = new QueueProcessor(firstDeps); - const firstEntry = enqueueActionEntry(firstDeps, { + const firstEntry = await enqueueActionEntry(firstDeps, { idempotencyKey: 'action-return:lease-1:1:opus', }); await firstProcessor.executeEntry(firstEntry); @@ -106,7 +116,7 @@ describe('QueueProcessor action successor generation fence', () => { const restartedDeps = depsWithStore(store); restartedDeps.invocationRecordStore = invocationRecordStore; const restartedProcessor = new QueueProcessor(restartedDeps); - const restartedEntry = enqueueActionEntry(restartedDeps, { + const restartedEntry = await enqueueActionEntry(restartedDeps, { idempotencyKey: 'action-return:lease-1:1:opus', }); await restartedProcessor.executeEntry(restartedEntry); @@ -153,7 +163,7 @@ describe('QueueProcessor action successor generation fence', () => { const deps = depsWithStore(store); deps.invocationRecordStore = invocationRecordStore; const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { + const entry = await enqueueActionEntry(deps, { idempotencyKey: 'action-return:lease-1:1:opus', }); @@ -202,7 +212,7 @@ describe('QueueProcessor action successor generation fence', () => { const deps = depsWithStore(store); deps.invocationRecordStore = invocationRecordStore; const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { + const entry = await enqueueActionEntry(deps, { idempotencyKey: 'action-return:lease-1:1:opus', }); @@ -225,7 +235,7 @@ describe('QueueProcessor action successor generation fence', () => { ackCollectedCursors: mock.fn(async () => {}), }); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const result = await processor.executeEntry(entry); @@ -248,7 +258,7 @@ describe('QueueProcessor action successor generation fence', () => { deliveryTransitioned: true, })); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { messageId: 'queued-trigger-1' }); + const entry = await enqueueActionEntry(deps, { messageId: 'queued-trigger-1' }); const hook = mock.fn(); processor.registerEntryCompleteHook(entry.id, hook); @@ -297,7 +307,7 @@ describe('QueueProcessor action successor generation fence', () => { }; deps.outboundHook = { deliver: mock.fn(async () => {}) }; const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const hook = mock.fn(); processor.registerEntryCompleteHook(entry.id, hook); @@ -335,7 +345,7 @@ describe('QueueProcessor action successor generation fence', () => { ackCollectedCursors: mock.fn(async () => {}), }); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const hook = mock.fn(); processor.registerEntryCompleteHook(entry.id, hook); @@ -369,7 +379,7 @@ describe('QueueProcessor action successor generation fence', () => { ackCollectedCursors: mock.fn(async () => {}), }); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { + const entry = await enqueueActionEntry(deps, { actionSuccessorFence: { leaseId: 'lease-1', generation: 1, @@ -414,7 +424,7 @@ describe('QueueProcessor action successor generation fence', () => { ackCollectedCursors: mock.fn(async () => {}), }); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const result = await processor.executeEntry(entry); @@ -448,7 +458,7 @@ describe('QueueProcessor action successor generation fence', () => { ackCollectedCursors: mock.fn(async () => {}), }); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const result = await processor.executeEntry(entry); @@ -481,7 +491,7 @@ describe('QueueProcessor action successor generation fence', () => { }; deps.outboundHook = { deliver: mock.fn(async () => {}) }; const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const result = await processor.executeEntry(entry); @@ -498,34 +508,6 @@ describe('QueueProcessor action successor generation fence', () => { ); }); - it('records every parallel holder before releasing the buffered batch', async () => { - const store = { - preflight: mock.fn(async () => ({ ok: true, reason: 'active' })), - preflightOutput: mock.fn(async () => ({ ok: true, reason: 'active' })), - commitOutcome: mock.fn(async () => ({ outcome: 'recorded', lease: { status: 'active' } })), - }; - const deps = depsWithStore(store, { - routeExecution: mock.fn(async function* (...args) { - const options = args[6]; - assert.equal(await options.beforeOutputCommit('opus'), true); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - assert.equal(await options.beforeOutputCommit('codex'), true); - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }); - const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { targetCats: ['opus', 'codex'] }); - - const result = await processor.executeEntry(entry); - - assert.equal(result.status, 'succeeded'); - assert.equal(store.preflight.mock.calls.length, 1); - assert.equal(store.preflightOutput.mock.calls.length, 2); - assert.equal(store.commitOutcome.mock.calls.length, 0); - assert.equal(deps.socketManager.broadcastAgentMessage.mock.calls.length, 2); - }); - it('retains queue Stop ownership across transient diagnostics until done', async () => { const store = { preflight: mock.fn(async () => ({ ok: true, reason: 'active' })), @@ -548,7 +530,7 @@ describe('QueueProcessor action successor generation fence', () => { ackCollectedCursors: mock.fn(async () => {}), }); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const result = await processor.executeEntry(entry); @@ -557,186 +539,6 @@ describe('QueueProcessor action successor generation fence', () => { assert.equal(deps.invocationTracker.completeSlot.mock.calls[0].arguments[1], 'opus'); }); - it('finalizes every failed holder when aggregate success has no successful output', async () => { - const store = { - preflight: mock.fn(async () => ({ ok: true, reason: 'active' })), - preflightOutput: mock.fn(), - commitOutcome: mock.fn(async () => ({ outcome: 'recorded', lease: { status: 'replaceable' } })), - }; - const deps = depsWithStore(store, { - routeExecution: mock.fn(async function* () { - yield { - type: 'error', - catId: 'opus', - errorCode: 'provider_failed', - errorDisposition: 'terminal', - timestamp: Date.now(), - }; - yield { - type: 'error', - catId: 'codex', - errorCode: 'provider_failed', - errorDisposition: 'terminal', - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }); - deps.invocationTracker.resolveFinalStatus = mock.fn(() => 'succeeded'); - deps.invocationTracker.getSlotState = mock.fn(() => 'absent'); - const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { targetCats: ['opus', 'codex'] }); - - const result = await processor.executeEntry(entry); - - assert.equal(result.status, 'canceled'); - assert.deepEqual( - store.commitOutcome.mock.calls.map((call) => ({ ...call.arguments[1], now: 0 })), - [ - { - generation: 1, - catId: 'opus', - outcome: 'failed', - evidenceRef: 'queue:multi-mention:req-1:opus:failed', - now: 0, - }, - { - generation: 1, - catId: 'codex', - outcome: 'failed', - evidenceRef: 'queue:multi-mention:req-1:codex:failed', - now: 0, - }, - ], - ); - }); - - it('preserves a canceled tombstone when zero-success peers otherwise fail', async () => { - const store = { - preflight: mock.fn(async () => ({ ok: true, reason: 'active' })), - preflightOutput: mock.fn(), - commitOutcome: mock.fn(async () => ({ outcome: 'recorded', lease: { status: 'replaceable' } })), - }; - const deps = depsWithStore(store, { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { - type: 'error', - catId: 'codex', - errorCode: 'provider_failed', - errorDisposition: 'terminal', - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }); - deps.invocationTracker.resolveFinalStatus = mock.fn(() => 'succeeded'); - deps.invocationTracker.getSlotState = mock.fn((_threadId, catId) => (catId === 'opus' ? 'canceled' : 'absent')); - const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { targetCats: ['opus', 'codex'] }); - - const result = await processor.executeEntry(entry); - - assert.equal(result.status, 'canceled'); - assert.deepEqual( - store.commitOutcome.mock.calls.map((call) => ({ ...call.arguments[1], now: 0 })), - [ - { - generation: 1, - catId: 'opus', - outcome: 'canceled', - evidenceRef: 'queue:multi-mention:req-1:opus:canceled', - now: 0, - }, - { - generation: 1, - catId: 'codex', - outcome: 'failed', - evidenceRef: 'queue:multi-mention:req-1:codex:failed', - now: 0, - }, - ], - ); - }); - - it('finalizes a terminally failed holder when a parallel peer succeeds', async () => { - const store = { - preflight: mock.fn(async () => ({ ok: true, reason: 'active' })), - preflightOutput: mock.fn(async () => ({ ok: true, reason: 'active' })), - commitOutcome: mock.fn(async () => ({ outcome: 'recorded', lease: { status: 'completed' } })), - }; - const deps = depsWithStore(store, { - routeExecution: mock.fn(async function* (...args) { - const options = args[6]; - assert.equal(await options.beforeOutputCommit('opus'), true); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { - type: 'error', - catId: 'codex', - errorCode: 'provider_failed', - errorDisposition: 'terminal', - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }); - deps.invocationTracker.resolveFinalStatus = mock.fn(() => 'succeeded'); - deps.invocationTracker.getSlotState = mock.fn(() => 'absent'); - const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { targetCats: ['opus', 'codex'] }); - - const result = await processor.executeEntry(entry); - - assert.equal(result.status, 'succeeded'); - assert.equal(store.commitOutcome.mock.calls.length, 1); - assert.deepEqual( - { ...store.commitOutcome.mock.calls[0].arguments[1], now: 0 }, - { - generation: 1, - catId: 'codex', - outcome: 'failed', - evidenceRef: 'queue:multi-mention:req-1:codex:failed', - now: 0, - }, - ); - }); - - it('finalizes a singly canceled holder when a parallel peer succeeds', async () => { - const store = { - preflight: mock.fn(async () => ({ ok: true, reason: 'active' })), - preflightOutput: mock.fn(async () => ({ ok: true, reason: 'active' })), - commitOutcome: mock.fn(async () => ({ outcome: 'recorded', lease: { status: 'completed' } })), - }; - const deps = depsWithStore(store, { - routeExecution: mock.fn(async function* (...args) { - const options = args[6]; - assert.equal(await options.beforeOutputCommit('opus'), true); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }); - deps.invocationTracker.resolveFinalStatus = mock.fn(() => 'succeeded'); - deps.invocationTracker.getSlotState = mock.fn((_threadId, catId) => (catId === 'codex' ? 'canceled' : 'absent')); - const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps, { targetCats: ['opus', 'codex'] }); - - const result = await processor.executeEntry(entry); - - assert.equal(result.status, 'succeeded'); - assert.equal(store.commitOutcome.mock.calls.length, 1); - assert.deepEqual( - { ...store.commitOutcome.mock.calls[0].arguments[1], now: 0 }, - { - generation: 1, - catId: 'codex', - outcome: 'canceled', - evidenceRef: 'queue:multi-mention:req-1:codex:canceled', - now: 0, - }, - ); - }); - it('still records provider failure as a runtime terminal outcome', async () => { const store = { preflight: mock.fn(async () => ({ ok: true, reason: 'active' })), @@ -744,13 +546,14 @@ describe('QueueProcessor action successor generation fence', () => { commitOutcome: mock.fn(async () => ({ outcome: 'recorded', lease: { status: 'replaceable' } })), }; const deps = depsWithStore(store, { + // biome-ignore lint/correctness/useYield: an immediate throw is still an async iterable failure. routeExecution: mock.fn(async function* () { throw new Error('provider failed'); }), ackCollectedCursors: mock.fn(async () => {}), }); const processor = new QueueProcessor(deps); - const entry = enqueueActionEntry(deps); + const entry = await enqueueActionEntry(deps); const result = await processor.executeEntry(entry); diff --git a/packages/api/test/agent-router.test.js b/packages/api/test/agent-router.test.js index 2ba1b35cfa..f9e3d52eef 100644 --- a/packages/api/test/agent-router.test.js +++ b/packages/api/test/agent-router.test.js @@ -13,6 +13,9 @@ import { resolve } from 'node:path'; import { after, describe, mock, test } from 'node:test'; import { migrateRouterOpts } from './helpers/agent-registry-helpers.js'; +const previousDefaultCatId = process.env.DEFAULT_CAT_ID; +process.env.DEFAULT_CAT_ID = 'opus'; + // Create mock dependencies for AgentRouter function createMockRegistry() { let counter = 0; @@ -331,12 +334,152 @@ async function withAvailabilityConfig(availabilityOverrides, fn) { } after(() => { + if (previousDefaultCatId === undefined) delete process.env.DEFAULT_CAT_ID; + else process.env.DEFAULT_CAT_ID = previousDefaultCatId; for (const projectRoot of tempProjectRoots) { rmSync(projectRoot, { recursive: true, force: true }); } }); describe('AgentRouter', () => { + test('routeExecution preserves lifecycle and active-run callbacks through the strategy boundary', async () => { + const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const lifecycleMessageStore = new MessageStore(); + const lifecycleStarted = mock.fn(async (input) => { + const response = await lifecycleMessageStore.append({ + from: { kind: 'agent', catId: input.catId }, + userId: input.userId, + content: '', + mentions: [], + origin: 'stream', + timestamp: input.startedAt, + threadId: input.threadId, + lifecycle: { + kind: 'response', + orderKey: `${input.startedAt}:${input.invocationId}`, + invocationId: input.invocationId, + targetId: input.catId, + inputEntryIds: ['entry-1'], + inputMessageIds: ['source-1'], + status: 'processing', + startedAt: input.startedAt, + }, + }); + return { + responseMessageId: response.id, + priorFrontierMessageId: null, + activeRun: { + threadId: input.threadId, + targetId: input.catId, + invocationId: input.invocationId, + responseMessageId: response.id, + inputEntryIds: ['entry-1'], + inputMessageIds: ['source-1'], + privateInputEntryIds: [], + startedAt: input.startedAt, + }, + }; + }); + const activeRunReady = mock.fn(); + const opusService = { + invoke: mock.fn(async function* (_prompt, options) { + options.activeRunDispatch?.register({ + invocationId: options.invocationId, + capabilities: { append: true, steer: true }, + handle: { threadId: 'callback-thread', turnId: 'provider-turn-1' }, + dispatch: async () => ({ + accepted: true, + handle: { threadId: 'callback-thread', turnId: 'provider-turn-1' }, + }), + }); + yield { type: 'done', catId: 'opus', invocationId: options.invocationId, timestamp: Date.now() }; + }), + }; + const router = new AgentRouter( + await migrateRouterOpts({ + claudeService: opusService, + codexService: createMockAgentService('codex'), + geminiService: createMockAgentService('gemini'), + registry: createMockRegistry(), + messageStore: lifecycleMessageStore, + threadStore: createMockThreadStore({ 'callback-thread': ['opus'] }), + }), + ); + + const output = []; + for await (const message of router.routeExecution( + 'user-1', + 'source body', + 'callback-thread', + 'source-1', + ['opus'], + { intent: 'execute', promptTags: [] }, + { + ownerAuthProvenance: 'strict', + humanDispositionInvocationOrigin: 'direct_owner', + parentInvocationId: 'parent-1', + onPromptMessagesExposed: async () => [], + onLifecycleInvocationStarted: lifecycleStarted, + onAgentClientActiveRunReady: activeRunReady, + }, + )) { + output.push(message); + } + + assert.equal(lifecycleStarted.mock.calls.length, 1); + assert.equal(activeRunReady.mock.calls.length, 1); + assert.equal(activeRunReady.mock.calls[0].arguments[0].catId, 'opus'); + assert.equal( + output.some((message) => message.lifecycleResponseMessageId), + true, + ); + }); + + test('frustration pagination advances in the thread timeline coordinate', async () => { + const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); + const beforeCalls = []; + const messageStore = { + ...createMockMessageStore(), + getByThread: async () => [ + { + id: 'source-1', + threadId: 'cursor-thread', + userId: 'user-1', + from: { kind: 'user', userId: 'user-1' }, + catId: null, + content: 'one user message', + mentions: [], + timestamp: 100, + deliveredAt: 900, + timelineOrderAt: 100, + }, + ], + getByThreadBefore: async (...args) => { + beforeCalls.push(args); + return []; + }, + }; + const router = new AgentRouter( + await migrateRouterOpts({ + claudeService: createMockAgentService('opus'), + codexService: createMockAgentService('codex'), + geminiService: createMockAgentService('gemini'), + registry: createMockRegistry(), + messageStore, + }), + ); + + await router.collectAndDetectTextFrustration('cursor-thread', (messages) => ({ + matched: false, + matchedKeywords: [], + matchCount: messages.length, + })); + + assert.equal(beforeCalls.length, 1); + assert.deepEqual(beforeCalls[0].slice(0, 2), ['cursor-thread', 100]); + }); + test('routingPolicy(review) avoids opus when default routing would pick opus', async () => { const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); @@ -1983,7 +2126,9 @@ describe('AgentRouter', () => { // User message + 2 cat responses = 3 appends assert.equal(appendedMessages.length, 3); - const appendedCatIds = appendedMessages.map((m) => m.catId).filter(Boolean); + const appendedCatIds = appendedMessages + .filter((message) => message.from?.kind === 'agent') + .map((message) => message.from.catId); assert.ok(appendedCatIds.includes('opus'), 'Opus response should be stored'); assert.ok(appendedCatIds.includes('codex'), 'Codex response should be stored'); }); @@ -2224,6 +2369,56 @@ describe('AgentRouter', () => { // ── F078: Smart Routing & Group Mentions ───────────────────────────── describe('F078: Default to last replier', () => { + test('canonical no-mention fallback binds the latest completed lifecycle responder before Queue admission', async () => { + const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); + + const messageStore = createMockMessageStore(); + messageStore.append({ + from: { kind: 'cat', catId: 'codex' }, + userId: 'user-1', + content: 'completed reply', + mentions: [], + timestamp: 1, + threadId: 't1', + lifecycle: { + kind: 'response', + status: 'completed', + sourceMessageId: 'source-1', + targetId: 'codex', + invocationId: 'invocation-1', + }, + }); + messageStore.append({ + from: { kind: 'cat', catId: 'gemini' }, + userId: 'user-1', + content: 'newer but still processing', + mentions: [], + timestamp: 2, + threadId: 't1', + lifecycle: { + kind: 'response', + status: 'processing', + sourceMessageId: 'source-2', + targetId: 'gemini', + invocationId: 'invocation-2', + }, + }); + const router = new AgentRouter( + await migrateRouterOpts({ + claudeService: createMockAgentService('opus'), + codexService: createMockAgentService('codex'), + geminiService: createMockAgentService('gemini'), + registry: createMockRegistry(), + messageStore, + }), + ); + + assert.deepStrictEqual(await router.resolveConversationTargetsAtAdmission([], 't1'), ['codex']); + assert.deepStrictEqual(await router.resolveConversationTargetsAtAdmission([], 'thread-without-completed-replies'), [ + 'opus', + ]); + }); + test('no @mention routes to most recent replier only (not all participants)', async () => { const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); @@ -2628,6 +2823,24 @@ describe('F078: Group mentions', () => { assert.ok(!targetCats.includes('gemini'), '@allison should not broadcast to all cats'); }); + test('does not turn ordinary Chinese prose after a stray inline @ into one giant missing handle', async () => { + const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); + const router = new AgentRouter( + await migrateRouterOpts({ + claudeService: createMockAgentService('opus'), + codexService: createMockAgentService('codex'), + geminiService: createMockAgentService('gemini'), + registry: createMockRegistry(), + messageStore: createMockMessageStore(), + }), + ); + + const result = await router.resolveTargetsAndIntent('你@一下缅因猫和狸花猫打个招呼我看看'); + + assert.deepEqual(result.routing_warnings, []); + assert.deepEqual(result.targetCats, ['opus'], 'ordinary prose keeps normal fallback routing'); + }); + test('@threadsafe does NOT trigger @thread (token boundary)', async () => { const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); diff --git a/packages/api/test/approval-hub/approval-ingress-append-recovery.test.js b/packages/api/test/approval-hub/approval-ingress-append-recovery.test.js index c4da2effdb..662f58c059 100644 --- a/packages/api/test/approval-hub/approval-ingress-append-recovery.test.js +++ b/packages/api/test/approval-hub/approval-ingress-append-recovery.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { ApprovalCardCommittedError, ApprovalIngress } from '../../dist/domains/approval-hub/ApprovalIngress.js'; import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const ownerUserId = 'user-1'; const requesterCatId = 'codex-sol'; @@ -31,14 +32,16 @@ class FakePublicationStore { function makeHarness() { const messageStore = new MessageStore(); - const origin = messageStore.append({ - userId: ownerUserId, - catId: null, - content: 'origin', - mentions: [], - timestamp: 1_721_111_110_000, - threadId: originRef.threadId, - }); + const origin = messageStore.append( + canonicalTestMessageInput({ + userId: ownerUserId, + catId: null, + content: 'origin', + mentions: [], + timestamp: 1_721_111_110_000, + threadId: originRef.threadId, + }), + ); origin.id = originRef.messageId; const broadcasts = []; const userEvents = []; diff --git a/packages/api/test/approval-hub/approval-ingress-fixtures.js b/packages/api/test/approval-hub/approval-ingress-fixtures.js index 55ae30c589..487e81ead9 100644 --- a/packages/api/test/approval-hub/approval-ingress-fixtures.js +++ b/packages/api/test/approval-hub/approval-ingress-fixtures.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { ApprovalIngress } from '../../dist/domains/approval-hub/ApprovalIngress.js'; import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; export const ownerUserId = 'user-1'; const requesterCatId = 'codex-sol'; @@ -37,15 +38,17 @@ export class FakePublicationStore { } function appendOrigin(messageStore, authorOverrides = {}) { - messageStore.append({ - userId: ownerUserId, - catId: null, - content: 'please propose this', - mentions: [], - timestamp: 1_721_111_110_000, - threadId: originRef.threadId, - ...authorOverrides, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: ownerUserId, + catId: null, + content: 'please propose this', + mentions: [], + timestamp: 1_721_111_110_000, + threadId: originRef.threadId, + ...authorOverrides, + }), + ); const stored = messageStore.getByThread(originRef.threadId, 1)[0]; stored.id = originRef.messageId; return stored; diff --git a/packages/api/test/approval-hub/approval-ingress.test.js b/packages/api/test/approval-hub/approval-ingress.test.js index 38f60a754e..40991e34f3 100644 --- a/packages/api/test/approval-hub/approval-ingress.test.js +++ b/packages/api/test/approval-hub/approval-ingress.test.js @@ -4,6 +4,7 @@ import { ApprovalPublicationNotAnchoredError, requireAnchoredPublication, } from '../../dist/domains/approval-hub/requireAnchoredPublication.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; import { FakePublicationStore, findApprovalCard, @@ -208,14 +209,16 @@ describe('ApprovalIngress', () => { /origin message thread mismatch/, ); - const otherOwnerOrigin = harness.messageStore.append({ - userId: 'user-2', - catId: null, - content: 'private', - mentions: [], - timestamp: 1_721_111_110_001, - threadId: 'source-thread', - }); + const otherOwnerOrigin = harness.messageStore.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + content: 'private', + mentions: [], + timestamp: 1_721_111_110_001, + threadId: 'source-thread', + }), + ); await assert.rejects( () => harness.ingress.publish( diff --git a/packages/api/test/approval-hub/dispatch-action-recovery.test.js b/packages/api/test/approval-hub/dispatch-action-recovery.test.js index 216c4b535a..f4104ae856 100644 --- a/packages/api/test/approval-hub/dispatch-action-recovery.test.js +++ b/packages/api/test/approval-hub/dispatch-action-recovery.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const HEAD_SHA = 'a'.repeat(40); @@ -308,21 +309,24 @@ test('persisted proposal receipt is revalidated idempotently after lease deliver }); test('stable approved-carrier retry produces one fenced queue dispatch', async () => { - const [{ InvocationQueue }, { enqueueA2ATargets }] = await Promise.all([ + const [{ InvocationQueue }, { MessageStore }, { enqueueA2ATargets }] = await Promise.all([ import('../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), + import('../../dist/domains/cats/services/stores/ports/MessageStore.js'), import('../../dist/routes/callback-a2a-trigger.js'), ]); const invocationQueue = new InvocationQueue(); - const triggerMessage = { - id: 'msg-action-1', - threadId: 'thread-target', - userId: 'user-1', - catId: 'codex-sol', - content: 'Review exact HEAD.', - mentions: ['codex-terra'], - origin: 'callback', - timestamp: 2_000, - }; + const messageStore = new MessageStore(); + const triggerMessage = messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-target', + userId: 'user-1', + catId: 'codex-sol', + content: 'Review exact HEAD.', + mentions: ['codex-terra'], + origin: 'callback', + timestamp: 2_000, + }), + ); const fence = { leaseId: lease.leaseId, generation: lease.generation, @@ -338,9 +342,9 @@ test('stable approved-carrier retry produces one fenced queue dispatch', async ( broadcastToRoom() {}, emitToUser() {}, }, - queueProcessor: { async tryAutoExecute() {} }, + queueProcessor: { async requestDrain() {} }, invocationQueue, - messageStore: {}, + messageStore, log: { info() {}, warn() {}, error() {} }, }; const input = { @@ -358,12 +362,12 @@ test('stable approved-carrier retry produces one fenced queue dispatch', async ( const retry = await enqueueA2ATargets(deps, input); assert.deepEqual(first.enqueued, ['codex-terra']); - assert.deepEqual(retry.enqueued, ['codex-terra']); + assert.deepEqual(retry.enqueued, []); + assert.deepEqual(retry.coalesced, ['codex-terra']); const queued = invocationQueue.list('thread-target', 'user-1'); assert.equal(queued.length, 1); - assert.equal(queued[0].idempotencyKey, `action:${lease.leaseId}:${lease.generation}:codex-terra`); - assert.equal(queued[0].ownerAuthProvenance, 'unknown'); - assert.deepEqual(queued[0].actionSuccessorFence, fence); + assert.equal(queued[0].execution.ownerAuthProvenance, 'unknown'); + assert.deepEqual(queued[0].execution.actionSuccessorFence, fence); }); test('recovery adopts the exact legacy-visible carrier as one queued custody source without user-message rescue', async () => { @@ -374,24 +378,26 @@ test('recovery adopts the exact legacy-visible carrier as one queued custody sou ]); const invocationQueue = new InvocationQueue(); const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - idempotencyKey: `dispatch-action:${proposal.proposalId}:message`, - threadId: proposal.targetThreadId, - userId: proposal.ownerUserId, - catId: proposal.senderCatId, - content: proposal.content, - mentions: proposal.targetCats, - origin: 'callback', - timestamp: 2_000, - extra: { - isExplicitPost: true, - crossPost: { - sourceThreadId: proposal.sourceThreadId, - effectClass: 'assign_work', + const triggerMessage = messageStore.append( + canonicalTestMessageInput({ + idempotencyKey: `dispatch-action:${proposal.proposalId}:message`, + threadId: proposal.targetThreadId, + userId: proposal.ownerUserId, + catId: proposal.senderCatId, + content: proposal.content, + mentions: proposal.targetCats, + origin: 'callback', + timestamp: 2_000, + extra: { + isExplicitPost: true, + crossPost: { + sourceThreadId: proposal.sourceThreadId, + effectClass: 'assign_work', + }, + targetCats: proposal.targetCats, }, - targetCats: proposal.targetCats, - }, - }); + }), + ); assert.equal(triggerMessage.deliveryStatus, undefined, 'reproduce the persisted visible half-carrier'); let autoExecuteCalls = 0; @@ -405,7 +411,7 @@ test('recovery adopts the exact legacy-visible carrier as one queued custody sou emitToUser() {}, }, queueProcessor: { - async tryAutoExecute() { + async requestDrain() { autoExecuteCalls += 1; }, }, @@ -435,38 +441,50 @@ test('recovery adopts the exact legacy-visible carrier as one queued custody sou const queued = invocationQueue.list(proposal.targetThreadId, proposal.ownerUserId); assert.equal(queued.length, 1); const recovered = messageStore.getById(triggerMessage.id); - assert.equal(recovered.deliveryStatus, 'queued'); - assert.equal(recovered.queueCustody.status, 'queued'); - assert.equal(recovered.queueCustody.receiptScope, 'cross_thread_delivery'); - assert.equal(recovered.queueCustody.carrierByTargetCatId['codex-terra'].entryId, queued[0].id); + assert.equal(recovered.deliveryStatus, undefined, 'public Agent speech stays published while custody is queued'); + assert.equal(recovered.lifecycle.kind, 'input', 'existing-source admission initializes History lifecycle identity'); + assert.deepEqual(recovered.lifecycle.dispatchRefs, []); + assert.equal(recovered.queueCustody, undefined, 'History must not mirror Queue ledger state'); + assert.equal(queued[0].payload.messageId, recovered.id); + assert.deepEqual(queued[0].execution.actionSuccessorFence, { + leaseId: lease.leaseId, + generation: lease.generation, + dispatchId: lease.dispatchId, + terminalPredicateDigest: lease.terminalPredicate.digest, + invocationLineageRef: `dispatch:${lease.dispatchId}`, + }); assert.equal(autoExecuteCalls, 1); }); test('identical recovery races and a process restart converge on the same durable Queue carrier', async () => { - const [{ InvocationQueue }, { MessageStore }, { enqueueA2ATargets }] = await Promise.all([ - import('../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), - import('../../dist/domains/cats/services/stores/ports/MessageStore.js'), - import('../../dist/routes/callback-a2a-trigger.js'), - ]); + const [{ InvocationQueue }, { InMemoryQueueLedgerStore }, { MessageStore }, { enqueueA2ATargets }] = + await Promise.all([ + import('../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), + import('../../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js'), + import('../../dist/domains/cats/services/stores/ports/MessageStore.js'), + import('../../dist/routes/callback-a2a-trigger.js'), + ]); const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - idempotencyKey: `dispatch-action:${proposal.proposalId}:message`, - threadId: proposal.targetThreadId, - userId: proposal.ownerUserId, - catId: proposal.senderCatId, - content: proposal.content, - mentions: proposal.targetCats, - origin: 'callback', - timestamp: 2_000, - extra: { - isExplicitPost: true, - crossPost: { - sourceThreadId: proposal.sourceThreadId, - effectClass: 'assign_work', + const triggerMessage = messageStore.append( + canonicalTestMessageInput({ + idempotencyKey: `dispatch-action:${proposal.proposalId}:message`, + threadId: proposal.targetThreadId, + userId: proposal.ownerUserId, + catId: proposal.senderCatId, + content: proposal.content, + mentions: proposal.targetCats, + origin: 'callback', + timestamp: 2_000, + extra: { + isExplicitPost: true, + crossPost: { + sourceThreadId: proposal.sourceThreadId, + effectClass: 'assign_work', + }, + targetCats: proposal.targetCats, }, - targetCats: proposal.targetCats, - }, - }); + }), + ); const fence = { leaseId: lease.leaseId, generation: lease.generation, @@ -492,35 +510,36 @@ test('identical recovery races and a process restart converge on the same durabl broadcastToRoom() {}, emitToUser() {}, }, - queueProcessor: { async tryAutoExecute() {} }, + queueProcessor: { async requestDrain() {} }, invocationQueue, messageStore, log: { info() {}, warn() {}, error() {} }, }); - const firstProcessQueue = new InvocationQueue(); + const ledger = new InMemoryQueueLedgerStore(); + const firstProcessQueue = new InvocationQueue(ledger); const raced = await Promise.all([ enqueueA2ATargets(depsFor(firstProcessQueue), input), enqueueA2ATargets(depsFor(firstProcessQueue), input), ]); assert.deepEqual( - raced.map((result) => result.enqueued), - [['codex-terra'], ['codex-terra']], + raced.flatMap((result) => [...result.enqueued, ...(result.coalesced ?? [])]), + ['codex-terra', 'codex-terra'], ); const firstEntries = firstProcessQueue.list(proposal.targetThreadId, proposal.ownerUserId); assert.equal(firstEntries.length, 1, 'recovery race must not create a second Queue entry'); - const admitted = messageStore.getById(triggerMessage.id); - const durableEntryId = admitted.queueCustody.carrierByTargetCatId['codex-terra'].entryId; + const durableEntryId = firstEntries[0].id; assert.equal(durableEntryId, firstEntries[0].id); - const restartedQueue = new InvocationQueue(); + const restartedQueue = new InvocationQueue(ledger); + await restartedQueue.hydrateFromLedger(messageStore); const restarted = await enqueueA2ATargets(depsFor(restartedQueue), input); - assert.deepEqual(restarted.enqueued, ['codex-terra']); + assert.deepEqual(restarted.coalesced, ['codex-terra']); const restoredEntries = restartedQueue.list(proposal.targetThreadId, proposal.ownerUserId); assert.equal(restoredEntries.length, 1, 'restart must restore, not mint, the durable Queue carrier'); assert.equal(restoredEntries[0].id, durableEntryId); - assert.equal(restoredEntries[0].idempotencyKey, `action:${lease.leaseId}:${lease.generation}:codex-terra`); + assert.deepEqual(restoredEntries[0].execution.actionSuccessorFence, fence); await enqueueA2ATargets(depsFor(restartedQueue), input); assert.equal( @@ -530,7 +549,7 @@ test('identical recovery races and a process restart converge on the same durabl ); }); -test('approved carrier classification fails closed on conflicting source or custody identity', async () => { +test('approved carrier classification fails closed on conflicting source or ledger identity', async () => { const { classifyApprovedActionCarrier } = await import( '../../dist/domains/ball-custody/ActionSuccessorRecoverySweep.js' ); @@ -538,7 +557,7 @@ test('approved carrier classification fails closed on conflicting source or cust id: 'msg-action-classification', threadId: proposal.targetThreadId, userId: proposal.ownerUserId, - catId: proposal.senderCatId, + from: { kind: 'agent', catId: proposal.senderCatId }, content: proposal.content, mentions: proposal.targetCats, origin: 'callback', @@ -553,47 +572,48 @@ test('approved carrier classification fails closed on conflicting source or cust }, }; - assert.deepEqual(classifyApprovedActionCarrier(proposal, carrier), { outcome: 'repairable' }); - assert.deepEqual(classifyApprovedActionCarrier(proposal, { ...carrier, content: 'conflicting replay' }), { + const fence = { + leaseId: lease.leaseId, + generation: lease.generation, + dispatchId: lease.dispatchId, + terminalPredicateDigest: lease.terminalPredicate.digest, + invocationLineageRef: `dispatch:${lease.dispatchId}`, + }; + assert.deepEqual(classifyApprovedActionCarrier(proposal, carrier, [], fence), { outcome: 'repairable' }); + assert.deepEqual(classifyApprovedActionCarrier(proposal, { ...carrier, content: 'conflicting replay' }, [], fence), { outcome: 'conflict', reason: 'carrier_source_conflict', }); - const admitted = { - ...carrier, - deliveryStatus: 'queued', - queueCustody: { - entryId: `cross-thread:${carrier.id}`, + const entry = { + version: 2, + id: 'queue-entry-terra', + threadId: proposal.targetThreadId, + owner: { kind: 'user', userId: proposal.ownerUserId }, + kind: 'message_wake', + from: { kind: 'agent', catId: proposal.senderCatId }, + targets: ['codex-terra'], + payload: { sourceRecordId: carrier.id, messageId: carrier.id, content: proposal.content }, + execution: { intent: 'execute', - ownerUserId: proposal.ownerUserId, - receiptScope: 'cross_thread_delivery', - allTargetCats: proposal.targetCats, - carrierByTargetCatId: { - 'codex-terra': { - entryId: 'queue-entry-terra', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: proposal.senderCatId, - a2aTriggerMessageId: carrier.id, - autoExecute: true, - }, - }, + ownerAuthProvenance: 'strict', + autoExecute: true, + actionSuccessorFence: fence, }, + delivery: {}, + status: 'queued', + enqueuedAt: 2_000, + priority: 'normal', + sourceCategory: 'a2a', }; - assert.deepEqual(classifyApprovedActionCarrier(proposal, admitted), { outcome: 'admitted' }); + assert.deepEqual(classifyApprovedActionCarrier(proposal, carrier, [entry], fence), { outcome: 'admitted' }); assert.deepEqual( - classifyApprovedActionCarrier(proposal, { - ...admitted, - queueCustody: { - ...admitted.queueCustody, - carrierByTargetCatId: { - 'codex-terra': { - ...admitted.queueCustody.carrierByTargetCatId['codex-terra'], - a2aTriggerMessageId: 'another-source', - }, - }, - }, - }), + classifyApprovedActionCarrier( + proposal, + carrier, + [{ ...entry, payload: { ...entry.payload, sourceRecordId: 'another-source' } }], + fence, + ), { outcome: 'conflict', reason: 'carrier_receipt_conflict' }, ); }); diff --git a/packages/api/test/approval-hub/dispatch-canonical-admission-gate.test.js b/packages/api/test/approval-hub/dispatch-canonical-admission-gate.test.js index 281d839042..635215dc5f 100644 --- a/packages/api/test/approval-hub/dispatch-canonical-admission-gate.test.js +++ b/packages/api/test/approval-hub/dispatch-canonical-admission-gate.test.js @@ -113,7 +113,7 @@ async function createFixture(t) { dispatchProposalStore, invocationRecordStore, router: createMockRouter(), - queueProcessor: { async tryAutoExecute() {} }, + queueProcessor: { async requestDrain() {} }, socketManager: { broadcastAgentMessage(...args) { broadcasts.push(args); diff --git a/packages/api/test/approval-hub/dispatch-canonical-admission-replay-redis.test.js b/packages/api/test/approval-hub/dispatch-canonical-admission-replay-redis.test.js index f6971f0a9e..c2cf3ce9f5 100644 --- a/packages/api/test/approval-hub/dispatch-canonical-admission-replay-redis.test.js +++ b/packages/api/test/approval-hub/dispatch-canonical-admission-replay-redis.test.js @@ -166,7 +166,7 @@ describe( dispatchProposalStore: proposalStore, invocationRecordStore, router: createRouter(), - queueProcessor: { async tryAutoExecute() {} }, + queueProcessor: { async requestDrain() {} }, socketManager: { broadcastAgentMessage(...args) { broadcasts.push(args); diff --git a/packages/api/test/approval-hub/dispatch-negative-authorization-fence.test.js b/packages/api/test/approval-hub/dispatch-negative-authorization-fence.test.js index aef0e238d7..a2101c82f5 100644 --- a/packages/api/test/approval-hub/dispatch-negative-authorization-fence.test.js +++ b/packages/api/test/approval-hub/dispatch-negative-authorization-fence.test.js @@ -52,12 +52,14 @@ function createMockRouter() { async function createFixture(t, options = {}) { const [ { InvocationRegistry }, + { InvocationQueue }, { MessageStore }, { ThreadStore }, { InMemoryDispatchProposalStore }, { callbacksRoutes }, ] = await Promise.all([ import('../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'), + import('../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), import('../../dist/domains/cats/services/stores/ports/MessageStore.js'), import('../../dist/domains/cats/services/stores/ports/ThreadStore.js'), import('../../dist/domains/approval-hub/stores/ports/IDispatchProposalStore.js'), @@ -65,6 +67,7 @@ async function createFixture(t, options = {}) { ]); const registry = new InvocationRegistry(); + const invocationQueue = new InvocationQueue(); const messageStore = new MessageStore(); const threadStore = new ThreadStore(); const dispatchProposalStore = new InMemoryDispatchProposalStore(); @@ -83,6 +86,8 @@ async function createFixture(t, options = {}) { threadStore, dispatchProposalStore, invocationRecordStore, + invocationQueue, + queueProcessor: { async requestDrain() {} }, router: createMockRouter(), socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, approvalIngress: { async publish() {} }, @@ -111,8 +116,6 @@ async function createFixture(t, options = {}) { }; }, }, - invocationQueue: {}, - queueProcessor: {}, } : {}), }); diff --git a/packages/api/test/approval-hub/scheduler-origin-callback-fixtures.js b/packages/api/test/approval-hub/scheduler-origin-callback-fixtures.js index 3c1300f4d0..e14f647bbe 100644 --- a/packages/api/test/approval-hub/scheduler-origin-callback-fixtures.js +++ b/packages/api/test/approval-hub/scheduler-origin-callback-fixtures.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; export const OWNER = 'user-1'; export const CAT = 'opus'; @@ -11,17 +12,19 @@ export const socket = () => ({ emitToUser() {}, broadcastToRoom() {}, broadcastA * authored by the `scheduler` pseudo-user with a null catId, never by the session owner. */ export async function appendSchedulerWakeRow(messageStore, threadId) { - const row = await messageStore.append({ - userId: 'scheduler', - catId: null, - content: '[hold-ball] the condition you were waiting on expired', - mentions: [], - origin: 'callback', - timestamp: Date.now(), - threadId, - source: { connector: 'scheduler', label: '定时任务', icon: 'scheduler' }, - extra: { scheduler: { hiddenTrigger: true } }, - }); + const row = await messageStore.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: '[hold-ball] the condition you were waiting on expired', + mentions: [], + origin: 'callback', + timestamp: Date.now(), + threadId, + source: { connector: 'scheduler', label: '定时任务', icon: 'scheduler' }, + extra: { scheduler: { hiddenTrigger: true } }, + }), + ); // Fixture self-check. If this ever drifts back to an owner-authored row, every positive // case below keeps passing with the exemption deleted — the file would assert nothing. assert.equal(row.userId, 'scheduler', 'the wake row must be scheduler-authored'); diff --git a/packages/api/test/approval-hub/wave2-dispatch-ingress-failure.test.js b/packages/api/test/approval-hub/wave2-dispatch-ingress-failure.test.js index 5759fa414c..80de044097 100644 --- a/packages/api/test/approval-hub/wave2-dispatch-ingress-failure.test.js +++ b/packages/api/test/approval-hub/wave2-dispatch-ingress-failure.test.js @@ -2,6 +2,7 @@ import '../helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; import { proposedReviewAction } from './helpers.js'; function createSucceedingIngress() { @@ -68,14 +69,16 @@ describe('P1-1/P1-4: F193 dispatch proposal ingress failure', () => { const target = await threadStore.create('user-1', 'Target'); await threadStore.addParticipants(source.id, ['opus']); await threadStore.addParticipants(target.id, ['sonnet']); - const origin = await messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'Dispatch origin', - mentions: [], - timestamp: Date.now(), - threadId: source.id, - }); + const origin = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'Dispatch origin', + mentions: [], + timestamp: Date.now(), + threadId: source.id, + }), + ); const store = new InMemoryDispatchProposalStore(); const socketManager = { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }; diff --git a/packages/api/test/approval-hub/wave2-origin-recovery.test.js b/packages/api/test/approval-hub/wave2-origin-recovery.test.js index 570a237a1f..e11c087102 100644 --- a/packages/api/test/approval-hub/wave2-origin-recovery.test.js +++ b/packages/api/test/approval-hub/wave2-origin-recovery.test.js @@ -2,6 +2,7 @@ import '../helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; import { proposedReviewAction } from './helpers.js'; function createSucceedingIngress() { @@ -81,22 +82,26 @@ describe('staged recovery origin preservation', () => { const target = await threadStore.create('user-1', 'Target'); await threadStore.addParticipants(source.id, ['opus']); await threadStore.addParticipants(target.id, ['sonnet']); - const originA = await messageStore.append({ - userId: 'user-1', - catId: 'opus', - threadId: source.id, - content: 'Origin A', - mentions: [], - timestamp: Date.now() - 2000, - }); - const originB = await messageStore.append({ - userId: 'user-1', - catId: 'opus', - threadId: source.id, - content: 'Origin B', - mentions: [], - timestamp: Date.now() - 1000, - }); + const originA = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + threadId: source.id, + content: 'Origin A', + mentions: [], + timestamp: Date.now() - 2000, + }), + ); + const originB = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + threadId: source.id, + content: 'Origin B', + mentions: [], + timestamp: Date.now() - 1000, + }), + ); const app = Fastify(); apps.push(app); diff --git a/packages/api/test/auto-reply-to-worklist.test.js b/packages/api/test/auto-reply-to-worklist.test.js index 24b2629b7d..ad82e840ff 100644 --- a/packages/api/test/auto-reply-to-worklist.test.js +++ b/packages/api/test/auto-reply-to-worklist.test.js @@ -10,6 +10,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { const messages = []; @@ -60,24 +61,28 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { test('auto-fills replyTo from a2aTriggerMessageId (not user message)', async () => { // 1. User's original message (what InvocationRecordStore.userMessageId points to) - const userMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: '请三只猫讨论', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const userMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '请三只猫讨论', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); // 2. Cat A's message that @mentions Cat B (the actual A2A trigger) - const catAMsg = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '砚砚帮我看看\n@codex', - mentions: ['codex'], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const catAMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '砚砚帮我看看\n@codex', + mentions: ['codex'], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); // 3. InvocationRecordStore has the USER message (top-level invocation) const createResult = invocationRecordStore.create({ @@ -124,23 +129,27 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { }); test('re-mentioned pending cat gets latest triggerMessageId', async () => { - const catAMsg = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '帮我看看\n@sonnet', - mentions: ['sonnet'], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const catAMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '帮我看看\n@sonnet', + mentions: ['sonnet'], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); - const catBMsg = messageStore.append({ - userId: 'user-1', - catId: 'codex', - content: 'sonnet 你也看看\n@sonnet', - mentions: ['sonnet'], - timestamp: Date.now() + 100, - threadId: 'thread-1', - }); + const catBMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'sonnet 你也看看\n@sonnet', + mentions: ['sonnet'], + timestamp: Date.now() + 100, + threadId: 'thread-1', + }), + ); const { invocationId, callbackToken } = await registry.create( 'user-1', diff --git a/packages/api/test/auto-reply-to.test.js b/packages/api/test/auto-reply-to.test.js index 22cb671d8b..83f2ff23a0 100644 --- a/packages/api/test/auto-reply-to.test.js +++ b/packages/api/test/auto-reply-to.test.js @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { const messages = []; @@ -62,14 +63,16 @@ describe('auto-replyTo for A2A invocations', () => { test('auto-fills replyTo from trigger message when cat does not pass replyTo', async () => { // 1. Simulate the trigger message (cat A @mentions cat B) - const triggerMsg = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '请帮忙看一下\n@codex', - mentions: ['codex'], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const triggerMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '请帮忙看一下\n@codex', + mentions: ['codex'], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); // 2. Create InvocationRecordStore record (as enqueueA2ATargets would) const createResult = invocationRecordStore.create({ @@ -130,24 +133,28 @@ describe('auto-replyTo for A2A invocations', () => { test('explicit replyTo takes precedence over auto-fill', async () => { // Trigger message - const triggerMsg = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '请看一下\n@codex', - mentions: ['codex'], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const triggerMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '请看一下\n@codex', + mentions: ['codex'], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); // A different message the cat wants to reply to explicitly - const otherMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: '用户的另一条消息', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const otherMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '用户的另一条消息', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); // InvocationRecordStore setup const createResult = invocationRecordStore.create({ @@ -218,14 +225,16 @@ describe('auto-replyTo for A2A invocations', () => { test('P3-2: no auto-fill when parentInvocationRecord threadId mismatches', async () => { // Trigger message exists in thread-1 - const triggerMsg = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '请看\n@codex', - mentions: ['codex'], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const triggerMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '请看\n@codex', + mentions: ['codex'], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); // InvocationRecord created for thread-1 const createResult = invocationRecordStore.create({ @@ -270,14 +279,16 @@ describe('auto-replyTo for A2A invocations', () => { test('no auto-fill when trigger message is in different thread', async () => { // Trigger message in thread-1 - const triggerMsg = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '请看\n@codex', - mentions: ['codex'], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const triggerMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '请看\n@codex', + mentions: ['codex'], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); const createResult = invocationRecordStore.create({ threadId: 'thread-1', diff --git a/packages/api/test/b1c-3-route-plumbing.test.js b/packages/api/test/b1c-3-route-plumbing.test.js index 6f4e3d6193..43ec6146d8 100644 --- a/packages/api/test/b1c-3-route-plumbing.test.js +++ b/packages/api/test/b1c-3-route-plumbing.test.js @@ -78,12 +78,16 @@ describe('F247 AC-B1c-3 PR-C: AgentRouter.getStrategyDeps() plumbing', () => { assert.equal(deps.invocationDeps.cloudInvokeBridge, undefined); }); - it('passes the exact A2A disposition producer only when provided', () => { - const dispositionService = { complete: async () => ({ outcome: 'applied' }) }; - const wired = makeMinimalAgentRouter({ a2aDispatchDispositionService: dispositionService }).getStrategyDeps(); - const absent = makeMinimalAgentRouter().getStrategyDeps(); - assert.strictEqual(wired.invocationDeps.a2aDispatchDispositionService, dispositionService); - assert.equal(absent.invocationDeps.a2aDispatchDispositionService, undefined); + it('keeps the public API port fallback when API_SERVER_PORT is absent', () => { + const previous = process.env.API_SERVER_PORT; + delete process.env.API_SERVER_PORT; + try { + const deps = makeMinimalAgentRouter().getStrategyDeps(); + assert.equal(deps.invocationDeps.apiUrl, 'http://127.0.0.1:3004'); + } finally { + if (previous === undefined) delete process.env.API_SERVER_PORT; + else process.env.API_SERVER_PORT = previous; + } }); }); diff --git a/packages/api/test/ball-custody-hold-events.test.js b/packages/api/test/ball-custody-hold-events.test.js index 08d97b9fb0..aac0a7bfcb 100644 --- a/packages/api/test/ball-custody-hold-events.test.js +++ b/packages/api/test/ball-custody-hold-events.test.js @@ -1,18 +1,18 @@ /** - * F233 PR3 — hold_ball source events. + * F117 Phase G — managed hold no longer mutates ordinary Ball custody. * - * Existing hold_ball scheduling tests are already large; this file only pins - * ball-custody side effects at the two true source points: - * - successful callback commit -> ball.held - * - reminder fire -> ball.hold_expired + * Action-successor custody keeps its explicit Ball event log, while managed + * hold state is owned by the persistent Message/Task lifecycle. These tests + * pin the two former integration points so the retired parallel projection + * cannot be reintroduced accidentally. */ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import Fastify from 'fastify'; -describe('F233 PR3: hold_ball ball-custody events', () => { - test('POST /api/callbacks/hold-ball records ball.held only after scheduler commit', async () => { +describe('F117 Phase G: managed hold stays out of ordinary Ball custody', () => { + test('POST /api/callbacks/hold-ball persists lifecycle state without recording ball.held', async () => { const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); const { InvocationRegistry } = await import( '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' @@ -125,11 +125,7 @@ describe('F233 PR3: hold_ball ball-custody events', () => { }); assert.equal(response.statusCode, 200); - assert.equal(events.length, 1); - assert.equal(events[0].kind, 'ball.held'); - assert.equal(events[0].sourceEventId, `hold:${thread.id}:codex:${insertedTasks[0].trigger.fireAt}`); - assert.equal(events[0].subjectKey, `ball:thread:${thread.id}`); - assert.deepEqual(events[0].payload, { catId: 'codex', fireAt: insertedTasks[0].trigger.fireAt }); + assert.deepEqual(events, []); const awaitState = insertedTasks[0].params.holdLifecycle.await; assert.ok(awaitState, 'timer hold must persist the unified wait shape'); assert.deepEqual(insertedTasks[0].params.holdLifecycle, { @@ -167,7 +163,7 @@ describe('F233 PR3: hold_ball ball-custody events', () => { }); }); - test('hold-ball reminder fire records ball.hold_expired at execution point', async () => { + test('hold-ball reminder fire queues its wake without recording ball.hold_expired', async () => { const { reminderTemplate } = await import('../dist/infrastructure/scheduler/templates/reminder.js'); const fireAt = Date.now() - 1; const events = []; @@ -196,10 +192,6 @@ describe('F233 PR3: hold_ball ball-custody events', () => { }); assert.equal(delivered.length, 1); - assert.equal(events.length, 1); - assert.equal(events[0].kind, 'ball.hold_expired'); - assert.equal(events[0].sourceEventId, `holdexp:thr-expired:codex:${fireAt}`); - assert.equal(events[0].subjectKey, 'ball:thread:thr-expired'); - assert.deepEqual(events[0].payload, { catId: 'codex', fireAt }); + assert.deepEqual(events, []); }); }); diff --git a/packages/api/test/ball-custody-ingest-redis.test.js b/packages/api/test/ball-custody-ingest-redis.test.js index 413049f8a4..ed0629ac5a 100644 --- a/packages/api/test/ball-custody-ingest-redis.test.js +++ b/packages/api/test/ball-custody-ingest-redis.test.js @@ -98,155 +98,4 @@ describe('BallCustodyIngest (Redis end-to-end)', { skip: redisIsolationSkipReaso await projector.rebuild(subjectKey); assert.deepStrictEqual(await store.get(subjectKey), before); }); - - it('#1371 completes and replays one dispatch across a real Redis heartbeat CAS conflict', async () => { - const { A2ADispatchDispositionService } = await import( - '../dist/domains/ball-custody/A2ADispatchDispositionService.js' - ); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - const { buildInvocationHeartbeatEvent } = await import('../dist/domains/ball-custody/ball-custody-events.js'); - const threadId = nextThread(); - const invocationId = `${threadId}/invocation`; - const subjectKey = `ball:thread:${threadId}`; - const { eventLog, store, projector, ingest } = makeStack(); - const messageStore = new MessageStore(); - const source = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '@codex-sol review complete', - mentions: ['codex-sol'], - timestamp: 1_000, - threadId, - }); - await ingest.record( - buildHandedEvent({ fromCatId: 'opus', toCatId: 'codex-sol', threadId, messageId: source.id, at: 1_000 }), - ); - let attempts = 0; - const warnings = []; - const service = new A2ADispatchDispositionService({ - registry: { isLatest: async (candidate) => candidate === invocationId }, - messageStore, - ballCustodyEventLog: eventLog, - ballCustodyProjectionStore: store, - log: { warn: (fields) => warnings.push(fields) }, - repairProjection: (key) => projector.rebuild(key), - now: () => 2_000, - ballCustody: { - record: (event) => ingest.record(event), - async recordFenced(event, expectedSequence) { - attempts += 1; - if (attempts === 1) - await ingest.record( - buildInvocationHeartbeatEvent({ threadId, invocationId, catId: 'codex-sol', draftUpdatedAt: 1_500 }), - ); - return ingest.recordFenced(event, expectedSequence); - }, - }, - }); - const auth = { - invocationId, - catId: 'codex-sol', - threadId, - a2aTriggerMessageId: source.id, - originTriggerMessageId: source.id, - }; - - assert.equal((await service.complete(auth, 'handled')).outcome, 'applied'); - assert.equal(attempts, 2); - assert.equal(warnings[0].expectedSequence, 1); - assert.equal(warnings[0].actualSequence, 2); - assert.equal((await new RedisBallCustodyProjectionStore(redis).get(subjectKey)).state, 'resolved'); - assert.equal((await service.complete(auth, 'handled')).outcome, 'replayed'); - const persisted = await new RedisBallCustodyEventLog(redis).read(subjectKey); - assert.equal(persisted.length, 3); - assert.equal(persisted.filter((event) => event.kind === 'ball.dispatch_dispositioned').length, 1); - assert.equal(attempts, 2, 'replay reuses the committed event without another append'); - }); - it('#1371 managed completion revalidates a heartbeat conflict through real Redis CAS', async () => { - const { ManagedHoldDispositionService } = await import( - '../dist/domains/ball-custody/ManagedHoldDispositionService.js' - ); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - const { buildHeldEvent, buildWakeConditionMetEvent, buildInvocationHeartbeatEvent } = await import( - '../dist/domains/ball-custody/ball-custody-events.js' - ); - const threadId = nextThread(); - const taskId = `${threadId}/task`; - const invocationId = `${threadId}/invocation`; - const subjectKey = `ball:thread:${threadId}`; - const { eventLog, store, projector, ingest } = makeStack(); - const messageStore = new MessageStore(); - const source = messageStore.append({ - userId: 'scheduler', - catId: null, - threadId, - content: 'command completed', - mentions: [], - timestamp: 1_150, - deliveryStatus: 'queued', - source: { connector: 'hold-ball', label: 'hold', meta: { taskId, threadId, catId: 'codex-sol', wakeWhen: true } }, - }); - await ingest.record(buildHeldEvent({ threadId, catId: 'codex-sol', fireAt: 5_000, at: 1_000 })); - await ingest.record( - buildWakeConditionMetEvent({ - threadId, - catId: 'codex-sol', - taskId, - command: 'test', - exitCode: 0, - timedOut: false, - durationMs: 100, - at: 1_100, - }), - ); - await ingest.record(buildHandedEvent({ threadId, toCatId: 'codex-sol', messageId: source.id, at: 1_200 })); - let attempts = 0; - const receipts = []; - const service = new ManagedHoldDispositionService({ - registry: { isLatest: async (id) => id === invocationId }, - dynamicTaskStore: { getById: () => null }, // late callback: the exact server-minted source remains authoritative - messageStore, - ballCustodyEventLog: eventLog, - ballCustodyProjectionStore: store, - repairProjection: (key) => projector.rebuild(key), - now: () => 2_000, - receiptService: { - complete: async (receipt) => { - const events = await new RedisBallCustodyEventLog(redis).read(subjectKey); - assert.equal( - events.filter((event) => event.kind === 'ball.hold_dispositioned').length, - 1, - 'receipt is never reached before the real Redis terminal append', - ); - receipts.push(receipt); - }, - }, - ballCustody: { - record: (event) => ingest.record(event), - recordFenced: async (event, expectedSequence) => { - if (++attempts === 1) - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId, - invocationId, - catId: 'codex-sol', - draftUpdatedAt: 1_300, - }), - ); - return ingest.recordFenced(event, expectedSequence); - }, - }, - }); - const auth = { invocationId, userId: 'user-1', catId: 'codex-sol', threadId, originTriggerMessageId: source.id }; - assert.equal((await service.complete(auth, 'handled')).outcome, 'applied'); - assert.equal(attempts, 2); - assert.equal((await new RedisBallCustodyProjectionStore(redis).get(subjectKey)).state, 'resolved'); - assert.equal((await service.complete(auth, 'handled')).outcome, 'replayed'); - assert.equal(attempts, 2); - assert.equal(receipts.length, 2); - assert.ok(receipts.every((receipt) => receipt.sourceMessageId === source.id && receipt.taskId === taskId)); - const events = await new RedisBallCustodyEventLog(redis).read(subjectKey); - assert.equal(events.length, 5); - assert.equal(events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 1); - }); }); diff --git a/packages/api/test/callback-a2a-pingpong.test.js b/packages/api/test/callback-a2a-pingpong.test.js deleted file mode 100644 index 16a8eeaea7..0000000000 --- a/packages/api/test/callback-a2a-pingpong.test.js +++ /dev/null @@ -1,487 +0,0 @@ -/** - * F167 L1 AC-A4: callback-a2a-trigger 也必须消费 streak 熔断。 - * - * 场景:worklist 已有 streak=3(opus↔codex × 3 pushes),第 4 轮通过 callback-a2a 触发 → - * enqueueA2ATargets 必须: - * 1. 不 enqueue(result.enqueued=[]) - * 2. 广播 `a2a_pingpong_terminated` system_info(与 route-serial 路径格式对齐) - * - * 注意:为了走 legacy worklist 分支(pushToWorklist 触发路径), - * test 必须 invocationQueue=undefined + 预先 registerWorklist(模拟 route-serial 正在跑)。 - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { catRegistry } from '@cat-cafe/shared'; - -async function loadRealRoster() { - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - const runtimeConfigs = toAllCatConfigs(loadCatConfig()); - catRegistry.reset(); - for (const [id, config] of Object.entries(runtimeConfigs)) { - catRegistry.register(id, config); - } -} - -function createMockSocketManager() { - const broadcasts = []; - return { - broadcasts, - emitToUser() {}, - broadcastToRoom() {}, - broadcastAgentMessage(msg) { - broadcasts.push(msg); - }, - }; -} - -function createMockLog() { - return { - info() {}, - warn() {}, - error() {}, - }; -} - -describe('F167 L1 AC-A4: callback-a2a-trigger ping-pong circuit breaker', () => { - test('streak=4 via callback (worklist path) → not enqueued + emit a2a_pingpong_terminated', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'thread-cb-pp-block'; - // Simulate route-serial registering worklist at start with opus as the first cat. - const entry = registerWorklist(threadId, ['opus'], 20); - try { - // Preload 3 rounds of ping-pong (opus↔codex) to bring streak to 3. - pushToWorklist(threadId, ['codex'], 'opus'); // streak=1 - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); // streak=2 (warn) - entry.executedIndex = 2; - pushToWorklist(threadId, ['codex'], 'opus'); // streak=3 (warn) - entry.executedIndex = 3; // now codex is current — ready to push opus (round 4) - - const socketManager = createMockSocketManager(); - const result = await enqueueA2ATargets( - { - router: null, - invocationRecordStore: null, - socketManager, - invocationQueue: undefined, // force legacy worklist path - invocationTracker: undefined, - queueProcessor: undefined, - log: createMockLog(), - }, - { - targetCats: ['opus'], - content: '@opus 再确认一下', - userId: 'user1', - threadId, - triggerMessage: { - id: 'msg-cb-4', - userId: 'user1', - catId: 'codex', - content: '@opus 再确认一下', - mentions: ['opus'], - timestamp: Date.now(), - }, - callerCatId: 'codex', - }, - ); - - assert.deepStrictEqual(result.enqueued, [], 'streak=4 via callback must not enqueue'); - assert.strictEqual(result.fallback, false, 'blocked push must not fall through to standalone'); - - const terminated = socketManager.broadcasts.find( - (m) => - m.type === 'system_info' && typeof m.content === 'string' && m.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(terminated, 'callback path must broadcast a2a_pingpong_terminated system_info'); - const parsed = JSON.parse(terminated.content); - assert.strictEqual(parsed.type, 'a2a_pingpong_terminated'); - assert.strictEqual(parsed.fromCatId, 'codex'); - assert.strictEqual(parsed.targetCatId, 'opus'); - assert.ok(parsed.pairCount >= 4, `pairCount must be >=4, got ${parsed.pairCount}`); - } finally { - unregisterWorklist(threadId, entry); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('streak=4 via callback (MODERN invocationQueue path) → not enqueued + emit a2a_pingpong_terminated (P1-1)', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - - const threadId = 'thread-cb-pp-block-modern'; - const entry = registerWorklist(threadId, ['opus'], 20); - try { - // Preload 3 rounds of ping-pong (opus↔codex) on the worklist (route-serial-style). - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); - entry.executedIndex = 2; - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 3; // streak=3, next 1:1 push should trip block threshold - - // Real InvocationQueue wired in — forces MODERN path (not forced undefined). - const invocationQueue = new InvocationQueue(); - - const socketManager = createMockSocketManager(); - const result = await enqueueA2ATargets( - { - router: null, - invocationRecordStore: null, - socketManager, - invocationQueue, - invocationTracker: undefined, - queueProcessor: undefined, - log: createMockLog(), - }, - { - targetCats: ['opus'], - content: '@opus 第四轮', - userId: 'user1', - threadId, - triggerMessage: { - id: 'msg-cb-modern-4', - userId: 'user1', - catId: 'codex', - content: '@opus 第四轮', - mentions: ['opus'], - timestamp: Date.now(), - }, - callerCatId: 'codex', - }, - ); - - assert.deepStrictEqual(result.enqueued, [], 'modern path streak=4 must not enqueue'); - assert.strictEqual(result.fallback, false); - - const terminated = socketManager.broadcasts.find( - (m) => - m.type === 'system_info' && typeof m.content === 'string' && m.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(terminated, 'modern invocationQueue path must broadcast a2a_pingpong_terminated system_info'); - const parsed = JSON.parse(terminated.content); - assert.strictEqual(parsed.type, 'a2a_pingpong_terminated'); - assert.strictEqual(parsed.fromCatId, 'codex'); - assert.strictEqual(parsed.targetCatId, 'opus'); - assert.ok(parsed.pairCount >= 4, `pairCount must be >=4, got ${parsed.pairCount}`); - - // Defense-in-depth: the blocked target must NOT appear in invocationQueue. - const queued = invocationQueue.list(threadId, 'user1'); - const hasBlockedCat = queued.some((e) => e.targetCats?.includes('opus')); - assert.ok(!hasBlockedCat, 'blocked target must not be enqueued into InvocationQueue'); - } finally { - unregisterWorklist(threadId, entry); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('F167-D1: callback (legacy path) with long content (>200) exempts round-4 streak — no termination', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'thread-cb-pp-longcontent-legacy'; - const entry = registerWorklist(threadId, ['opus'], 20); - try { - // Preload 3 rounds of ping-pong on the worklist (route-serial-style). - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); - entry.executedIndex = 2; - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 3; // streak = 3 - - // Round 4 via callback, but content is > 200 chars (real discussion). - const longContent = `${'详细架构分析'.repeat(40)}\n@opus 这段需要你确认`; - const socketManager = createMockSocketManager(); - const result = await enqueueA2ATargets( - { - router: null, - invocationRecordStore: null, - socketManager, - invocationQueue: undefined, - invocationTracker: undefined, - queueProcessor: undefined, - log: createMockLog(), - }, - { - targetCats: ['opus'], - content: longContent, - userId: 'user1', - threadId, - triggerMessage: { - id: 'msg-cb-long-1', - userId: 'user1', - catId: 'codex', - content: longContent, - mentions: ['opus'], - timestamp: Date.now(), - }, - callerCatId: 'codex', - }, - ); - - assert.deepStrictEqual(result.enqueued, ['opus'], 'long-content round-4 must still enqueue (exempt)'); - const terminated = socketManager.broadcasts.find( - (m) => - m.type === 'system_info' && typeof m.content === 'string' && m.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(!terminated, 'long-content discussion must not trip the breaker on round 4'); - } finally { - unregisterWorklist(threadId, entry); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('F167-D1: callback (modern InvocationQueue path) with long content (>200) exempts round-4 streak', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - - const threadId = 'thread-cb-pp-longcontent-modern'; - const entry = registerWorklist(threadId, ['opus'], 20); - try { - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); - entry.executedIndex = 2; - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 3; // streak = 3 - - const invocationQueue = new InvocationQueue(); - const longContent = `${'详细架构分析'.repeat(40)}\n@opus 这段需要你确认`; - const socketManager = createMockSocketManager(); - const _result = await enqueueA2ATargets( - { - router: null, - invocationRecordStore: null, - socketManager, - invocationQueue, - invocationTracker: undefined, - queueProcessor: undefined, - log: createMockLog(), - }, - { - targetCats: ['opus'], - content: longContent, - userId: 'user1', - threadId, - triggerMessage: { - id: 'msg-cb-long-modern-1', - userId: 'user1', - catId: 'codex', - content: longContent, - mentions: ['opus'], - timestamp: Date.now(), - }, - callerCatId: 'codex', - }, - ); - - const terminated = socketManager.broadcasts.find( - (m) => - m.type === 'system_info' && typeof m.content === 'string' && m.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(!terminated, 'modern path: long-content discussion must not trip breaker on round 4'); - } finally { - unregisterWorklist(threadId, entry); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('P1 (cloud Codex review): modern path — hasQueuedAgentForCat skip must NOT mutate streak even with substantive content', async () => { - // Reward-hack vector cloud Codex flagged: substantive activity (long content) - // triggers streak RESET even when the target is later skipped by - // `hasQueuedAgentForCat` dedup guard. After fix: streak only mutates when - // the target is about to actually enqueue (post-guards). - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist, pushToWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - - const threadId = 'thread-cb-pp-codex-p1-modern'; - const entry = registerWorklist(threadId, ['opus'], 20); - try { - // Preload streak=3 via inertia pushes (worklist path, no activity = legacy accumulation). - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); - entry.executedIndex = 2; - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 3; // streak=3 - - // Preload InvocationQueue with a queued A2A 'opus' entry so the same-turn dedup path fires. - // F-coalesce (云端 codex R4 P1): dedup now coalesces via findInFlightAgentEntry, which is - // SCOPED to sourceCategory==='a2a' (continuation/multi-mention entries are NOT mergeable). - // A real A2A entry always carries sourceCategory:'a2a' (callback-a2a-trigger enqueue), so the - // preload must too — otherwise it models a shape production never emits. - const invocationQueue = new InvocationQueue(); - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId, - userId: 'user1', - content: 'existing', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - // F216 c0 (砚砚 P1): findInFlightAgentEntry now caller-scopes. The trigger below uses - // callerCatId 'codex', so the preload must too — otherwise the in-flight dedup/coalesce - // path never fires and this test's "skip → streak unchanged" premise breaks. Same caller - // = the legit same-turn repeat this test exercises. - callerCatId: 'codex', - }); - assert.ok(invocationQueue.hasQueuedAgentForCat(threadId, 'opus'), 'precondition: opus already queued'); - - const socketManager = createMockSocketManager(); - const longContent = `${'详细架构分析'.repeat(40)}\n@opus 请看`; // >200 chars → substantive - const result = await enqueueA2ATargets( - { - router: null, - invocationRecordStore: null, - socketManager, - invocationQueue, - invocationTracker: undefined, - queueProcessor: undefined, - log: createMockLog(), - }, - { - targetCats: ['opus'], - content: longContent, - userId: 'user1', - threadId, - triggerMessage: { - id: 'msg-cb-codex-p1', - userId: 'user1', - catId: 'codex', - content: longContent, - mentions: ['opus'], - timestamp: Date.now(), - }, - callerCatId: 'codex', - }, - ); - - assert.deepStrictEqual(result.enqueued, [], 'dedup must skip — nothing enqueued'); - // KEY assertion: streak must stay at 3 (no reset by substantive activity) - const currentEntry = getWorklist(threadId); - assert.equal( - currentEntry?.streakPair?.count, - 3, - 'streak must NOT reset to 1 when hasQueuedAgentForCat dedup skipped the enqueue (cloud Codex P1)', - ); - } finally { - unregisterWorklist(threadId, entry); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('streak<4 via callback (normal push) → enqueues + no terminated broadcast', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'thread-cb-pp-ok'; - const entry = registerWorklist(threadId, ['opus'], 20); - try { - const socketManager = createMockSocketManager(); - const result = await enqueueA2ATargets( - { - router: null, - invocationRecordStore: null, - socketManager, - invocationQueue: undefined, - invocationTracker: undefined, - queueProcessor: undefined, - log: createMockLog(), - }, - { - targetCats: ['codex'], - content: '@codex 帮忙看看', - userId: 'user1', - threadId, - triggerMessage: { - id: 'msg-cb-1', - userId: 'user1', - catId: 'opus', - content: '@codex 帮忙看看', - mentions: ['codex'], - timestamp: Date.now(), - }, - callerCatId: 'opus', - }, - ); - - assert.deepStrictEqual(result.enqueued, ['codex'], 'first push must enqueue normally'); - const terminated = socketManager.broadcasts.find( - (m) => - m.type === 'system_info' && typeof m.content === 'string' && m.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(!terminated, 'normal push must not broadcast a2a_pingpong_terminated'); - } finally { - unregisterWorklist(threadId, entry); - } - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); -}); diff --git a/packages/api/test/callback-a2a-postmsg.test.js b/packages/api/test/callback-a2a-postmsg.test.js index d3379d62ec..446e10fac0 100644 --- a/packages/api/test/callback-a2a-postmsg.test.js +++ b/packages/api/test/callback-a2a-postmsg.test.js @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; import Fastify from 'fastify'; +import { adaptInvocationQueue, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { const messages = []; @@ -88,18 +89,28 @@ describe('post_message A2A mention invocation', () => { let socketManager; let invocationRecordStore; let mockRouter; + let invocationQueue; + let queueProcessor; beforeEach(async () => { const { InvocationRegistry } = await import( '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' ); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); registry = new InvocationRegistry(); messageStore = new MessageStore(); socketManager = createMockSocketManager(); invocationRecordStore = createMockInvocationRecordStore(); mockRouter = createMockRouter(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); + queueProcessor = { + async onInvocationComplete() {}, + async requestDrain() {}, + registerEntryCompleteHook() {}, + unregisterEntryCompleteHook() {}, + }; }); async function createApp(opts = {}) { @@ -111,6 +122,8 @@ describe('post_message A2A mention invocation', () => { socketManager, router: mockRouter, invocationRecordStore, + invocationQueue, + queueProcessor, ...opts, }); return app; @@ -162,7 +175,7 @@ describe('post_message A2A mention invocation', () => { // P1-2 regression: @ inside code block → no invocation test('post-message with @ in code block does NOT trigger invocation', async () => { const app = await createApp(); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -181,10 +194,14 @@ describe('post_message A2A mention invocation', () => { ); }); - // Positive case: line-start @ → mentions stored + invocation created - test('post-message with line-start @ stores mentions and triggers invocation', async () => { + // Positive case: line-start @ → mentions stored + canonical Queue carrier created + test('post-message with line-start @ stores mentions and queues A2A', async () => { + let observedAppend; + messageStore.onAppend = (message) => { + observedAppend = structuredClone(message); + }; const app = await createApp(); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -201,93 +218,48 @@ describe('post_message A2A mention invocation', () => { const recent = messageStore.getRecent(10); assert.equal(recent.length, 1); assert.ok(recent[0].mentions.includes('codex'), 'Message should store codex as mention (缅因猫 = codex)'); - - // InvocationRecord should be created - assert.equal(invocationRecordStore.getRecords().length, 1); - assert.deepEqual(invocationRecordStore.getRecords()[0].targetCats, ['codex']); - }); - - test('post-message duplicate retry recovers a queued A2A callback before returning duplicate', async () => { - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const queueProcessor = { - async onInvocationComplete() {}, - async tryAutoExecute() {}, - registerEntryCompleteHook() {}, - unregisterEntryCompleteHook() {}, - }; - const invocationQueue = new InvocationQueue(); - const app = await createApp({ invocationQueue, queueProcessor }); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); - const content = 'same queued callback report needing A2A recovery'; - - const queued = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content, - mentions: ['codex'], - origin: 'callback', - timestamp: Date.now(), - threadId: 't1', - extra: { - isExplicitPost: true, - stream: { - invocationId, - turnInvocationId: invocationId, - }, - }, - deliveryStatus: 'queued', - }); - - const response = await app.inject({ - method: 'POST', - url: '/api/callbacks/post-message', - headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - payload: { content, targetCats: ['codex'] }, - }); - - assert.equal(response.statusCode, 200); - const body = JSON.parse(response.body); - assert.equal(body.status, 'duplicate'); - assert.equal(body.messageId, queued.id); + assert.notEqual(recent[0].deliveryStatus, 'queued', 'agent speech must be public before recipient admission'); + assert.deepEqual(recent[0].from, { kind: 'agent', catId: 'opus' }); + assert.equal(observedAppend.queueCustodyAdmission, undefined); + assert.equal(socketManager.getMessages().length, 1, 'public speech must broadcast immediately'); const entries = invocationQueue.list('t1', 'user-1'); - assert.equal(entries.length, 1, 'duplicate retry should enqueue the recovered A2A target'); - assert.equal(entries[0].messageId, queued.id, 'recovered queue entry should carry the existing message id'); - assert.deepEqual(entries[0].targetCats, ['codex']); - assert.equal(entries[0].autoExecute, true); - assert.equal(messageStore.size, 1); - assert.equal(socketManager.getMessages().length, 0, 'queued recovery should wait for QueueProcessor delivery'); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(invocationRecordStore.getRecords().length, 0); + assert.equal(mockRouter.getExecutions().length, 0); }); - // F-coalesce: use the real Queue and MessageStore because a queued callback now has to establish - // durable custody before tryAutoExecute. A method-shaped Queue fake can prove the response copy, - // but cannot prove that the coalesced trigger remains recoverable on the existing carrier. - test('post-message does not claim routed when InvocationQueue coalesces a duplicate queued target', async () => { + test('post-message preserves separate source identity for an already-queued target', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const tryAutoExecuteCalls = []; + const drainCalls = []; const queueProcessor = { async onInvocationComplete() {}, - async tryAutoExecute(threadId) { - tryAutoExecuteCalls.push(threadId); + async requestDrain(threadId) { + drainCalls.push(threadId); }, registerEntryCompleteHook() {}, unregisterEntryCompleteHook() {}, }; - const invocationQueue = new InvocationQueue(); + const invocationQueue = adaptInvocationQueue(new InvocationQueue()); const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); - const existing = invocationQueue.enqueue({ - threadId: 't1', - userId: 'user-1', - ownerAuthProvenance: 'unknown', - content: 'earlier queued handoff', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - a2aTriggerMessageId: 'earlier-trigger', - }).entry; + const existing = invocationQueue.enqueue( + canonicalTestQueueInput({ + threadId: 't1', + userId: 'user-1', + kind: 'message_wake', + ownerAuthProvenance: 'unknown', + content: 'earlier queued handoff', + messageId: 'earlier-trigger', + source: 'agent', + sourceCategory: 'a2a', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + callerCatId: 'opus', + a2aTriggerMessageId: 'earlier-trigger', + }), + ).entry; const app = await createApp({ invocationQueue, queueProcessor }); const response = await app.inject({ @@ -302,25 +274,22 @@ describe('post_message A2A mention invocation', () => { assert.equal(response.statusCode, 200); const body = JSON.parse(response.body); const entries = invocationQueue.list('t1', 'user-1'); - assert.equal(entries.length, 1, 'coalesce must preserve one Queue carrier'); + assert.equal(entries.length, 2, 'each durable source must own one independent Queue row'); assert.equal(entries[0].id, existing.id); - assert.match(entries[0].content, /earlier queued handoff/); - assert.match(entries[0].content, /修复完成了/); - assert.deepEqual(entries[0].mergedMessageIds, [body.messageId]); - const custody = messageStore.getById(body.messageId).queueCustody; - assert.equal(custody.carrierByTargetCatId.codex.entryId, existing.id); - assert.deepEqual(custody.pendingTargetCats, ['codex']); - assert.deepEqual(body.routed, [], 'Response must expose that no new A2A route was enqueued'); - assert.doesNotMatch(body.message, /消息已路由给 @codex/, 'Coalesced duplicate must not be reported as routed'); - assert.match(body.message, /未新增唤醒|已有待处理队列/); - assert.deepEqual(tryAutoExecuteCalls, ['t1'], 'Existing queued entry should still be nudged for auto-execute'); + assert.match(entries[0].payload.content, /earlier queued handoff/); + assert.doesNotMatch(entries[0].payload.content, /修复完成了/); + assert.equal(entries[1].payload.messageId, body.messageId); + assert.match(entries[1].payload.content, /修复完成了/); + assert.deepEqual(body.routed, ['codex']); + assert.match(body.message, /消息已路由给 @codex/); + assert.deepEqual(drainCalls, ['t1']); assert.equal(invocationRecordStore.getRecords().length, 0, 'InvocationQueue path must not create legacy records'); }); // Content-before-mention regression: 上面写内容,最后一行 @ (缅因猫习惯) - test('post-message with content-before-mention triggers invocation', async () => { + test('post-message with content-before-mention queues A2A', async () => { const app = await createApp(); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -340,9 +309,10 @@ describe('post_message A2A mention invocation', () => { 'Content-before-mention: codex should be mentioned when @缅因猫 is on last line', ); - const records = invocationRecordStore.getRecords(); - const a2aRecord = records.find((r) => r.targetCats.includes('codex')); - assert.ok(a2aRecord, 'Content-before-mention should trigger A2A invocation for codex'); + const entries = invocationQueue.list('t1', 'user-1'); + assert.equal(entries.length, 1, 'Content-before-mention should queue A2A for codex'); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(mockRouter.getExecutions().length, 0); }); test('post-message skips redundant A2A when target already covered by active parent invocation', async () => { @@ -362,7 +332,7 @@ describe('post_message A2A mention invocation', () => { complete() {}, }; const app = await createApp({ invocationTracker: mockInvocationTracker }); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -379,7 +349,7 @@ describe('post_message A2A mention invocation', () => { }); // F108 slot-aware: opus active, @codex in different slot → codex SHOULD be invoked - test('post-message wakes codex when opus is active in different slot (slot-aware fallback)', async () => { + test('post-message queues codex when opus is active in a different slot', async () => { const mockInvocationTracker = { has() { return true; @@ -393,7 +363,7 @@ describe('post_message A2A mention invocation', () => { complete() {}, }; const app = await createApp({ invocationTracker: mockInvocationTracker }); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -405,17 +375,14 @@ describe('post_message A2A mention invocation', () => { }); assert.equal(response.statusCode, 200); - // codex should be invoked even though opus is active - assert.equal(invocationRecordStore.getRecords().length, 1, 'Should create InvocationRecord for codex'); - assert.deepEqual( - invocationRecordStore.getRecords()[0].targetCats, - ['codex'], - 'codex should be invoked (different slot from active opus)', - ); + const entries = invocationQueue.list('t1', 'user-1'); + assert.equal(entries.length, 1, 'Should queue codex in its independent slot'); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(invocationRecordStore.getRecords().length, 0); }); // F108 slot-aware: opus active, explicit targetCats:["codex"] → codex SHOULD be invoked - test('post-message with targetCats wakes codex when opus is active (no worklist)', async () => { + test('post-message with targetCats queues codex when opus is active', async () => { const mockInvocationTracker = { has() { return true; @@ -429,7 +396,7 @@ describe('post_message A2A mention invocation', () => { complete() {}, }; const app = await createApp({ invocationTracker: mockInvocationTracker }); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -442,18 +409,16 @@ describe('post_message A2A mention invocation', () => { }); assert.equal(response.statusCode, 200); - assert.equal( - invocationRecordStore.getRecords().length, - 1, - 'Should create InvocationRecord for codex via targetCats', - ); - assert.deepEqual(invocationRecordStore.getRecords()[0].targetCats, ['codex']); + const entries = invocationQueue.list('t1', 'user-1'); + assert.equal(entries.length, 1, 'Should queue codex via targetCats'); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(invocationRecordStore.getRecords().length, 0); }); // Invalid catId in explicitTargetCats → filtered out, no A2A crash test('post-message with invalid catId in targetCats is filtered gracefully', async () => { const app = await createApp(); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -477,7 +442,7 @@ describe('post_message A2A mention invocation', () => { // Mixed valid + invalid targetCats → only valid ones enter A2A test('post-message with mixed valid/invalid targetCats keeps only valid ones', async () => { const app = await createApp(); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -490,15 +455,15 @@ describe('post_message A2A mention invocation', () => { }); assert.equal(response.statusCode, 200); - // A2A should fire for codex only - const records = invocationRecordStore.getRecords(); - assert.equal(records.length, 1, 'Should create InvocationRecord for valid target'); - assert.deepEqual(records[0].targetCats, ['codex'], 'Only valid catId (codex) should be in targetCats'); + const entries = invocationQueue.list('t1', 'user-1'); + assert.equal(entries.length, 1, 'Should queue only the valid target'); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(invocationRecordStore.getRecords().length, 0); }); test('single line-start mention drops polluted explicit targetCats extras (fail-closed)', async () => { const app = await createApp(); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -511,9 +476,10 @@ describe('post_message A2A mention invocation', () => { }); assert.equal(response.statusCode, 200); - const records = invocationRecordStore.getRecords(); - assert.equal(records.length, 1, 'single mention should enqueue exactly one target'); - assert.deepEqual(records[0].targetCats, ['codex'], 'extra explicit target should be dropped'); + const entries = invocationQueue.list('t1', 'user-1'); + assert.equal(entries.length, 1, 'single mention should enqueue exactly one target'); + assert.deepEqual(entries[0].targets, ['codex'], 'extra explicit target should be dropped'); + assert.equal(invocationRecordStore.getRecords().length, 0); const recent = messageStore.getRecent(10); assert.equal(recent.length, 1); @@ -524,7 +490,7 @@ describe('post_message A2A mention invocation', () => { // Self-mention filter: opus @布偶猫 → no invocation (can't invoke self) test('post-message self-mention does NOT trigger invocation', async () => { const app = await createApp(); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const response = await app.inject({ method: 'POST', @@ -548,6 +514,8 @@ describe('F052: cross-thread A2A mention routing', () => { let socketManager; let invocationRecordStore; let mockRouter; + let invocationQueue; + let queueProcessor; beforeEach(async () => { const { InvocationRegistry } = await import( @@ -555,6 +523,7 @@ describe('F052: cross-thread A2A mention routing', () => { ); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); registry = new InvocationRegistry(); messageStore = new MessageStore(); @@ -562,6 +531,8 @@ describe('F052: cross-thread A2A mention routing', () => { socketManager = createMockSocketManager(); invocationRecordStore = createMockInvocationRecordStore(); mockRouter = createMockRouter(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); + queueProcessor = { async requestDrain() {} }; }); async function createAppWithThreadStore() { @@ -574,6 +545,8 @@ describe('F052: cross-thread A2A mention routing', () => { socketManager, router: mockRouter, invocationRecordStore, + invocationQueue, + queueProcessor, }); return app; } diff --git a/packages/api/test/callback-a2a-trigger.test.js b/packages/api/test/callback-a2a-trigger.test.js index 36b9bbd511..e8f5397647 100644 --- a/packages/api/test/callback-a2a-trigger.test.js +++ b/packages/api/test/callback-a2a-trigger.test.js @@ -1,4056 +1,216 @@ -/** - * Unit tests for callback-a2a-trigger.ts (F27 rewrite) - * - * F27: callback A2A now pushes to parent worklist instead of spawning - * independent invocations. triggerA2AInvocation is kept as fallback only. - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -describe('triggerA2AInvocation (fallback path)', () => { - test('releases a terminal dynamic A2A child with the callback route controller', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - const slotCompletions = []; - let routeController; - const controller = new AbortController(); - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return controller; - }, - startAll() { - return controller; - }, - tryStartThreadAll() { - return controller; - }, - trackExternalSlot() { - return true; - }, - completeSlot(threadId, catId, completedController) { - slotCompletions.push({ threadId, catId, controller: completedController }); - }, - complete() {}, - completeAll() {}, - }; - const mockRouter = { - async *routeExecution(_userId, _content, threadId, _messageId, _targetCats, _intent, options) { - routeController = options.invocationController; - assert.equal(options.trackA2ASlot(threadId, 'opus', 'user-1', routeController), true); - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }, - }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: { - create() { - return { outcome: 'created', invocationId: 'inv-dynamic-child' }; - }, - update(id, data) { - return { id, ...data }; - }, - }, - socketManager: { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }, - invocationTracker: mockInvocationTracker, - log: { error() {}, warn() {}, info() {} }, - }, - { - targetCats: ['codex'], - content: '@codex route to opus', - userId: 'user-1', - threadId: 'thread-callback-child', - triggerMessage: { - id: 'msg-callback-child', - threadId: 'thread-callback-child', - userId: 'user-1', - catId: 'codex', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.deepEqual(slotCompletions, [ - { threadId: 'thread-callback-child', catId: 'opus', controller: routeController }, - ]); - }); - - test('marks InvocationRecord as canceled when thread is deleting (P2-1)', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const updates = []; - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-1' }; - }, - update(id, data) { - updates.push({ id, ...data }); - return { id, ...data }; - }, - }; - - // Simulate aborted signal (thread is deleting) - const abortController = new AbortController(); - abortController.abort(); - - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return abortController; - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockRouter = { - async *routeExecution() { - throw new Error('should not be called'); - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { - error() {}, - warn() {}, - info() {}, - }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview please', - userId: 'user-1', - threadId: 't-deleting', - triggerMessage: { - id: 'msg-1', - threadId: 't-deleting', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - const cancelUpdate = updates.find((u) => u.status === 'canceled'); - assert.ok(cancelUpdate, 'InvocationRecord must be marked as canceled on deleting race'); - assert.equal(cancelUpdate.id, 'inv-1'); - }); - - test('does not trigger invocation for duplicate idempotency key', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const updates = []; - const mockInvocationRecordStore = { - create() { - return { outcome: 'duplicate', invocationId: 'inv-existing' }; - }, - update(id, data) { - updates.push({ id, ...data }); - return { id, ...data }; - }, - }; - - const mockRouter = { - async *routeExecution() { - throw new Error('should not be called'); - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 't1', - triggerMessage: { - id: 'msg-1', - threadId: 't1', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - assert.equal(updates.length, 0, 'No updates on duplicate'); - }); - - test('skips redundant A2A when target cat is already in active parent target set', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - let createCalled = 0; - let routeCalled = 0; - - const mockInvocationRecordStore = { - create() { - createCalled++; - return { outcome: 'created', invocationId: 'inv-dup' }; - }, - update() {}, - }; - - const mockInvocationTracker = { - has() { - return true; - }, - getActiveSlots() { - return ['opus', 'codex', 'gemini']; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockRouter = { - async *routeExecution() { - routeCalled++; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nalready covered by parent', - userId: 'user-1', - threadId: 'active-thread', - triggerMessage: { - id: 'msg-covered', - threadId: 'active-thread', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal(createCalled, 0, 'redundant A2A should not create InvocationRecord'); - assert.equal(routeCalled, 0, 'redundant A2A should not execute routeExecution'); - }); - - test('broadcasts terminal error + done when routeExecution throws (release loading lock)', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const updates = []; - const roomEvents = []; - const agentBroadcasts = []; - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-err' }; - }, - update(id, data) { - updates.push({ id, ...data }); - return { id, ...data }; - }, - }; - - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockRouter = { - async *routeExecution() { - throw new Error('route failed before done'); - }, - }; - - const mockSocketManager = { - broadcastAgentMessage(msg, threadId) { - agentBroadcasts.push({ msg, threadId }); - }, - broadcastToRoom(room, event, payload) { - roomEvents.push({ room, event, payload }); - }, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nplease review', - userId: 'user-1', - threadId: 'thread-err', - triggerMessage: { - id: 'msg-err', - threadId: 'thread-err', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - // triggerA2AInvocation is fire-and-forget; wait for background task to flush. - await new Promise((resolve) => setTimeout(resolve, 20)); - - // #768: intent_mode is deferred to first CLI event; routeExecution threw before yielding, - // so intent_mode must NOT be broadcast. - assert.equal( - roomEvents.filter((e) => e.event === 'intent_mode').length, - 0, - '#768: intent_mode must NOT be broadcast when routeExecution throws before yielding', - ); - assert.equal( - agentBroadcasts.some((b) => b.msg.type === 'error'), - true, - 'should broadcast error on execution failure', - ); - assert.equal( - agentBroadcasts.some((b) => b.msg.type === 'done' && b.msg.isFinal === true), - true, - 'should broadcast terminal done(isFinal) to release loading lock', - ); - assert.equal( - updates.some((u) => u.status === 'failed'), - true, - 'failed status should be persisted', - ); - }); - - test('#768 regression: intent_mode IS broadcast once CLI produces first event (a2a path)', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const roomEvents = []; - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-768-ok' }; - }, - update() {}, - }; - - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockRouter = { - async *routeExecution() { - yield { type: 'text', catId: 'codex', content: 'hello', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom(room, event, payload) { - roomEvents.push({ room, event, payload }); - }, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 't-768-ok', - triggerMessage: { - id: 'msg-768-ok', - threadId: 't-768-ok', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - const intentEvents = roomEvents.filter((e) => e.event === 'intent_mode'); - assert.equal(intentEvents.length, 1, '#768: intent_mode must be broadcast exactly once when CLI yields'); - assert.equal(intentEvents[0].payload.threadId, 't-768-ok'); - }); - - test('calls queueProcessor.onInvocationComplete on success', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const completions = []; - const invocationUpdates = []; - const mockQueueProcessor = { - async onInvocationComplete(threadId, catId, status) { - completions.push({ threadId, catId, status }); - }, - }; - - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-q1' }; - }, - update(_id, input) { - invocationUpdates.push(input); - }, - }; - - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockRouter = { - async *routeExecution() { - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - queueProcessor: mockQueueProcessor, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 't-queue-ok', - triggerMessage: { - id: 'msg-q1', - threadId: 't-queue-ok', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - assert.equal(completions.length, 1, 'onInvocationComplete must be called once'); - assert.equal(completions[0].threadId, 't-queue-ok'); - assert.equal(completions[0].status, 'succeeded'); - const succeededUpdate = invocationUpdates.find((input) => input.status === 'succeeded'); - assert.deepEqual(succeededUpdate.successfulCatIds, ['codex']); - }); - - test('calls queueProcessor.onInvocationComplete with failed on error', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const completions = []; - const mockQueueProcessor = { - async onInvocationComplete(threadId, catId, status) { - completions.push({ threadId, catId, status }); - }, - }; - - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-q2' }; - }, - update() {}, - }; - - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockRouter = { - async *routeExecution() { - throw new Error('simulated failure'); - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - queueProcessor: mockQueueProcessor, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 't-queue-err', - triggerMessage: { - id: 'msg-q2', - threadId: 't-queue-err', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - assert.equal(completions.length, 1, 'onInvocationComplete must be called on error'); - assert.equal(completions[0].threadId, 't-queue-err'); - assert.equal(completions[0].status, 'failed'); - }); - - test('preserves a standalone A2A terminal child error instead of writing empty success', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const updates = []; - const completions = []; - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-terminal-error' }; - }, - update(_id, input) { - updates.push(input); - }, - }; - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - await triggerA2AInvocation( - { - router: { - async *routeExecution() { - yield { - type: 'error', - catId: 'codex', - error: 'queued_prompt_exposure_rejected:standalone-message', - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }, - invocationRecordStore: mockInvocationRecordStore, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {} }, - invocationTracker: mockInvocationTracker, - queueProcessor: { - async onInvocationComplete(_threadId, _catId, status) { - completions.push(status); - }, - }, - log: { error() {}, warn() {}, info() {} }, - }, - { - targetCats: ['codex'], - content: '@codex reproduce terminal child error', - userId: 'user-1', - threadId: 'thread-terminal-error', - triggerMessage: { - id: 'message-terminal-error', - threadId: 'thread-terminal-error', - userId: 'user-1', - catId: 'opus', - content: 'handoff', - mentions: ['codex'], - timestamp: Date.now(), - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal( - updates.some((update) => update.status === 'succeeded'), - false, - ); - const failed = updates.find((update) => update.status === 'failed'); - assert.equal(failed.error, 'queued_prompt_exposure_rejected:standalone-message'); - assert.deepEqual(completions, ['failed']); - }); - - test('calls queueProcessor.onInvocationComplete with canceled on abort', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - const completions = []; - const mockQueueProcessor = { - async onInvocationComplete(threadId, catId, status) { - completions.push({ threadId, catId, status }); - }, - }; - - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-q3' }; - }, - update() {}, - }; - - const abortController = new AbortController(); - const mockInvocationTracker = { - has() { - return false; - }, - start() { - // Simulate abort mid-execution (e.g., force-send canceled this invocation) - setTimeout(() => abortController.abort(), 5); - return abortController; - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockRouter = { - async *routeExecution(_userId, _content, _threadId, _messageId, _targetCats, _intent, opts) { - // Simulate some work before abort hits - await new Promise((resolve) => setTimeout(resolve, 15)); - if (opts?.signal?.aborted) return; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - queueProcessor: mockQueueProcessor, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 't-queue-cancel', - triggerMessage: { - id: 'msg-q3', - threadId: 't-queue-cancel', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 80)); - - assert.equal(completions.length, 1, 'onInvocationComplete must be called on abort'); - assert.equal(completions[0].threadId, 't-queue-cancel'); - assert.equal(completions[0].status, 'canceled'); - }); - - test('F222 P1: A2A direct routeExecution passes frustrationAutoIssueEligible=false', async () => { - const { triggerA2AInvocation } = await import('../dist/routes/callback-a2a-trigger.js'); - - let capturedOpts; - const mockRouterCapture = { - async *routeExecution(_userId, _content, _threadId, _messageId, _targetCats, _intent, opts) { - capturedOpts = opts; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-f222' }; - }, - update() {}, - }; - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - const mockQueueProcessor = { async onInvocationComplete() {} }; - const mockSocketManager = { broadcastAgentMessage() {}, broadcastToRoom() {} }; - const mockLog = { error() {}, warn() {}, info() {} }; - - await triggerA2AInvocation( - { - router: mockRouterCapture, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - queueProcessor: mockQueueProcessor, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 't-f222-provenance', - triggerMessage: { - id: 'msg-f222', - threadId: 't-f222-provenance', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 80)); - - assert.equal( - capturedOpts?.frustrationAutoIssueEligible, - false, - 'A2A direct execution must suppress frustration detection', - ); - assert.equal(capturedOpts?.humanDispositionInvocationOrigin, 'a2a'); - }); -}); - -describe('enqueueA2ATargets (F27 primary path)', () => { - test('enqueues targets to parent worklist when worklist exists', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const worklist = ['opus']; - registerWorklist('t-enqueue', worklist, 15); - - const mockLog = { error() {}, warn() {}, info() {} }; - - try { - const result = await enqueueA2ATargets( - { - router: {}, - invocationRecordStore: {}, - socketManager: {}, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview please', - userId: 'user-1', - threadId: 't-enqueue', - triggerMessage: { - id: 'msg-1', - threadId: 't-enqueue', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - assert.equal(result.fallback, false, 'Should use worklist path, not fallback'); - assert.deepEqual(result.enqueued, ['codex'], 'codex should be enqueued'); - assert.deepEqual(worklist, ['opus', 'codex'], 'worklist should grow'); - } finally { - unregisterWorklist('t-enqueue'); - } - }); - - test('deduplicates targets already in worklist', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const worklist = ['opus', 'codex']; - registerWorklist('t-dedup', worklist, 15); - - const mockLog = { error() {}, warn() {}, info() {} }; - - try { - const result = await enqueueA2ATargets( - { - router: {}, - invocationRecordStore: {}, - socketManager: {}, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nagain', - userId: 'user-1', - threadId: 't-dedup', - triggerMessage: { - id: 'msg-2', - threadId: 't-dedup', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - assert.equal(result.fallback, false); - assert.deepEqual(result.enqueued, [], 'codex already in worklist, nothing enqueued'); - assert.deepEqual(worklist, ['opus', 'codex'], 'worklist unchanged'); - } finally { - unregisterWorklist('t-dedup'); - } - }); - - test('respects max depth limit', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { registerWorklist, unregisterWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const worklist = ['opus']; - registerWorklist('t-depth', worklist, 1); // maxDepth=1 - - const mockLog = { error() {}, warn() {}, info() {} }; - - try { - // Push one — should work (a2aCount 0 → 1) - const r1 = await enqueueA2ATargets( - { router: {}, invocationRecordStore: {}, socketManager: {}, log: mockLog }, - { - targetCats: ['codex'], - content: '@缅因猫', - userId: 'u1', - threadId: 't-depth', - triggerMessage: { - id: 'm1', - threadId: 't-depth', - userId: 'u1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - assert.deepEqual(r1.enqueued, ['codex']); - - // Push another — should fail (a2aCount=1 >= maxDepth=1) - const r2 = await enqueueA2ATargets( - { router: {}, invocationRecordStore: {}, socketManager: {}, log: mockLog }, - { - targetCats: ['gemini'], - content: '@暹罗猫', - userId: 'u1', - threadId: 't-depth', - triggerMessage: { - id: 'm2', - threadId: 't-depth', - userId: 'u1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - assert.deepEqual(r2.enqueued, [], 'depth limit reached'); - assert.equal(getWorklist('t-depth').a2aCount, 1); - } finally { - unregisterWorklist('t-depth'); - } - }); - - test('R1 P1-2: slot-aware fallback allows non-conflicting cross-slot invocation', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - let startCalled = 0; - let createCalled = 0; - const mockInvocationTracker = { - has() { - return true; - }, // Parent is active - getActiveSlots() { - return ['opus']; - }, // Only opus is active — codex is non-conflicting - start() { - startCalled++; - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockInvocationRecordStore = { - create() { - createCalled++; - return { outcome: 'created', invocationId: 'inv-x' }; - }, - update() {}, - }; - - const mockRouter = { - async *routeExecution() { - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - const result = await enqueueA2ATargets( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 'active-parent-thread', - triggerMessage: { - id: 'msg-p2', - threadId: 'active-parent-thread', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - // Wait for fire-and-forget background execution - await new Promise((resolve) => setTimeout(resolve, 20)); - - // F108 slot-aware: codex is non-conflicting with opus, safe to start - assert.equal(startCalled, 1, 'tracker.start() should be called for non-conflicting slot'); - assert.equal(createCalled, 1, 'invocationRecord should be created for non-conflicting target'); - assert.equal(result.fallback, true, 'should indicate fallback path was used'); - assert.deepEqual(result.enqueued, ['codex'], 'non-conflicting target should be enqueued'); - }); - - test('R1 P1-2: slot-aware fallback skips when all targets already active', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - let startCalled = 0; - const mockInvocationTracker = { - has() { - return true; - }, - getActiveSlots() { - return ['opus', 'codex']; - }, // codex already active - start() { - startCalled++; - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-skip' }; - }, - update() {}, - }; - - const mockRouter = { - async *routeExecution() { - throw new Error('must not be called'); - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - const result = await enqueueA2ATargets( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 'all-active-thread', - triggerMessage: { - id: 'msg-allactive', - threadId: 'all-active-thread', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - assert.equal(startCalled, 0, 'tracker.start() must NOT be called when target already active'); - assert.equal(result.fallback, true, 'should indicate fallback path was attempted'); - assert.deepEqual(result.enqueued, [], 'nothing enqueued when all targets already active'); - }); - - test('F122 AC-A3: not_found reason falls back to standalone invocation', async () => { - // Race condition: hasWorklist returns true, but worklist is unregistered - // between has() and push(). pushToWorklist returns { added: [], reason: 'not_found' }. - // enqueueA2ATargets must fall through to standalone invocation. - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { - registerWorklist, - unregisterWorklist, - hasWorklist: hasWL, - } = await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - - // Register then immediately unregister to set up the race - // We need hasWorklist to return true but pushToWorklist to return not_found. - // Since the module-level functions share state, we can't do this with the real registry. - // Instead, test that when no worklist exists at all (hasWorklist=false), - // fallback path triggers — and separately test the new not_found branch via - // a targeted code path where we register + unregister between calls. - // Actually the simplest way: the not_found branch now falls through to the same - // fallback code. So if we register a worklist with parentInvocationId 'inv-X', - // then call enqueueA2ATargets with parentInvocationId 'inv-Y' (wrong key), - // hasWorklist(threadId) returns true (thread index), but pushToWorklist - // with 'inv-Y' returns not_found (specific key doesn't exist). - const threadId = 't-notfound-race'; - const worklist = ['opus']; - const entry = registerWorklist(threadId, worklist, 10, 'inv-existing'); - assert.equal(hasWL(threadId), true, 'setup: thread has worklist via inv-existing'); - - let routeCalled = 0; - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-fb-nf' }; - }, - update() {}, - }; - - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - let routeOptions; - const mockRouter = { - async *routeExecution(_userId, _content, _threadId, _messageId, _targetCats, _intent, opts) { - routeCalled++; - routeOptions = opts; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - try { - const result = await enqueueA2ATargets( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId, - triggerMessage: { - id: 'msg-nf', - threadId, - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - // Wrong parentInvocationId — will cause not_found from pushToWorklist - parentInvocationId: 'inv-nonexistent', - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal(result.fallback, true, 'not_found must trigger fallback path'); - assert.equal(routeCalled, 1, 'standalone invocation must be triggered on not_found'); - assert.deepEqual(routeOptions.turnCustodyWakeForCat('codex'), { - kind: 'structured', - protocol: 'dispatch', - subjectKey: `ball:thread:${threadId}`, - holderCatId: 'codex', - handoff: { - sourceEventId: 'route:msg-nf:codex', - messageId: 'msg-nf', - fromCatId: 'opus', - }, - }); - } finally { - unregisterWorklist(threadId, entry, 'inv-existing'); - } - }); - - test('falls back to standalone invocation when no worklist exists', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - const updates = []; - const mockInvocationRecordStore = { - create() { - return { outcome: 'created', invocationId: 'inv-fb' }; - }, - update(id, data) { - updates.push({ id, ...data }); - }, - }; - - const mockInvocationTracker = { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }; - - let routeCalled = 0; - let routeOptions; - const mockRouter = { - async *routeExecution(_userId, _content, _threadId, _messageId, _targetCats, _intent, opts) { - routeCalled++; - routeOptions = opts; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - const mockSocketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; - - const mockLog = { error() {}, warn() {}, info() {} }; - - const result = await enqueueA2ATargets( - { - router: mockRouter, - invocationRecordStore: mockInvocationRecordStore, - socketManager: mockSocketManager, - invocationTracker: mockInvocationTracker, - log: mockLog, - }, - { - targetCats: ['codex', 'codex-terra'], - content: '@缅因猫\nreview', - userId: 'user-1', - threadId: 'no-worklist-thread', - triggerMessage: { - id: 'msg-fb', - threadId: 'no-worklist-thread', - userId: 'user-1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now(), - }, - }, - ); - - // Wait for fire-and-forget background task - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal(result.fallback, true, 'should use fallback when no worklist'); - assert.equal(routeCalled, 1, 'routeExecution called in fallback path'); - assert.deepEqual( - ['codex', 'codex-terra'].map((catId) => routeOptions.turnCustodyWakeForCat(catId)), - ['codex', 'codex-terra'].map((catId) => ({ - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:no-worklist-thread', - holderCatId: catId, - handoff: { - sourceEventId: `route:msg-fb:${catId}`, - messageId: 'msg-fb', - fromCatId: 'opus', - }, - })), - ); - }); -}); - -// ── F122B: A2A enqueue to InvocationQueue ── -describe('enqueueA2ATargets F122B (InvocationQueue path)', () => { - test('persists exact ball.handed before accepted single-recipient A2A work can auto-execute', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - const order = []; - const custodyEvents = []; - const mockInvocationQueue = { - enqueue(input) { - return { - outcome: 'enqueued', - entry: { id: `q-${input.targetCats[0]}`, ...input, status: 'queued', createdAt: Date.now() }, - }; - }, - countAgentEntriesForThread() { - return 0; - }, - findInFlightAgentEntry() { - return null; - }, - backfillMessageId() {}, - list() { - return []; - }, - }; - - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - queueProcessor: { - onInvocationComplete() {}, - async tryAutoExecute() { - order.push('auto-execute'); - assert.equal(custodyEvents.length, 1, 'the accepted handoff must be durable before execution starts'); - }, - }, - invocationQueue: mockInvocationQueue, - ballCustody: { - async record(event) { - order.push(`handoff:${event.payload.toCatId}`); - custodyEvents.push(event); - }, - }, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['opus'], - content: 'A2A handoff message', - userId: 'system', - threadId: 'thread-a2a', - triggerMessage: { - id: 'msg-trigger', - mentions: ['opus'], - content: 'test', - catId: 'gemini', - }, - callerCatId: 'gemini', - }, - ); - - assert.deepEqual(result.enqueued, ['opus']); - assert.deepEqual(order, ['handoff:opus', 'auto-execute']); - assert.deepEqual( - custodyEvents.map((event) => ({ - sourceEventId: event.sourceEventId, - subjectKey: event.subjectKey, - kind: event.kind, - payload: event.payload, - })), - [ - { - sourceEventId: 'route:msg-trigger:opus', - subjectKey: 'ball:thread:thread-a2a', - kind: 'ball.handed', - payload: { toCatId: 'opus', fromCatId: 'gemini' }, - }, - ], - ); - }); - - test('keeps an accepted single-recipient queue path off fail-open broadcast when custody shadow write rejects', async () => { - const [{ enqueueA2ATargets }, { MessageDeliveryService }] = await Promise.all([ - import('../dist/routes/callback-a2a-trigger.js'), - import('../dist/domains/cats/services/agents/invocation/MessageDeliveryService.js'), - ]); - - const queueEntries = []; - const autoExecuteCalls = []; - const markDeliveredCalls = []; - const warnCalls = []; - const errorCalls = []; - const log = { - info() {}, - warn(context, message) { - warnCalls.push({ context, message }); - }, - error(context, message) { - errorCalls.push({ context, message }); - }, - }; - const deps = { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - queueProcessor: { - onInvocationComplete() {}, - async tryAutoExecute(threadId) { - autoExecuteCalls.push(threadId); - }, - }, - invocationQueue: { - enqueue(input) { - const entry = { - id: `q-${input.targetCats[0]}`, - ...input, - status: 'queued', - createdAt: Date.now(), - }; - queueEntries.push(entry); - return { outcome: 'enqueued', entry }; - }, - countAgentEntriesForThread() { - return 0; - }, - findInFlightAgentEntry() { - return null; - }, - backfillMessageId() {}, - list() { - return queueEntries; - }, - }, - ballCustody: { - async record() { - throw new Error('shadow Redis append unavailable'); - }, - }, - log, - }; - - const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ - canEnqueueA2A: true, - willEnqueueToQueue: true, - messageId: 'stored-message', - threadId: 'thread-shadow-failure', - log, - enqueueA2A: () => - enqueueA2ATargets(deps, { - targetCats: ['opus'], - content: 'A2A handoff message', - userId: 'system', - threadId: 'thread-shadow-failure', - triggerMessage: { - id: 'msg-shadow-failure', - mentions: ['opus'], - content: 'test', - catId: 'gemini', - }, - callerCatId: 'gemini', - }), - markDelivered: async (messageId) => { - markDeliveredCalls.push(messageId); - return null; - }, - zeroEnqueuedWarnMessage: 'zero targets', - enqueueFailureMessage: 'enqueue failed', - }); - - assert.equal(result.shouldBroadcastNow, false, 'accepted queue work must not fail open to parent broadcast'); - assert.equal(result.enqueueFailed, false); - assert.deepEqual(result.enqueued, ['opus']); - assert.equal(queueEntries.length, 1, 'the child must be accepted exactly once'); - assert.deepEqual(autoExecuteCalls, ['thread-shadow-failure']); - assert.equal(markDeliveredCalls.length, 0, 'queued parent message must remain queued'); - assert.equal(warnCalls.length, 1, 'the shadow write gap must remain observable'); - assert.equal(errorCalls.length, 0, 'the accepted queue path must not be reported as enqueue failure'); - }); - - test('enqueues to InvocationQueue with agent source when invocationQueue dep is provided', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - const enqueueCalls = []; - const emitCalls = []; - const mockInvocationQueue = { - enqueue(input) { - enqueueCalls.push(input); - return { outcome: 'enqueued', entry: { id: 'q-1', ...input, status: 'queued', createdAt: Date.now() } }; - }, - countAgentEntriesForThread() { - return 0; - }, - hasQueuedAgentForCat() { - return false; - }, - backfillMessageId() {}, - list() { - return [{ id: 'q-1', status: 'queued' }]; - }, - }; - const tryAutoExecuteCalls = []; - const mockQueueProcessor = { - onInvocationComplete() {}, - tryAutoExecute(threadId) { - tryAutoExecuteCalls.push(threadId); - return Promise.resolve(); - }, - }; - const socketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser(userId, event, data) { - emitCalls.push({ userId, event, data }); - }, - }; - - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager, - invocationTracker: { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }, - queueProcessor: mockQueueProcessor, - invocationQueue: mockInvocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['opus'], - content: 'A2A handoff message', - userId: 'system', - ownerAuthProvenance: 'strict', - threadId: 't1', - triggerMessage: { id: 'msg-trigger', mentions: ['opus'], content: 'test' }, - callerCatId: 'codex', - parentInvocationId: 'inv-parent', - }, - ); - - assert.equal(enqueueCalls.length, 1, 'should enqueue to InvocationQueue'); - assert.equal(enqueueCalls[0].source, 'agent'); - assert.equal(enqueueCalls[0].autoExecute, true); - assert.equal(enqueueCalls[0].callerCatId, 'codex'); - assert.equal(enqueueCalls[0].ownerAuthProvenance, 'strict'); - assert.equal(enqueueCalls[0].a2aTriggerMessageId, 'msg-trigger'); - assert.equal(enqueueCalls[0].targetCats[0], 'opus'); - assert.equal(tryAutoExecuteCalls.length, 1, 'should trigger tryAutoExecute'); - const queueUpdated = emitCalls.find((c) => c.event === 'queue_updated'); - assert.ok(queueUpdated, 'should emit queue_updated after enqueue'); - assert.equal(queueUpdated.userId, 'system'); - assert.equal(queueUpdated.data.action, 'enqueued'); - assert.equal(queueUpdated.data.threadId, 't1'); - assert.deepEqual(result.enqueued, ['opus']); - assert.equal(result.fallback, false); - }); - - test('respects A2A depth limit — rejects when depth exceeded', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - const enqueueCalls = []; - const mockInvocationQueue = { - enqueue(input) { - enqueueCalls.push(input); - return { outcome: 'enqueued', entry: { id: 'q-1', ...input } }; - }, - // F122B: agent entry count for depth tracking - countAgentEntriesForThread(threadId) { - return 10; // At depth limit - }, - list() { - return []; - }, - }; - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - invocationTracker: { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - return Promise.resolve(); - }, - }, - invocationQueue: mockInvocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['opus'], - content: 'deep A2A', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'msg-deep', mentions: ['opus'], content: 'test' }, - callerCatId: 'codex', - }, - ); - - assert.equal(enqueueCalls.length, 0, 'should NOT enqueue when depth limit reached'); - assert.deepEqual(result.enqueued, []); - }); - - test('F-coalesce: merges into a queued agent entry instead of dispatching a duplicate', async () => { - // Contract change (F-coalesce): a repeated same-turn handoff to a cat that already has a QUEUED - // agent entry is now MERGED into that entry (caller intent preserved) rather than skip-dropped - // (old behaviour lost the follow-up). A new (non-duplicate) cat still enqueues normally. - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - const enqueueCalls = []; - const findCalls = []; - const coalesceCalls = []; - const custodyEvents = []; - const mockInvocationQueue = { - enqueue(input) { - enqueueCalls.push(input); - return { outcome: 'enqueued', entry: { id: 'q-1', ...input } }; - }, - countAgentEntriesForThread() { - return 0; - }, - // opus already has a queued agent entry → returned as in-flight for coalescing - findInFlightAgentEntry(_threadId, catId, callerCatId, parentInvocationId, ownerAuthProvenance) { - findCalls.push({ callerCatId, parentInvocationId, ownerAuthProvenance }); - return catId === 'opus' - ? { id: 'q-existing', userId: 'system', status: 'queued', source: 'agent', targetCats: ['opus'] } - : null; - }, - coalesceContentIntoQueuedAgent( - _threadId, - _userId, - entryId, - content, - messageId, - callerCatId, - parentInvocationId, - ownerAuthProvenance, - ) { - coalesceCalls.push({ - entryId, - content, - messageId, - callerCatId, - parentInvocationId, - ownerAuthProvenance, - }); - return true; - }, - backfillMessageId() {}, - list() { - return []; - }, - }; - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - invocationTracker: { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - return Promise.resolve(); - }, - }, - invocationQueue: mockInvocationQueue, - ballCustody: { - async record(event) { - custodyEvents.push(event); - }, - }, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['opus'], - content: 'A2A handoff', - userId: 'system', - ownerAuthProvenance: 'strict', - threadId: 't1', - triggerMessage: { id: 'msg-dup', mentions: ['opus'], content: 'test' }, - callerCatId: 'gemini', - parentInvocationId: 'parent-strict', - }, - ); - - // opus → coalesced into its queued entry (no new enqueue), but the accepted route still - // advances the thread ball before that queued entry can execute. - assert.equal(coalesceCalls.length, 1, 'opus handoff should be coalesced into the queued entry'); - assert.equal(coalesceCalls[0].entryId, 'q-existing'); - assert.deepEqual(findCalls, [ - { callerCatId: 'gemini', parentInvocationId: 'parent-strict', ownerAuthProvenance: 'strict' }, - ]); - assert.equal(coalesceCalls[0].ownerAuthProvenance, 'strict'); - assert.equal(enqueueCalls.length, 0, 'coalescing must not create a duplicate entry'); - assert.deepEqual( - custodyEvents.map((event) => event.payload.toCatId), - ['opus'], - ); - assert.deepEqual(result.enqueued, [], 'coalescing is not a new route'); - assert.deepEqual(result.coalesced, ['opus'], 'the merged cat is reported as coalesced, not routed'); - }); - - test('depth limit enforced per-target — multi-target stops at limit (cloud P1)', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - let depth = 9; // one slot left - const enqueueCalls = []; - const mockInvocationQueue = { - enqueue(input) { - enqueueCalls.push(input); - depth++; // simulate entry being added - return { outcome: 'enqueued', entry: { id: `q-${depth}`, ...input } }; - }, - countAgentEntriesForThread() { - return depth; - }, - hasQueuedAgentForCat() { - return false; - }, - backfillMessageId() {}, - list() { - return []; - }, - }; - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - invocationTracker: { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - return Promise.resolve(); - }, - }, - invocationQueue: mockInvocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['opus', 'codex'], - content: 'multi-target near limit', - userId: 'system', - threadId: 't1', - triggerMessage: { id: 'msg-overflow', mentions: ['opus', 'codex'], content: 'test' }, - callerCatId: 'gemini', - }, - ); - - // depth starts at 9, first enqueue (opus) brings it to 10, second (codex) should be rejected - assert.equal(enqueueCalls.length, 1, 'should enqueue only first target before hitting limit'); - assert.equal(enqueueCalls[0].targetCats[0], 'opus'); - assert.deepEqual(result.enqueued, ['opus']); - }); - - test('backfills triggerMessage.id onto queue entry after enqueue (AC-B6-P1)', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - - const backfillCalls = []; - const mockInvocationQueue = { - enqueue(input) { - return { outcome: 'enqueued', entry: { id: 'q-1', ...input, status: 'queued', createdAt: Date.now() } }; - }, - countAgentEntriesForThread() { - return 0; - }, - hasQueuedAgentForCat() { - return false; - }, - backfillMessageId(threadId, userId, entryId, messageId) { - backfillCalls.push({ threadId, userId, entryId, messageId }); - }, - appendMergedMessageId() {}, - list() { - return []; - }, - }; - await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - invocationTracker: { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - return Promise.resolve(); - }, - }, - invocationQueue: mockInvocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['opus'], - content: 'A2A handoff', - userId: 'system', +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; +import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; + +const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); +const { queueEntryId } = await import('../dist/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.js'); +const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); +const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); + +function setup() { + const messageStore = new MessageStore(); + const invocationQueue = new InvocationQueue(); + const events = []; + const requestDrain = mock.fn(async () => { + events.push('drain'); + }); + const tryAutoAppendExactEntry = mock.fn(async () => { + events.push('append'); + return { outcome: 'rejected' }; + }); + const registerCallerDispatchInitialTargets = mock.fn(() => { + events.push('observe'); + }); + const deps = { + messageStore, + invocationQueue, + queueProcessor: { requestDrain, tryAutoAppendExactEntry, registerCallerDispatchInitialTargets }, + socketManager: { + emitToUser: mock.fn(), + broadcastAgentMessage: mock.fn(), + }, + log: { + info: mock.fn(), + warn: mock.fn(), + error: mock.fn(), + }, + }; + const appendTrigger = (id) => + messageStore.append( + canonicalTestMessageInput({ + id, + from: { kind: 'agent', catId: 'opus' }, + userId: 'u1', + catId: 'opus', threadId: 't1', - triggerMessage: { id: 'msg-trigger-123', mentions: ['opus'], content: 'test' }, - callerCatId: 'codex', - }, - ); - - assert.equal(backfillCalls.length, 1, 'should backfill messageId onto queue entry'); - assert.equal(backfillCalls[0].entryId, 'q-1'); - assert.equal(backfillCalls[0].messageId, 'msg-trigger-123'); - }); - - test('PR7 initializes same-thread A2A custody before prompt exposure', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { QueuedMessageCustodyCoordinator } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' - ); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const socketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser() {}, - }; - const promptProcessor = new QueueProcessor({ - queue: invocationQueue, - messageStore, - queueCustodyCoordinator, - socketManager, - }); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'same-thread handoff already published in history', - mentions: ['codex'], - timestamp: 100, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); - let promptBoundaryReached = false; - - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager, - invocationTracker: { - has() { - return false; - }, - start() { - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete() {}, - completeAll() {}, - }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - async tryAutoExecute() { - const [entry] = invocationQueue.list('thread-target', 'user-1'); - assert.equal(entry.messageId, triggerMessage.id, 'callback must backfill the published trigger'); - const custody = messageStore.getById(triggerMessage.id)?.queueCustody; - assert.ok(custody, 'same-thread custody must be durable before provider execution'); - assert.deepEqual(custody.allTargetCats, ['codex']); - assert.equal(custody.carrierByTargetCatId.codex.entryId, entry.id); - await promptProcessor.markPromptMessagesSeen({ - threadId: 'thread-target', - userId: 'user-1', - catId: 'codex', - invocationId: 'inv-same-thread-child', - messageIds: [triggerMessage.id], - seenAt: 200, - }); - promptBoundaryReached = true; - }, - }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: triggerMessage.content, - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-same-thread', - }, - ); - - assert.deepEqual(result.enqueued, ['codex']); - assert.equal(promptBoundaryReached, true); - assert.deepEqual(messageStore.getById(triggerMessage.id).queueCustody.seenByCatIds, ['codex']); - assert.deepEqual(invocationQueue.list('thread-target', 'user-1')[0].queuedSeenByCatIds, ['codex']); - }); - - test('PR7 establishes complete same-thread custody for mixed enqueued and coalesced fan-out', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const existing = invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: 'existing codex carrier', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - a2aParentInvocationId: 'parent-same-thread', - a2aTriggerMessageId: 'message-existing', - }).entry; - invocationQueue.backfillMessageId('thread-target', 'user-1', existing.id, 'message-existing'); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'fan out to both targets', - mentions: ['codex', 'codex-terra'], - timestamp: 200, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); - let custodyAtAutoExecute; - - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - custodyAtAutoExecute = messageStore.getById(triggerMessage.id)?.queueCustody; - return Promise.resolve(); - }, - }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex', 'codex-terra'], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-same-thread', - }, - ); - - assert.deepEqual(result.enqueued, ['codex-terra']); - assert.deepEqual(result.coalesced, ['codex']); - assert.ok(custodyAtAutoExecute, 'the complete fan-out must be durable before either target executes'); - assert.deepEqual(custodyAtAutoExecute.allTargetCats, ['codex', 'codex-terra']); - assert.deepEqual(custodyAtAutoExecute.pendingTargetCats, ['codex', 'codex-terra']); - const entries = invocationQueue.list('thread-target', 'user-1'); - assert.equal(custodyAtAutoExecute.carrierByTargetCatId.codex.entryId, existing.id); - assert.equal( - custodyAtAutoExecute.carrierByTargetCatId['codex-terra'].entryId, - entries.find((entry) => entry.targetCats.includes('codex-terra')).id, - ); - }); - - test('PR7 establishes complete same-thread custody when every fan-out target coalesces', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const existingByCat = new Map(); - for (const catId of ['codex', 'codex-terra']) { - const entry = invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: `existing ${catId} carrier`, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - a2aParentInvocationId: 'parent-same-thread', - a2aTriggerMessageId: `message-existing-${catId}`, - }).entry; - invocationQueue.backfillMessageId('thread-target', 'user-1', entry.id, `message-existing-${catId}`); - existingByCat.set(catId, entry); - } - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'coalesce both targets', - mentions: ['codex', 'codex-terra'], - timestamp: 300, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); - let custodyAtAutoExecute; - - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - custodyAtAutoExecute = messageStore.getById(triggerMessage.id)?.queueCustody; - return Promise.resolve(); - }, - }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex', 'codex-terra'], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-same-thread', - }, - ); - - assert.deepEqual(result.enqueued, []); - assert.deepEqual(result.coalesced, ['codex', 'codex-terra']); - assert.ok(custodyAtAutoExecute, 'all-coalesced fan-out still requires durable group custody'); - assert.deepEqual(custodyAtAutoExecute.allTargetCats, ['codex', 'codex-terra']); - assert.deepEqual(custodyAtAutoExecute.pendingTargetCats, ['codex', 'codex-terra']); - for (const catId of ['codex', 'codex-terra']) { - assert.equal(custodyAtAutoExecute.carrierByTargetCatId[catId].entryId, existingByCat.get(catId).id); - } - }); - - test('PR7 competing scheduler cannot cross the provider boundary before fan-out custody commits', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { QueuedMessageCustodyCoordinator } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' - ); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const initializeQueueCustody = messageStore.initializeQueueCustody.bind(messageStore); - let releaseFanoutCustody; - const fanoutCustodyGate = new Promise((resolve) => { - releaseFanoutCustody = resolve; - }); - let fanoutInitializationStarted; - const fanoutInitializationStart = new Promise((resolve) => { - fanoutInitializationStarted = resolve; - }); - let initializationCalls = 0; - messageStore.initializeQueueCustody = async (...args) => { - initializationCalls += 1; - if (initializationCalls === 1) { - fanoutInitializationStarted(); - await fanoutCustodyGate; - } - return initializeQueueCustody(...args); - }; - const queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const socketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser() {}, - }; - let providerStarts = 0; - const invocationTracker = { - has() { - return false; - }, - startAll() { - return new AbortController(); - }, - waitForSessionSealRelease() { - return Promise.resolve(); - }, - completeAll() {}, - }; - const queueProcessor = new QueueProcessor({ - queue: invocationQueue, - messageStore, - queueCustodyCoordinator, - socketManager, - invocationTracker, - invocationRecordStore: { - create() { - return { outcome: 'created', invocationId: `inv-fanout-race-${Date.now()}` }; - }, - update(id, data) { - return { id, ...data }; - }, - }, - router: { - async *routeExecution(_userId, _content, _threadId, _messageId, targetCats) { - providerStarts += 1; - for (const catId of targetCats) { - yield { type: 'done', catId, timestamp: Date.now() }; - } - }, - async ackCollectedCursors() {}, - }, - log: { info() {}, warn() {}, error() {} }, - }); - const existingCodex = invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: 'existing codex carrier', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - a2aParentInvocationId: 'parent-same-thread', - a2aTriggerMessageId: 'message-existing-codex', - }).entry; - invocationQueue.backfillMessageId('thread-target', 'user-1', existingCodex.id, 'message-existing-codex'); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'fan out under a competing scheduler', - mentions: ['codex', 'codex-terra'], - timestamp: 350, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); - - const admission = enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager, - messageStore, - invocationTracker, - queueProcessor, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex', 'codex-terra'], - content: triggerMessage.content, - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-same-thread', - }, - ); - - await fanoutInitializationStart; - await queueProcessor.tryAutoExecute('thread-target'); - await new Promise((resolve) => setTimeout(resolve, 50)); - const providerStartsBeforeCustody = providerStarts; - releaseFanoutCustody(); - let admissionResult; - let admissionError; - try { - admissionResult = await admission; - } catch (error) { - admissionError = error; - } - - assert.equal( - providerStartsBeforeCustody, - 0, - 'provider execution must not begin until complete fan-out custody is durable', - ); - assert.ifError(admissionError); - assert.deepEqual(admissionResult.coalesced, ['codex']); - assert.deepEqual(admissionResult.enqueued, ['codex-terra']); - assert.deepEqual(messageStore.getById(triggerMessage.id).queueCustody.pendingTargetCats, ['codex', 'codex-terra']); - }); - - test('PR7 same-source reentry joins one staged carrier during the first custody CAS', async () => { - const [ - { enqueueA2ATargets }, - { MessageDeliveryService }, - { InvocationQueue }, - { QueueProcessor }, - { QueuedMessageCustodyCoordinator }, - { MessageStore }, - ] = await Promise.all([ - import('../dist/routes/callback-a2a-trigger.js'), - import('../dist/domains/cats/services/agents/invocation/MessageDeliveryService.js'), - import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), - import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'), - import('../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'), - import('../dist/domains/cats/services/stores/ports/MessageStore.js'), - ]); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const initializeQueueCustody = messageStore.initializeQueueCustody.bind(messageStore); - let releaseFirstCustodyCas; - const firstCustodyCasGate = new Promise((resolve) => { - releaseFirstCustodyCas = resolve; - }); - let firstCustodyCasStarted; - const firstCustodyCasStart = new Promise((resolve) => { - firstCustodyCasStarted = resolve; - }); - let releaseSecondCustodyReturn; - const secondCustodyReturnGate = new Promise((resolve) => { - releaseSecondCustodyReturn = resolve; - }); - let secondCustodyCommitted; - const secondCustodyCommit = new Promise((resolve) => { - secondCustodyCommitted = resolve; - }); - let initializeCalls = 0; - let stagedCarrierIdsAtSecondCas = []; - messageStore.initializeQueueCustody = async (...args) => { - initializeCalls += 1; - if (initializeCalls === 1) { - firstCustodyCasStarted(); - await firstCustodyCasGate; - return initializeQueueCustody(...args); - } - const result = initializeQueueCustody(...args); - stagedCarrierIdsAtSecondCas = invocationQueue - .list('thread-same-source-reentry', 'user-1') - .filter((entry) => entry.targetCats.includes('codex')) - .map((entry) => entry.id); - secondCustodyCommitted(); - await secondCustodyReturnGate; - return result; - }; - - const queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const socketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser() {}, - }; - let providerStarts = 0; - const providerContents = []; - const invocationTracker = { - has() { - return false; - }, - startAll() { - return new AbortController(); - }, - waitForSessionSealRelease() { - return Promise.resolve(); - }, - completeAll() {}, - }; - const queueProcessor = new QueueProcessor({ - queue: invocationQueue, - messageStore, - queueCustodyCoordinator, - socketManager, - invocationTracker, - invocationRecordStore: { - create() { - return { outcome: 'created', invocationId: `inv-same-source-${Date.now()}` }; - }, - update(id, data) { - return { id, ...data }; - }, - }, - router: { - async *routeExecution(_userId, content, _threadId, _messageId, targetCats) { - providerStarts += 1; - providerContents.push(content); - for (const catId of targetCats) { - yield { type: 'done', catId, timestamp: Date.now() }; - } - }, - async ackCollectedCursors() {}, - }, - log: { info() {}, warn() {}, error() {} }, - }); - const realTryAutoExecute = queueProcessor.tryAutoExecute.bind(queueProcessor); - let autoExecuteEntrants = 0; - let releaseAutoExecute; - const autoExecuteGate = new Promise((resolve) => { - releaseAutoExecute = resolve; - }); - queueProcessor.tryAutoExecute = async (threadId) => { - autoExecuteEntrants += 1; - if (autoExecuteEntrants === 2) releaseAutoExecute(); - if (autoExecuteEntrants === 1) setTimeout(releaseAutoExecute, 25); - await autoExecuteGate; - return realTryAutoExecute(threadId); - }; - - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'same durable source must execute once', - mentions: ['codex'], - timestamp: 365, - threadId: 'thread-same-source-reentry', - deliveryStatus: 'queued', - }); - const deps = { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager, - messageStore, - invocationTracker, - queueProcessor, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }; - const opts = { - targetCats: ['codex'], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'unknown', - threadId: triggerMessage.threadId, - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-same-source-reentry', - }; - const fallbackBroadcasts = []; - const recoverSameSource = async () => { - const decision = await MessageDeliveryService.resolveCallbackDeliveryDecision({ - canEnqueueA2A: true, - willEnqueueToQueue: true, - messageId: triggerMessage.id, - threadId: triggerMessage.threadId, - log: deps.log, - enqueueA2A: () => enqueueA2ATargets(deps, opts), - markDelivered: (deliveredAt) => messageStore.markDelivered(triggerMessage.id, deliveredAt), - zeroEnqueuedWarnMessage: 'same-source recovery had no target', - enqueueFailureMessage: 'same-source recovery failed', - }); - if (decision.shouldBroadcastNow) fallbackBroadcasts.push(triggerMessage.id); - return decision; - }; - - const firstRecovery = recoverSameSource(); - await firstCustodyCasStart; - const duplicateRecovery = recoverSameSource(); - await secondCustodyCommit; - releaseFirstCustodyCas(); - releaseSecondCustodyReturn(); - - const [firstDecision, duplicateDecision] = await Promise.all([firstRecovery, duplicateRecovery]); - await new Promise((resolve) => setTimeout(resolve, 50)); - - assert.equal(initializeCalls, 2, 'both callers converge through the same custody CAS identity'); - assert.equal(new Set(stagedCarrierIdsAtSecondCas).size, 1, 'same-source reentry must stage one carrier identity'); - assert.equal(firstDecision.enqueueFailed, false, 'the legitimate first callback must converge successfully'); - assert.equal(duplicateDecision.enqueueFailed, false, 'the duplicate recovery must converge successfully'); - assert.deepEqual(fallbackBroadcasts, [], 'custody convergence must not fall open to live broadcast'); - assert.equal(providerStarts, 1, 'the joined carrier may enter the provider exactly once'); - assert.deepEqual( - providerContents, - [triggerMessage.content], - 'same-source join must not duplicate provider content', - ); - }); - - test('PR7 same-source delivery joins restart custody after CAS before Queue projection', async () => { - const [ - { enqueueA2ATargets }, - { MessageDeliveryService }, - { InvocationQueue }, - { QueueProcessor }, - { QueuedMessageCustodyCoordinator, createFanoutQueueCustodyAdmission }, - { StartupReconciler }, - { MessageStore }, - ] = await Promise.all([ - import('../dist/routes/callback-a2a-trigger.js'), - import('../dist/domains/cats/services/agents/invocation/MessageDeliveryService.js'), - import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), - import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'), - import('../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'), - import('../dist/domains/cats/services/agents/invocation/StartupReconciler.js'), - import('../dist/domains/cats/services/stores/ports/MessageStore.js'), - ]); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'restart projection gap must still execute once', - mentions: ['codex'], - timestamp: 370, - threadId: 'thread-restart-projection-gap', - deliveryStatus: 'queued', - }); - await messageStore.initializeQueueCustodyAdmission( - triggerMessage.id, - createFanoutQueueCustodyAdmission(triggerMessage.id, { - ownerUserId: 'user-1', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - requestedTargetCats: ['codex'], - intent: 'execute', - callerCatId: 'opus', - a2aParentInvocationId: 'parent-restart-projection-gap', - createdAt: triggerMessage.timestamp, + content: `source-${id}`, + mentions: ['codex'], + timestamp: Date.now(), }), ); - messageStore.scanByDeliveryStatus = (status) => - status === 'queued' && messageStore.getById(triggerMessage.id)?.deliveryStatus === 'queued' - ? [triggerMessage.id] - : []; - - const getById = messageStore.getById.bind(messageStore); - const initializeQueueCustody = messageStore.initializeQueueCustody.bind(messageStore); - let blockNextPostCasRead = false; - let postCasReadBlocked = false; - let enterProjectionGap; - const projectionGapEntered = new Promise((resolve) => { - enterProjectionGap = resolve; - }); - let releaseProjectionGap; - const projectionGap = new Promise((resolve) => { - releaseProjectionGap = resolve; - }); - messageStore.initializeQueueCustody = async (...args) => { - const result = await initializeQueueCustody(...args); - blockNextPostCasRead = true; - return result; - }; - messageStore.getById = (messageId) => { - if (messageId === triggerMessage.id && blockNextPostCasRead && !postCasReadBlocked) { - postCasReadBlocked = true; - blockNextPostCasRead = false; - enterProjectionGap(); - return projectionGap.then(() => getById(messageId)); - } - return getById(messageId); - }; - - const startup = new StartupReconciler({ - invocationRecordStore: { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - }, - invocationQueue, - messageStore, - a2aDispatchDispositionService: { - async inspectHandoff({ sourceMessageId }) { - return { - outcome: 'live', - sourceMessageId, - fromCatId: 'opus', - handoffSourceEventId: `route:${sourceMessageId}`, - }; - }, - }, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, - }); - const startupRun = startup.reconcileOrphans(); - await projectionGapEntered; + return { deps, events, invocationQueue, messageStore, appendTrigger }; +} - const committed = getById(triggerMessage.id); - assert.equal(committed.queueCustody?.status, 'queued', 'startup committed full custody before projection'); - assert.equal(invocationQueue.list(triggerMessage.threadId, 'user-1').length, 0, 'local Queue is still empty'); +async function enqueue(deps, triggerMessage, targetCats = ['codex']) { + return enqueueA2ATargets(deps, { + targetCats, + content: triggerMessage.content, + userId: 'u1', + ownerAuthProvenance: 'strict', + threadId: 't1', + triggerMessage, + callerCatId: 'opus', + parentInvocationId: 'parent-1', + }); +} - const socketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser() {}, - }; - const callbackDeps = { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager, - messageStore, - invocationQueue, - queueProcessor: { async tryAutoExecute() {} }, - log: { info() {}, warn() {}, error() {} }, - }; - const decision = await MessageDeliveryService.resolveCallbackDeliveryDecision({ - canEnqueueA2A: true, - willEnqueueToQueue: true, - messageId: triggerMessage.id, - threadId: triggerMessage.threadId, - log: callbackDeps.log, - enqueueA2A: () => - enqueueA2ATargets(callbackDeps, { - targetCats: ['codex'], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'unknown', - threadId: triggerMessage.threadId, - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-restart-projection-gap', - }), - markDelivered: (deliveredAt) => messageStore.markDelivered(triggerMessage.id, deliveredAt), - zeroEnqueuedWarnMessage: 'restart projection join had no target', - enqueueFailureMessage: 'restart projection join failed', - }); +describe('enqueueA2ATargets single durable ledger', () => { + it('fans one public Agent message into one deterministic source row with pending targets', async () => { + const { deps, invocationQueue, messageStore, appendTrigger } = setup(); + const trigger = appendTrigger('a2a-source-1'); - releaseProjectionGap(); - await startupRun; + const result = await enqueue(deps, trigger, ['codex', 'fable5']); - const canonicalEntryId = `queue-custody:${triggerMessage.id}:codex`; - const projectedEntries = invocationQueue.list(triggerMessage.threadId, 'user-1'); - assert.equal(decision.enqueueFailed, false, 'same-source callback must join committed custody'); - assert.equal(decision.shouldBroadcastNow, false, 'committed custody must never fall open to broadcast'); + assert.deepEqual(result, { enqueued: ['codex', 'fable5'] }); + const rows = invocationQueue.list('t1', 'u1'); assert.deepEqual( - projectedEntries.map((entry) => entry.id), - [canonicalEntryId], - 'callback and startup must converge on one canonical Queue carrier', - ); - - let providerStarts = 0; - const providerContents = []; - const queueProcessor = new QueueProcessor({ - queue: invocationQueue, - messageStore, - queueCustodyCoordinator: new QueuedMessageCustodyCoordinator({ messageStore }), - socketManager, - invocationTracker: { - has() { - return false; - }, - startAll() { - return new AbortController(); - }, - waitForSessionSealRelease() { - return Promise.resolve(); - }, - completeAll() {}, - }, - invocationRecordStore: { - create() { - return { outcome: 'created', invocationId: `inv-restart-gap-${Date.now()}` }; - }, - update(id, data) { - return { id, ...data }; - }, - }, - router: { - async *routeExecution(_userId, content, _threadId, _messageId, targetCats) { - providerStarts += 1; - providerContents.push(content); - for (const catId of targetCats) yield { type: 'done', catId, timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }, - log: { info() {}, warn() {}, error() {} }, - }); - await queueProcessor.tryAutoExecute(triggerMessage.threadId); - await new Promise((resolve) => setTimeout(resolve, 50)); - - assert.equal(providerStarts, 1, 'the custody-bound carrier executes exactly once'); - assert.deepEqual(providerContents, [triggerMessage.content], 'the source content is not duplicated'); - }); - - test('PR7 pure restart preserves a full-custody action fence before provider admission', async () => { - const [ - { enqueueA2ATargets }, - { InvocationQueue }, - { QueueProcessor }, - { QueuedMessageCustodyCoordinator }, - { StartupReconciler }, - { MessageStore }, - ] = await Promise.all([ - import('../dist/routes/callback-a2a-trigger.js'), - import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), - import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'), - import('../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'), - import('../dist/domains/cats/services/agents/invocation/StartupReconciler.js'), - import('../dist/domains/cats/services/stores/ports/MessageStore.js'), + rows.map((row) => ({ + id: row.id, + targets: row.targets, + messageId: row.payload.messageId, + })), + [{ id: queueEntryId(trigger.id), targets: ['codex', 'fable5'], messageId: trigger.id }], + ); + const persisted = messageStore.getById(trigger.id); + assert.equal(persisted.queueCustody, undefined); + assert.equal(persisted.queueCustodyAdmission, undefined); + assert.equal(deps.queueProcessor.registerCallerDispatchInitialTargets.mock.calls.length, 1); + assert.equal(deps.queueProcessor.registerCallerDispatchInitialTargets.mock.calls[0].arguments[0].id, trigger.id); + assert.deepEqual(deps.queueProcessor.registerCallerDispatchInitialTargets.mock.calls[0].arguments[1], [ + 'codex', + 'fable5', ]); - - const actionSuccessorFence = { - leaseId: 'lease-action-restart', - generation: 7, - dispatchId: 'cross-post:action-restart', - terminalPredicateDigest: 'terminal-predicate-action-restart', - invocationLineageRef: 'dispatch:cross-post:action-restart', - }; - const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'terminal action must stay fenced after restart', - mentions: ['codex'], - timestamp: 372, - threadId: 'thread-action-restart', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-action-source', - sourceInvocationId: 'parent-action-restart', - effectClass: 'assign_work', - }, - }, - }); - messageStore.scanByDeliveryStatus = (status) => - status === 'queued' && messageStore.getById(triggerMessage.id)?.deliveryStatus === 'queued' - ? [triggerMessage.id] - : []; - - const beforeRestartQueue = new InvocationQueue(); - const admission = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - invocationQueue: beforeRestartQueue, - queueProcessor: { async tryAutoExecute() {} }, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'strict', - threadId: triggerMessage.threadId, - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-action-restart', - actionSuccessorFence, - }, - ); - assert.deepEqual(admission.enqueued, ['codex']); - const committed = messageStore.getById(triggerMessage.id); - assert.equal(committed.queueCustody?.status, 'queued'); - assert.equal(committed.queueCustodyAdmission, undefined, 'full custody atomically replaces admission intent'); - - const restartedQueue = new InvocationQueue(); - const startup = new StartupReconciler({ - invocationRecordStore: { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - }, - invocationQueue: restartedQueue, - messageStore, - a2aDispatchDispositionService: { - async inspectHandoff({ sourceMessageId }) { - return { - outcome: 'live', - sourceMessageId, - fromCatId: 'opus', - handoffSourceEventId: `route:${sourceMessageId}`, - }; - }, - }, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, - }); - await startup.reconcileOrphans(); - - const restored = restartedQueue.markProcessing(triggerMessage.threadId, 'user-1'); - assert.ok(restored, 'pure restart must restore the full-custody carrier'); - let preflightCalls = 0; - let providerStarts = 0; - let invocationRecordCreates = 0; - const queueProcessor = new QueueProcessor({ - queue: restartedQueue, - messageStore, - queueCustodyCoordinator: new QueuedMessageCustodyCoordinator({ messageStore }), - actionSuccessorLeaseStore: { - async preflight(leaseId, generation, terminalPredicateDigest) { - preflightCalls += 1; - assert.deepEqual( - { leaseId, generation, terminalPredicateDigest }, - { - leaseId: actionSuccessorFence.leaseId, - generation: actionSuccessorFence.generation, - terminalPredicateDigest: actionSuccessorFence.terminalPredicateDigest, - }, - ); - return { ok: false, reason: 'subject_terminal' }; - }, - }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - invocationTracker: { - has() { - return false; - }, - startAll() { - return new AbortController(); - }, - waitForSessionSealRelease() { - return Promise.resolve(); - }, - completeAll() {}, - }, - invocationRecordStore: { - create() { - invocationRecordCreates += 1; - return { outcome: 'created', invocationId: 'inv-action-restart' }; - }, - update(id, data) { - return { id, ...data }; - }, - }, - router: { - async *routeExecution() { - providerStarts += 1; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }, - log: { info() {}, warn() {}, error() {} }, - }); - - const result = await queueProcessor.executeEntry(restored); - - assert.deepEqual(restored.actionSuccessorFence, actionSuccessorFence); - assert.equal(restored.idempotencyKey, 'action:lease-action-restart:7:codex'); - assert.equal(result.status, 'canceled', 'terminal action lease must fail closed before provider admission'); - assert.equal(preflightCalls, 1, 'restart projection must retain the action preflight boundary'); - assert.equal(invocationRecordCreates, 0, 'terminal action must not mint an invocation record'); - assert.equal(providerStarts, 0, 'terminal action must never reach the provider after restart'); }); - test('PR7 restart recovers the complete fan-out when the process dies before the first custody CAS', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { StartupReconciler } = await import('../dist/domains/cats/services/agents/invocation/StartupReconciler.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const crashedQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const initializeQueueCustody = messageStore.initializeQueueCustody.bind(messageStore); - let releaseFirstCustodyCas; - const firstCustodyCasGate = new Promise((resolve) => { - releaseFirstCustodyCas = resolve; - }); - let firstCustodyCasStarted; - const firstCustodyCasStart = new Promise((resolve) => { - firstCustodyCasStarted = resolve; - }); - let initializeCalls = 0; - messageStore.initializeQueueCustody = async (...args) => { - initializeCalls += 1; - if (initializeCalls === 1) { - firstCustodyCasStarted(); - await firstCustodyCasGate; - } - return initializeQueueCustody(...args); - }; - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'fan out and survive restart', - mentions: ['codex', 'codex-terra'], - timestamp: 375, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); - messageStore.scanByDeliveryStatus = (status) => - status === 'queued' && messageStore.getById(triggerMessage.id)?.deliveryStatus === 'queued' - ? [triggerMessage.id] - : []; - - const interruptedAdmission = enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - invocationQueue: crashedQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex', 'codex-terra'], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-same-thread', - }, - ); - - await firstCustodyCasStart; - assert.equal(crashedQueue.list('thread-target', 'user-1').length, 2, 'old process staged both carriers'); + it('replay converges by source id and target without duplicating a row', async () => { + const { deps, invocationQueue, appendTrigger } = setup(); + const trigger = appendTrigger('a2a-source-replay'); - const restartedQueue = new InvocationQueue(); - const startup = new StartupReconciler({ - invocationRecordStore: { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - }, - invocationQueue: restartedQueue, - messageStore, - a2aDispatchDispositionService: { - async inspectHandoff({ sourceMessageId }) { - return { - outcome: 'live', - sourceMessageId, - fromCatId: 'opus', - handoffSourceEventId: `route:${sourceMessageId}`, - }; - }, - }, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, - }); - - await startup.reconcileOrphans(); + await enqueue(deps, trigger); + await enqueue(deps, trigger); - const restoredEntries = restartedQueue.list('thread-target', 'user-1'); - assert.equal(restoredEntries.length, 2, 'restart must rebuild every target carrier'); - assert.deepEqual(restoredEntries.flatMap((entry) => entry.targetCats).sort(), ['codex', 'codex-terra']); - const recoveredMessage = messageStore.getById(triggerMessage.id); - assert.equal(recoveredMessage.deliveryStatus, 'queued', 'pending fan-out must not become delivered-only'); - assert.deepEqual(recoveredMessage.queueCustody?.pendingTargetCats, ['codex', 'codex-terra']); - - releaseFirstCustodyCas(); - await interruptedAdmission.catch(() => {}); + assert.equal(invocationQueue.list('t1', 'u1').length, 1); + assert.equal(invocationQueue.list('t1', 'u1')[0].id, queueEntryId(trigger.id)); }); - test('PR7 restart never revives a target rejected before the first custody CAS', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { StartupReconciler } = await import('../dist/domains/cats/services/agents/invocation/StartupReconciler.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const crashedQueue = new InvocationQueue(); - for (let index = 0; index < 10; index += 1) { - crashedQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-depth-rejection', - userId: 'user-1', - content: `existing pending A2A ${index}`, - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - intent: 'execute', - autoExecute: false, - callerCatId: 'codex-terra', - a2aParentInvocationId: `parent-existing-${index}`, - a2aTriggerMessageId: `message-existing-${index}`, - }); - } - - const messageStore = new MessageStore(); - const initializeQueueCustody = messageStore.initializeQueueCustody.bind(messageStore); - let releaseFirstCustodyCas; - const firstCustodyCasGate = new Promise((resolve) => { - releaseFirstCustodyCas = resolve; - }); - let firstCustodyCasStarted; - const firstCustodyCasStart = new Promise((resolve) => { - firstCustodyCasStarted = resolve; - }); - let initializeCalls = 0; - messageStore.initializeQueueCustody = async (...args) => { - initializeCalls += 1; - if (initializeCalls === 1) { - firstCustodyCasStarted(); - await firstCustodyCasGate; - } - return initializeQueueCustody(...args); - }; - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'codex-terra', - content: 'this target must remain rejected after restart', - mentions: ['codex'], - timestamp: 390, - threadId: 'thread-depth-rejection', - deliveryStatus: 'queued', - }); - messageStore.scanByDeliveryStatus = (status) => - status === 'queued' && messageStore.getById(triggerMessage.id)?.deliveryStatus === 'queued' - ? [triggerMessage.id] - : []; - - const interruptedAdmission = enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - invocationQueue: crashedQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'unknown', - threadId: 'thread-depth-rejection', - triggerMessage, - callerCatId: 'codex-terra', - parentInvocationId: 'parent-depth-rejection', - }, - ); - - await firstCustodyCasStart; - assert.equal( - crashedQueue.list('thread-depth-rejection', 'user-1').filter((entry) => entry.targetCats.includes('codex')) - .length, - 0, - 'ordinary enqueue policy rejects codex before the crash window', - ); - const persistedAdmission = messageStore.getById(triggerMessage.id)?.queueCustodyAdmission; - assert.deepEqual(persistedAdmission?.requestedTargetCats, ['codex']); - assert.deepEqual(persistedAdmission?.targetCats, [], 'durable admission records the final rejected outcome'); + it('skips a consumed source instead of recreating Queue work after its row is gone', async () => { + const { deps, invocationQueue, messageStore, appendTrigger, events } = setup(); + const trigger = appendTrigger('a2a-source-consumed'); - const restartedQueue = new InvocationQueue(); - const startup = new StartupReconciler({ - invocationRecordStore: { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - }, - invocationQueue: restartedQueue, - messageStore, - a2aDispatchDispositionService: { - async inspectHandoff({ sourceMessageId }) { - return { - outcome: 'live', - sourceMessageId, - fromCatId: 'codex-terra', - handoffSourceEventId: `route:${sourceMessageId}`, - }; - }, - }, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, + await enqueue(deps, trigger); + const [row] = invocationQueue.list('t1', 'u1'); + assert.ok(row); + const claim = await invocationQueue.markProcessingDurable('t1', 'u1', { + entryId: row.id, + targetCats: ['codex'], }); - - await startup.reconcileOrphans(); - + assert.equal(claim.status, 'claimed'); + assert.equal(await invocationQueue.commitClaimedProcessing('t1', [row.id], Date.now()), true); + const lifecycle = messageStore.getById(trigger.id)?.lifecycle; + assert.ok(lifecycle); assert.equal( - restartedQueue.listAutoExecute('thread-depth-rejection').filter((entry) => entry.targetCats.includes('codex')) - .length, - 0, - 'restart recovery must preserve the enqueue-time target rejection', - ); - - releaseFirstCustodyCas(); - await interruptedAdmission.catch(() => {}); - }); - - test('PR7 rejects incompatible same-thread custody before any target can auto-execute', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'same-thread custody collision', - mentions: ['codex'], - timestamp: 400, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); - messageStore.initializeQueueCustody(triggerMessage.id, { - version: 1, - entryId: 'unrelated-entry', - revision: 1, - intent: 'execute', - status: 'queued', - allTargetCats: ['codex'], - pendingTargetCats: ['codex'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 400, - updatedAt: 400, - }); - let autoExecuteCalls = 0; - - await assert.rejects( - enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - autoExecuteCalls += 1; - return Promise.resolve(); - }, - }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: triggerMessage.content, - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-same-thread', - }, - ), - /custody identity mismatch/, + messageStore.advanceLifecycleInputDispatch(trigger.id, { + orderKey: lifecycle.orderKey, + ...(lifecycle.producerInvocationId ? { producerInvocationId: lifecycle.producerInvocationId } : {}), + targetId: 'codex', + phase: 'dispatched', + statusMessageId: 'a2a-source-consumed-response', + dispatchedAt: Date.now(), + }).kind, + 'applied', ); + assert.ok(await invocationQueue.removeProcessedAcrossUsersDurable('t1', row.id, 'succeeded')); - assert.equal(autoExecuteCalls, 0); - assert.deepEqual(invocationQueue.list('thread-target', 'user-1'), []); + assert.deepEqual(await enqueue(deps, trigger), { enqueued: [] }); + assert.equal(invocationQueue.list('t1', 'u1').length, 0); + assert.deepEqual(events, ['observe', 'append', 'drain']); }); - test('F264 initializes per-target cross-thread custody before A2A auto-execution', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const userEvents = []; - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'terminal release', - mentions: ['codex', 'codex-terra'], - timestamp: 100, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - coordination: { - id: 'coord-1', - phase: 'terminal', - hop: 1, - }, - }, - }); - - let custodyAtAutoExecute; - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser(userId, event, data) { - userEvents.push({ userId, event, data }); - }, - }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - custodyAtAutoExecute = messageStore.getById(triggerMessage.id)?.queueCustody; - return Promise.resolve(); - }, - }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex', 'codex-terra'], - content: 'terminal release', - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-source', - }, - ); + it('keeps distinct source bodies as distinct work orders instead of concatenating them', async () => { + const { deps, invocationQueue, appendTrigger } = setup(); + const first = appendTrigger('a2a-source-first'); + const second = appendTrigger('a2a-source-second'); - assert.deepEqual(result.enqueued, ['codex', 'codex-terra']); - assert.ok(custodyAtAutoExecute, 'durable receipt custody must exist before the child can start'); - assert.equal(custodyAtAutoExecute.receiptScope, 'cross_thread_delivery'); - assert.deepEqual(custodyAtAutoExecute.allTargetCats, ['codex', 'codex-terra']); - assert.deepEqual(custodyAtAutoExecute.pendingTargetCats, ['codex', 'codex-terra']); + await enqueue(deps, first); + await enqueue(deps, second); - const entries = invocationQueue.list('thread-target', 'user-1'); - assert.equal(entries.length, 2, 'A2A keeps independent per-target Queue carriers'); + const rows = invocationQueue.list('t1', 'u1'); + assert.equal(rows.length, 2); assert.deepEqual( - custodyAtAutoExecute.carrierByTargetCatId, - Object.fromEntries( - entries.map((entry) => [ - entry.targetCats[0], - { - entryId: entry.id, - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'opus', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: triggerMessage.id, - autoExecute: true, - createdAt: entry.createdAt, - }, - ]), - ), + rows.map((row) => row.payload.messageId), + [first.id, second.id], ); - const queuedTimelineEvent = userEvents.find((event) => event.event === 'messages_queued'); - assert.equal(queuedTimelineEvent.userId, 'user-1'); - assert.equal(queuedTimelineEvent.data.threadId, 'thread-target'); - assert.equal(queuedTimelineEvent.data.messages[0].id, triggerMessage.id); - assert.equal(queuedTimelineEvent.data.messages[0].extra.queueReceipt.scope, 'cross_thread_delivery'); assert.deepEqual( - queuedTimelineEvent.data.messages[0].extra.queueReceipt.targets.map((target) => target.state), - ['queued', 'queued'], - ); - }); - - test('PR7 direct-active cross-thread handoff drains exactly once when the target slot becomes idle', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { QueuedMessageCustodyCoordinator } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' - ); - const { InvocationRecordStore } = await import( - '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js' - ); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const threadId = 'thread-direct-active-cross-thread'; - const targetCatId = 'codex-sol'; - const invocationQueue = new InvocationQueue(); - const invocationTracker = new InvocationTracker(); - const invocationRecordStore = new InvocationRecordStore(); - const messageStore = new MessageStore(); - const queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const socketManager = { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }; - let providerStarts = 0; - const queueProcessor = new QueueProcessor({ - queue: invocationQueue, - invocationTracker, - invocationRecordStore, - messageStore, - queueCustodyCoordinator, - socketManager, - router: { - async *routeExecution(userId, _content, routedThreadId, _messageId, targetCats, _intent, options) { - providerStarts += 1; - await options.onPromptMessagesExposed({ - threadId: routedThreadId, - userId, - catId: targetCats[0], - invocationId: 'inv-queued-successor', - messageIds: options.persistedPromptMessageIds, - seenAt: Date.now(), - }); - yield { - type: 'done', - catId: targetCats[0], - invocationId: 'inv-queued-successor', - timestamp: Date.now(), - }; - }, - async ackCollectedCursors() {}, - }, - log: { info() {}, warn() {}, error() {} }, - }); - const directController = invocationTracker.startAll(threadId, [targetCatId], 'user-1', 'inv-direct-active'); - assert.ok(directController, 'test must hold the exact target slot before the handoff arrives'); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'coordinate queued while the target is busy', - mentions: [targetCatId], - timestamp: 450, - threadId, - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'inv-source', - effectClass: 'coordinate', - }, - }, - }); - - await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore, - socketManager, - messageStore, - invocationTracker, - queueProcessor, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: [targetCatId], - content: triggerMessage.content, - userId: 'user-1', - ownerAuthProvenance: 'unknown', - threadId, - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'inv-source', - }, + rows.map((row) => row.payload.content), + [first.content, second.content], ); - - assert.equal(providerStarts, 0, 'busy admission must stay durable without starting a competing provider'); - assert.equal(invocationQueue.list(threadId, 'user-1').length, 1); - assert.equal(messageStore.getById(triggerMessage.id).deliveryStatus, 'queued'); - - invocationTracker.completeAll(threadId, [targetCatId], directController); - await queueProcessor.onInvocationComplete(threadId, targetCatId, 'succeeded', 'inv-direct-active', [targetCatId]); - for ( - let attempt = 0; - attempt < 50 && messageStore.getById(triggerMessage.id)?.deliveryStatus !== 'delivered'; - attempt++ - ) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - assert.equal(providerStarts, 1, 'the durable carrier must start once when the direct slot becomes idle'); - assert.deepEqual(invocationQueue.list(threadId, 'user-1'), []); - const terminal = messageStore.getById(triggerMessage.id); - assert.equal(terminal.deliveryStatus, 'delivered'); - assert.equal(terminal.queueCustody.status, 'terminal'); - assert.deepEqual(terminal.queueCustody.handledByCatIds, [targetCatId]); }); - test('F264 rejects a mismatched existing cross-thread custody before auto-execution', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'terminal release', - mentions: ['codex'], - timestamp: 100, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - }, - }); - messageStore.initializeQueueCustody(triggerMessage.id, { - version: 1, - entryId: 'unrelated-entry', - revision: 1, - intent: 'execute', - status: 'queued', - allTargetCats: ['codex'], - pendingTargetCats: ['codex'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 100, - updatedAt: 100, - }); - let autoExecuteCalls = 0; - - await assert.rejects( - enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - autoExecuteCalls += 1; - return Promise.resolve(); - }, - }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { + it('applies the chain-depth limit once per source row without splitting its fan-out', async () => { + const { deps, invocationQueue, appendTrigger } = setup(); + for (let index = 0; index < 9; index += 1) { + invocationQueue.enqueueDurableNow( + canonicalTestQueueInput({ + sourceId: `seed-${index}`, + from: { kind: 'agent', catId: 'opus' }, + threadId: 't1', + userId: 'u1', + kind: 'message_wake', + ownerAuthProvenance: 'strict', + content: `seed-${index}`, + messageId: `seed-message-${index}`, targetCats: ['codex'], - content: 'terminal release', - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-source', - }, - ), - /custody identity mismatch/, - ); - - assert.equal(autoExecuteCalls, 0); - assert.deepEqual(invocationQueue.list('thread-target', 'user-1'), []); - }); - - test('F264 binds a coalesced cross-thread message to the existing exact Queue carrier', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const messageStore = new MessageStore(); - const existing = invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: 'first handoff', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-first', - }).entry; - invocationQueue.backfillMessageId('thread-target', 'user-1', existing.id, 'message-first'); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'second handoff', - mentions: ['codex'], - timestamp: 200, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - }, - }); - let custodyAtAutoExecute; - - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - custodyAtAutoExecute = messageStore.getById(triggerMessage.id)?.queueCustody; - return Promise.resolve(); - }, - }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: 'second handoff', - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-source', - }, - ); - - assert.deepEqual(result.enqueued, []); - assert.deepEqual(result.coalesced, ['codex']); - assert.equal(custodyAtAutoExecute.carrierByTargetCatId.codex.entryId, existing.id); - assert.equal(custodyAtAutoExecute.carrierByTargetCatId.codex.a2aTriggerMessageId, 'message-first'); - const merged = invocationQueue.getEntrySnapshot('thread-target', 'user-1', existing.id); - assert.equal(merged.content, 'first handoff\n\nsecond handoff'); - assert.deepEqual(merged.mergedMessageIds, [triggerMessage.id]); - }); - - test('F264 action replay restores every durable coalesced member beyond the recent timeline window', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { createInitialCrossThreadQueuedMessageCustody, QueuedMessageCustodyCoordinator } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' - ); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const admissionQueue = new InvocationQueue(); - const enqueueCarrier = (catId, triggerMessageId) => - admissionQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: 'first handoff', - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: triggerMessageId, - }).entry; - const opusCarrier = enqueueCarrier('opus', 'message-first'); - const codexCarrier = enqueueCarrier('codex', 'message-first'); - const messageStore = new MessageStore({ maxMessages: 3_000 }); - const crossPost = { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }; - const first = messageStore.append({ - userId: 'user-1', - catId: 'sonnet', - content: 'first handoff', - mentions: ['opus', 'codex'], - timestamp: 100, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { crossPost }, - }); - admissionQueue.backfillMessageId('thread-target', 'user-1', opusCarrier.id, first.id); - admissionQueue.backfillMessageId('thread-target', 'user-1', codexCarrier.id, first.id); - assert.equal( - messageStore.initializeQueueCustody( - first.id, - createInitialCrossThreadQueuedMessageCustody(first.id, [opusCarrier, codexCarrier], { - requestedTargetCats: ['opus', 'codex'], - createdAt: first.timestamp, + sourceCategory: 'a2a', + intent: 'execute', + autoExecute: true, }), - ).kind, - 'initialized', - ); - const second = messageStore.append({ - userId: 'user-1', - catId: 'sonnet', - content: 'second handoff', - mentions: ['opus'], - timestamp: 101, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { crossPost }, - }); - assert.equal( - admissionQueue.coalesceContentIntoQueuedAgent( - 'thread-target', - 'user-1', - opusCarrier.id, - second.content, - second.id, - 'sonnet', - 'parent-source', - ), - true, - ); - const mergedOpusCarrier = admissionQueue.getEntrySnapshot('thread-target', 'user-1', opusCarrier.id); - assert.equal( - messageStore.initializeQueueCustody( - second.id, - createInitialCrossThreadQueuedMessageCustody(second.id, [mergedOpusCarrier], { - requestedTargetCats: ['opus'], - createdAt: second.timestamp, - }), - ).kind, - 'initialized', - ); - for (let index = 0; index < 2_001; index += 1) { - const newerMessage = messageStore.append({ - userId: 'user-1', - catId: 'sonnet', - content: `newer durable message ${index}`, - mentions: [], - timestamp: 102 + index, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); - assert.equal(messageStore.markDelivered(newerMessage.id, 102 + index)?.deliveryStatus, 'delivered'); - } - - const replayQueue = new InvocationQueue(); - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { - onInvocationComplete() {}, - tryAutoExecute() { - return Promise.resolve(); - }, - }, - invocationQueue: replayQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['opus'], - content: second.content, - userId: 'user-1', - threadId: 'thread-target', - triggerMessage: second, - callerCatId: 'sonnet', - parentInvocationId: 'parent-source', - ownerAuthProvenance: 'strict', - actionSuccessorFence: { - leaseId: 'lease-review-1', - generation: 2, - dispatchId: 'cross-post:message-second', - }, - }, - ); - - assert.deepEqual(result.enqueued, ['opus']); - const restored = replayQueue.getEntrySnapshot('thread-target', 'user-1', opusCarrier.id); - assert.equal(restored.messageId, first.id); - assert.equal(restored.ownerAuthProvenance, 'unknown'); - assert.deepEqual(restored.mergedMessageIds, [second.id]); - assert.deepEqual(restored.targetCats, ['opus']); - assert.deepEqual(restored.actionSuccessorFence, { - leaseId: 'lease-review-1', - generation: 2, - dispatchId: 'cross-post:message-second', - }); - for (const messageId of [first.id, second.id]) { - const binding = messageStore.getById(messageId).queueCustody.carrierByTargetCatId.opus; - assert.equal(binding.idempotencyKey, 'action:lease-review-1:2:opus'); - assert.deepEqual( - binding.actionSuccessorFence, - restored.actionSuccessorFence, - 'action replay must durably rebind every coalesced custody member before Queue projection', ); } - assert.equal(replayQueue.getEntrySnapshot('thread-target', 'user-1', codexCarrier.id), null); - - const processing = replayQueue.markProcessing('thread-target', 'user-1'); - assert.ok(processing, 'the complete restored carrier must remain provider-selectable'); - let providerStarts = 0; - const providerContents = []; - const queueProcessor = new QueueProcessor({ - queue: replayQueue, - messageStore, - queueCustodyCoordinator: new QueuedMessageCustodyCoordinator({ messageStore }), - actionSuccessorLeaseStore: { - async preflight() { - return { ok: true, reason: 'active' }; - }, - async commitOutcome() { - return { outcome: 'recorded', lease: { status: 'completed' } }; - }, - }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - invocationTracker: { - has() { - return false; - }, - startAll() { - return new AbortController(); - }, - waitForSessionSealRelease() { - return Promise.resolve(); - }, - completeAll() {}, - }, - invocationRecordStore: { - create() { - return { outcome: 'created', invocationId: 'inv-windowed-action-replay' }; - }, - update(id, data) { - return { id, ...data }; - }, - }, - router: { - async *routeExecution(_userId, content, _threadId, _messageId, targetCats, _intent, options) { - providerStarts += 1; - providerContents.push(content); - for (const catId of targetCats) { - assert.equal(await options.beforeOutputCommit(catId), true); - yield { type: 'done', catId, timestamp: Date.now() }; - } - }, - async ackCollectedCursors() {}, - }, - log: { info() {}, warn() {}, error() {} }, - }); - - const execution = await queueProcessor.executeEntry(processing); - - assert.equal(execution.status, 'succeeded'); - assert.equal(providerStarts, 1, 'the complete durable carrier executes exactly once'); - assert.deepEqual(providerContents, ['first handoff\nsecond handoff']); - }); - - test('F264 restores a coalesced Queue carrier when durable receipt initialization fails', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - const existing = invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: 'first handoff', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-first', - }).entry; - invocationQueue.backfillMessageId('thread-target', 'user-1', existing.id, 'message-first'); - const before = invocationQueue.getEntrySnapshot('thread-target', 'user-1', existing.id); - const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'second handoff', - mentions: ['codex'], - timestamp: 210, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - }, - }); - messageStore.initializeQueueCustody(triggerMessage.id, { - version: 1, - entryId: 'conflicting-custody', - revision: 1, - intent: 'execute', - status: 'queued', - allTargetCats: ['codex'], - pendingTargetCats: ['codex'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 210, - updatedAt: 210, - }); + const trigger = appendTrigger('a2a-source-depth'); - await assert.rejects( - enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { onInvocationComplete() {}, tryAutoExecute() {} }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: 'second handoff', - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-source', - }, - ), - /custody identity mismatch/, - ); + const result = await enqueue(deps, trigger, ['codex', 'fable5']); - assert.deepEqual(invocationQueue.getEntrySnapshot('thread-target', 'user-1', existing.id), before); + assert.deepEqual(result, { enqueued: ['codex', 'fable5'] }); + assert.equal(invocationQueue.list('t1', 'u1').length, 10); }); - test('F264 persists a distinct failed receipt when no cross-thread target is admitted', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - for (let index = 0; index < 10; index += 1) { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: `existing-${index}`, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [`cat-${index}`], - intent: 'execute', - }); - } - const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'terminal release', - mentions: ['codex'], - timestamp: 300, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - }, - }); - const userEvents = []; + it('offers the row to Active Append without reintroducing ordinary Ball custody', async () => { + const { deps, events, appendTrigger } = setup(); + deps.ballCustody = { + record: mock.fn(async () => { + events.push('ball'); + }), + }; + const trigger = appendTrigger('a2a-source-order'); - const result = await enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser(userId, event, data) { - userEvents.push({ userId, event, data }); - }, - }, - messageStore, - queueProcessor: { onInvocationComplete() {}, tryAutoExecute() {} }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: 'terminal release', - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-source', - }, - ); + await enqueue(deps, trigger); - assert.deepEqual(result.enqueued, []); - const stored = messageStore.getById(triggerMessage.id); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, []); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['codex']); - const receipt = userEvents.find((event) => event.event === 'messages_queued').data.messages[0].extra.queueReceipt; - assert.deepEqual(receipt.targets, [ - { - catId: 'codex', - state: 'failed', - retryable: false, - attempts: [ - { - id: `cross-thread:${triggerMessage.id}:codex:1`, - targetCatId: 'codex', - sequence: 1, - state: 'failed', - terminalReason: 'invocation_failed', - createdAt: triggerMessage.timestamp, - updatedAt: triggerMessage.timestamp, - }, - ], - }, - ]); + assert.deepEqual(events, ['observe', 'append', 'drain']); }); - test('F264 rejects an idempotent-looking empty carrier receipt for a different requested target', async () => { - const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - - const invocationQueue = new InvocationQueue(); - for (let index = 0; index < 10; index += 1) { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-target', - userId: 'user-1', - content: `existing-${index}`, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [`cat-${index}`], - intent: 'execute', - }); - } - const messageStore = new MessageStore(); - const triggerMessage = messageStore.append({ - userId: 'user-1', + it('rejects sources that are not persisted public Agent messages', async () => { + const { deps } = setup(); + const forged = canonicalTestMessageInput({ + id: 'missing-source', + from: { kind: 'agent', catId: 'opus' }, + userId: 'u1', catId: 'opus', - content: 'terminal release', + threadId: 't1', + content: 'not persisted', mentions: ['codex'], - timestamp: 320, - threadId: 'thread-target', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - }, - }); - messageStore.initializeQueueCustody(triggerMessage.id, { - version: 1, - entryId: `cross-thread:${triggerMessage.id}`, - revision: 1, - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: {}, - intent: 'execute', - status: 'terminal', - allTargetCats: ['opus'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: ['opus'], - handledByCatIds: [], - priority: 'normal', - createdAt: 320, - updatedAt: 320, + timestamp: Date.now(), }); - await assert.rejects( - enqueueA2ATargets( - { - router: { async *routeExecution() {} }, - invocationRecordStore: { create() {}, update() {} }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - messageStore, - queueProcessor: { onInvocationComplete() {}, tryAutoExecute() {} }, - invocationQueue, - log: { info() {}, warn() {}, error() {} }, - }, - { - targetCats: ['codex'], - content: 'terminal release', - userId: 'user-1', - threadId: 'thread-target', - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'parent-source', - }, - ), - /custody identity mismatch/, - ); + await assert.rejects(enqueue(deps, forged), /persisted public agent source message/); }); }); diff --git a/packages/api/test/callback-auth-system-message.test.js b/packages/api/test/callback-auth-system-message.test.js index 1f203c0c9e..67362d3400 100644 --- a/packages/api/test/callback-auth-system-message.test.js +++ b/packages/api/test/callback-auth-system-message.test.js @@ -69,7 +69,7 @@ describe('CallbackAuthSystemMessageNotifier (F174-D2b-1)', () => { assert.equal(messageStore.appended.length, 1, 'one message appended'); const msg = messageStore.appended[0]; assert.equal(msg.threadId, 't1'); - assert.equal(msg.catId, null, 'system message uses catId=null'); + assert.deepEqual(msg.from, { kind: 'system', service: 'callback-auth' }); assert.equal(msg.userId, 'u1'); assert.equal(msg.timestamp, now); assert.deepEqual(msg.mentions, []); diff --git a/packages/api/test/callback-complete-managed-hold-route.test.js b/packages/api/test/callback-complete-managed-hold-route.test.js deleted file mode 100644 index d7fde9e10b..0000000000 --- a/packages/api/test/callback-complete-managed-hold-route.test.js +++ /dev/null @@ -1,264 +0,0 @@ -/** - * F167 × F254 — invocation-bound managed hold disposition callback. - */ -import assert from 'node:assert/strict'; -import { beforeEach, describe, test } from 'node:test'; -import Fastify from 'fastify'; - -describe('POST /api/callbacks/complete-managed-hold', () => { - let registry; - let threadStore; - - beforeEach(async () => { - const { InvocationRegistry } = await import( - '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' - ); - const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); - registry = new InvocationRegistry(); - threadStore = new ThreadStore(); - }); - - async function createApp(managedHoldDispositionService) { - const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); - const app = Fastify(); - await app.register(callbacksRoutes, { - registry, - messageStore: { - async getMessagesForThread() { - return []; - }, - }, - socketManager: { - broadcastAgentMessage() {}, - getMessages() { - return []; - }, - }, - threadStore, - evidenceStore: { - async store() {}, - async search() { - return []; - }, - }, - markerQueue: { enqueue() {} }, - reflectionService: { async run() {} }, - holdBallDeps: { registry, managedHoldDispositionService }, - }); - return app; - } - - async function credentials() { - const thread = await threadStore.create('user-managed-hold', 'managed hold'); - const auth = await registry.create( - 'user-managed-hold', - 'codex-sol', - thread.id, - undefined, - undefined, - undefined, - 'message-managed-hold', - ); - return { ...auth, threadId: thread.id }; - } - - test('authenticates the current invocation and forwards only its record plus typed disposition', async () => { - const calls = []; - const app = await createApp({ - async complete(record, disposition) { - calls.push({ record, disposition }); - return { outcome: 'applied', disposition }; - }, - }); - const auth = await credentials(); - const response = await app.inject({ - method: 'POST', - url: '/api/callbacks/complete-managed-hold', - headers: { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }, - payload: { disposition: 'completed' }, - }); - - assert.equal(response.statusCode, 200); - assert.equal(calls.length, 1); - assert.equal(calls[0].record.invocationId, auth.invocationId); - assert.equal(calls[0].record.threadId, auth.threadId); - assert.equal(calls[0].record.originTriggerMessageId, 'message-managed-hold'); - assert.equal(calls[0].disposition, 'completed'); - }); - - test('rejects caller-selected subjects and stale fenced dispositions', async () => { - const { ManagedHoldDispositionError } = await import( - '../dist/domains/ball-custody/ManagedHoldDispositionService.js' - ); - const app = await createApp({ - async complete() { - throw new ManagedHoldDispositionError('managed_hold_disposition_stale_invocation'); - }, - }); - const auth = await credentials(); - const headers = { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }; - - const widened = await app.inject({ - method: 'POST', - url: '/api/callbacks/complete-managed-hold', - headers, - payload: { disposition: 'handled', taskId: 'caller-selected' }, - }); - assert.equal(widened.statusCode, 400); - - const stale = await app.inject({ - method: 'POST', - url: '/api/callbacks/complete-managed-hold', - headers, - payload: { disposition: 'handled' }, - }); - assert.equal(stale.statusCode, 409); - assert.equal(JSON.parse(stale.body).code, 'managed_hold_disposition_stale_invocation'); - }); -}); - -describe('POST /api/callbacks/complete-a2a-dispatch', () => { - let registry; - let threadStore; - - beforeEach(async () => { - const { InvocationRegistry } = await import( - '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' - ); - const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); - registry = new InvocationRegistry(); - threadStore = new ThreadStore(); - }); - - async function createApp(a2aDispatchDispositionService) { - const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); - const app = Fastify(); - await app.register(callbacksRoutes, { - registry, - messageStore: { - async getMessagesForThread() { - return []; - }, - }, - socketManager: { - broadcastAgentMessage() {}, - getMessages() { - return []; - }, - }, - threadStore, - evidenceStore: { - async store() {}, - async search() { - return []; - }, - }, - markerQueue: { enqueue() {} }, - reflectionService: { async run() {} }, - holdBallDeps: { registry, a2aDispatchDispositionService }, - }); - return app; - } - - async function credentials() { - const thread = await threadStore.create('user-a2a', 'a2a dispatch'); - const auth = await registry.create( - 'user-a2a', - 'codex-sol', - thread.id, - undefined, - 'message-a2a', - undefined, - 'message-a2a', - ); - return { ...auth, threadId: thread.id }; - } - - test('forwards only callback auth plus the typed disposition', async () => { - const calls = []; - const app = await createApp({ - async complete(record, disposition) { - calls.push({ record, disposition }); - return { outcome: 'applied', disposition }; - }, - }); - const auth = await credentials(); - const response = await app.inject({ - method: 'POST', - url: '/api/callbacks/complete-a2a-dispatch', - headers: { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }, - payload: { disposition: 'completed' }, - }); - - assert.equal(response.statusCode, 200); - assert.equal(calls.length, 1); - assert.equal(calls[0].record.a2aTriggerMessageId, 'message-a2a'); - assert.equal(calls[0].record.originTriggerMessageId, 'message-a2a'); - assert.equal(calls[0].disposition, 'completed'); - }); - - test('rejects caller-selected subjects and stale fenced dispositions', async () => { - const { A2ADispatchDispositionError } = await import( - '../dist/domains/ball-custody/A2ADispatchDispositionService.js' - ); - const app = await createApp({ - async complete() { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_stale_invocation'); - }, - }); - const auth = await credentials(); - const headers = { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }; - assert.equal( - ( - await app.inject({ - method: 'POST', - url: '/api/callbacks/complete-a2a-dispatch', - headers, - payload: { disposition: 'handled', sourceMessageId: 'caller-selected' }, - }) - ).statusCode, - 400, - ); - const stale = await app.inject({ - method: 'POST', - url: '/api/callbacks/complete-a2a-dispatch', - headers, - payload: { disposition: 'handled' }, - }); - assert.equal(stale.statusCode, 409); - assert.equal(JSON.parse(stale.body).code, 'a2a_dispatch_disposition_stale_invocation'); - }); - - test('returns a verified replacement pointer when a disposition carrier was superseded', async () => { - const { A2ADispatchDispositionError } = await import( - '../dist/domains/ball-custody/A2ADispatchDispositionService.js' - ); - const replacement = { - kind: 'handed', - sourceEventId: 'route:message-successor:codex-sol', - sourceMessageId: 'message-successor', - fromCatId: 'opus', - toCatId: 'codex-sol', - coordination: { id: 'coord-successor', phase: 'active', hop: 1 }, - }; - const app = await createApp({ - async complete() { - throw new A2ADispatchDispositionError('a2a_dispatch_disposition_replaced', replacement); - }, - }); - const auth = await credentials(); - const response = await app.inject({ - method: 'POST', - url: '/api/callbacks/complete-a2a-dispatch', - headers: { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }, - payload: { disposition: 'handled' }, - }); - - assert.equal(response.statusCode, 409); - assert.deepEqual(JSON.parse(response.body), { - error: 'A2A dispatch disposition rejected', - code: 'a2a_dispatch_disposition_replaced', - replacement, - }); - }); -}); diff --git a/packages/api/test/callback-cross-post-fail-closed.test.js b/packages/api/test/callback-cross-post-fail-closed.test.js index 2d36144bd1..ed2232acc9 100644 --- a/packages/api/test/callback-cross-post-fail-closed.test.js +++ b/packages/api/test/callback-cross-post-fail-closed.test.js @@ -21,6 +21,7 @@ function createMockSocketManager() { return { broadcastAgentMessage() {}, broadcastToRoom() {}, + emitToUser() {}, }; } @@ -111,14 +112,17 @@ describe('F193 AC-A4: cross-post fail-closed when no routing credentials', () => let invocationRecordStore; let mockRouter; let threadStore; + let invocationQueue; beforeEach(async () => { const { InvocationRegistry } = await import( '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' ); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); registry = new InvocationRegistry(); + invocationQueue = new InvocationQueue(); messageStore = new MessageStore(); socketManager = createMockSocketManager(); invocationRecordStore = createMockInvocationRecordStore(); @@ -145,6 +149,8 @@ describe('F193 AC-A4: cross-post fail-closed when no routing credentials', () => socketManager, router: mockRouter, invocationRecordStore, + invocationQueue, + queueProcessor: { async requestDrain() {} }, threadStore, ...opts, }); diff --git a/packages/api/test/callback-delivery.test.js b/packages/api/test/callback-delivery.test.js index 7d37986531..6358afdc22 100644 --- a/packages/api/test/callback-delivery.test.js +++ b/packages/api/test/callback-delivery.test.js @@ -13,21 +13,17 @@ function logger() { } describe('callback delivery decision helper', () => { - it('keeps queued message off live broadcast when A2A enqueue succeeds', async () => { + it('records a successful wake admission for already-public agent speech', async () => { const log = logger(); const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ canEnqueueA2A: true, - willEnqueueToQueue: true, messageId: 'm1', threadId: 't1', log, enqueueA2A: mock.fn(async () => ({ enqueued: ['opus'] })), - markDelivered: mock.fn(async () => null), - zeroEnqueuedWarnMessage: 'zero', enqueueFailureMessage: 'fail', }); - assert.equal(result.shouldBroadcastNow, false); assert.deepEqual(result.enqueued, ['opus']); assert.equal(result.enqueueAttempted, true); assert.equal(result.enqueueFailed, false); @@ -35,153 +31,73 @@ describe('callback delivery decision helper', () => { assert.equal(log.error.mock.calls.length, 0); }); - it('fails open to broadcast when a queued message enqueues zero targets', async () => { + it('reports zero handled targets as a failed wake admission', async () => { const log = logger(); - const markDelivered = mock.fn(async () => null); const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ canEnqueueA2A: true, - willEnqueueToQueue: true, messageId: 'm1', threadId: 't1', log, enqueueA2A: mock.fn(async () => ({ enqueued: [] })), - markDelivered, - zeroEnqueuedWarnMessage: 'zero', enqueueFailureMessage: 'fail', }); - assert.equal(result.shouldBroadcastNow, true); assert.deepEqual(result.enqueued, []); assert.equal(result.enqueueAttempted, true); - assert.equal(result.enqueueFailed, false); - assert.equal(markDelivered.mock.calls.length, 1); + assert.equal(result.enqueueFailed, true); assert.equal(log.error.mock.calls.length, 0); }); - it('preserves a rejected routing receipt through zero-target callback delivery', async () => { - const routingPreflight = { - v: 1, - ownerId: 'owner-1', - observedAt: 10, - resolverState: 'fresh', - snapshotRef: 'snapshot:1', - targets: [ - { - targetCatId: 'opus', - disposition: 'rejected', - reasons: [{ code: 'unavailable', summary: '暂不可用', sourceRefs: ['signal:1'] }], - alternatives: [], - }, - ], - }; - const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ - canEnqueueA2A: true, - willEnqueueToQueue: true, - messageId: 'm-routing', - threadId: 't1', - log: logger(), - enqueueA2A: mock.fn(async () => ({ enqueued: [], routingPreflight })), - zeroEnqueuedWarnMessage: 'routing blocked', - enqueueFailureMessage: 'fail', - }); - - assert.equal(result.shouldBroadcastNow, true); - assert.deepEqual(result.routingPreflight, routingPreflight); - }); - - it('fails open to broadcast when enqueue throws', async () => { + it('reports a thrown wake admission without changing publication state', async () => { const log = logger(); - const markDelivered = mock.fn(async () => null); const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ canEnqueueA2A: true, - willEnqueueToQueue: true, messageId: 'm1', threadId: 't1', log, enqueueA2A: mock.fn(async () => { throw new Error('boom'); }), - markDelivered, - zeroEnqueuedWarnMessage: 'zero', enqueueFailureMessage: 'fail', }); - assert.equal(result.shouldBroadcastNow, true); assert.deepEqual(result.enqueued, []); assert.equal(result.enqueueAttempted, true); assert.equal(result.enqueueFailed, true); - assert.equal(markDelivered.mock.calls.length, 1); assert.equal(log.error.mock.calls.length, 1); }); - it('keeps a typed obligation queued for replay when enqueue fails', async () => { - const log = logger(); - const markDelivered = mock.fn(async () => null); - const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ - canEnqueueA2A: true, - willEnqueueToQueue: true, - messageId: 'typed-review-handback', - threadId: 't1', - log, - enqueueA2A: mock.fn(async () => { - throw new Error('temporary queue outage'); - }), - markDelivered, - preserveQueuedOnEnqueueFailure: true, - zeroEnqueuedWarnMessage: 'zero', - enqueueFailureMessage: 'fail', - }); - - assert.equal(result.shouldBroadcastNow, false); - assert.equal(result.enqueueAttempted, true); - assert.equal(result.enqueueFailed, true); - assert.equal(markDelivered.mock.calls.length, 0, 'the durable typed obligation must remain queued for replay'); - }); - - it('still broadcasts non-queued messages after enqueueing A2A targets', async () => { + it('does not attempt wake admission when the callback has no eligible A2A targets', async () => { const log = logger(); const enqueueA2A = mock.fn(async () => ({ enqueued: ['opus'] })); const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ - canEnqueueA2A: true, - willEnqueueToQueue: false, + canEnqueueA2A: false, messageId: 'm1', threadId: 't1', log, enqueueA2A, - zeroEnqueuedWarnMessage: 'zero', enqueueFailureMessage: 'fail', }); - assert.equal(result.shouldBroadcastNow, true); - assert.deepEqual(result.enqueued, ['opus']); - assert.equal(result.enqueueAttempted, true); + assert.deepEqual(result.enqueued, []); + assert.equal(result.enqueueAttempted, false); assert.equal(result.enqueueFailed, false); - assert.equal(enqueueA2A.mock.calls.length, 1); + assert.equal(enqueueA2A.mock.calls.length, 0); }); - // F216 AC-D6: coalesced targets are handled — enqueued=0 + coalesced>0 must NOT - // trigger the zero-enqueued warn/recovery path. Message stays queued (not recovered). - it('does NOT warn or recover when enqueued=0 but coalesced>0 (F216 AC-D6)', async () => { + it('treats a coalesced target as an admitted wake (F216 AC-D6)', async () => { const log = logger(); - const markDelivered = mock.fn(async () => null); const result = await MessageDeliveryService.resolveCallbackDeliveryDecision({ canEnqueueA2A: true, - willEnqueueToQueue: true, messageId: 'm1', threadId: 't1', log, enqueueA2A: mock.fn(async () => ({ enqueued: [], coalesced: ['opus'] })), - markDelivered, - zeroEnqueuedWarnMessage: 'zero', enqueueFailureMessage: 'fail', }); - // Coalesced = handled, so message stays queued (not recovered/broadcast). - assert.equal(result.shouldBroadcastNow, false, 'coalesced = handled, no broadcast fallback'); assert.deepEqual(result.enqueued, []); assert.equal(result.enqueueAttempted, true); assert.equal(result.enqueueFailed, false); - // markDelivered must NOT be called (message still queued, not recovered). - assert.equal(markDelivered.mock.calls.length, 0, 'no recovery markDelivered for coalesced'); }); }); diff --git a/packages/api/test/callback-f177h-participant-check.test.js b/packages/api/test/callback-f177h-participant-check.test.js index 2d2ebb4652..d431693ee5 100644 --- a/packages/api/test/callback-f177h-participant-check.test.js +++ b/packages/api/test/callback-f177h-participant-check.test.js @@ -20,6 +20,7 @@ function createMockSocketManager() { return { broadcastAgentMessage() {}, broadcastToRoom() {}, + emitToUser() {}, }; } @@ -135,14 +136,17 @@ describe('F177-H: Cross-post participant check warning', () => { let mockRouter; let threadStore; let agentKeyRegistry; + let invocationQueue; beforeEach(async () => { const { InvocationRegistry } = await import( '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' ); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); registry = new InvocationRegistry(); + invocationQueue = new InvocationQueue(); messageStore = new MessageStore(); socketManager = createMockSocketManager(); invocationRecordStore = createMockInvocationRecordStore(); @@ -160,6 +164,8 @@ describe('F177-H: Cross-post participant check warning', () => { socketManager, router: mockRouter, invocationRecordStore, + invocationQueue, + queueProcessor: { async requestDrain() {} }, threadStore, agentKeyRegistry, }); diff --git a/packages/api/test/callback-f193-boundary.test.js b/packages/api/test/callback-f193-boundary.test.js index f99a3ec0a9..b08617fec7 100644 --- a/packages/api/test/callback-f193-boundary.test.js +++ b/packages/api/test/callback-f193-boundary.test.js @@ -20,6 +20,7 @@ function createMockSocketManager() { return { broadcastAgentMessage() {}, broadcastToRoom() {}, + emitToUser() {}, }; } @@ -92,14 +93,17 @@ describe('F193 AC-A5: KD-1 boundary regression', () => { let invocationRecordStore; let mockRouter; let threadStore; + let invocationQueue; beforeEach(async () => { const { InvocationRegistry } = await import( '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' ); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); registry = new InvocationRegistry(); + invocationQueue = new InvocationQueue(); messageStore = new MessageStore(); socketManager = createMockSocketManager(); invocationRecordStore = createMockInvocationRecordStore(); @@ -116,6 +120,8 @@ describe('F193 AC-A5: KD-1 boundary regression', () => { socketManager, router: mockRouter, invocationRecordStore, + invocationQueue, + queueProcessor: { async requestDrain() {} }, threadStore, }); return app; @@ -170,17 +176,15 @@ describe('F193 AC-A5: KD-1 boundary regression', () => { }); assert.equal(res.statusCode, 200, 'cross-post with line-start @opus must be accepted'); - // Verify the cross-post created an InvocationRecord for opus in target thread - // (would be 0 if same-cat self-filter wrongly applied to cross-thread) - const records = invocationRecordStore.getRecords(); - assert.ok( - records.length >= 1, - 'cross-thread @opus must trigger target-thread opus session (self-filter exemption)', - ); - const opusRecord = records.find((r) => r.targetCats?.includes('opus')); - assert.ok( - opusRecord, - 'InvocationRecord for opus in target thread must exist; self-filter must NOT drop @opus on cross-thread', + // Canonical lifecycle admission is Queue-owned. The no-op drain deliberately + // leaves the entry at ingress, before an InvocationRecord is created. + const queued = invocationQueue.list(targetThread.id, 'user-1'); + assert.equal(queued.length, 1, 'cross-thread @opus must admit one target-thread Queue entry'); + assert.equal(queued[0]?.kind, 'message_wake'); + assert.deepEqual( + queued.flatMap((entry) => entry.targets), + ['opus'], + 'same-name self-filter must NOT drop @opus on a cross-thread wake', ); }); }); diff --git a/packages/api/test/callback-hold-ball-route-scheduling.test.js b/packages/api/test/callback-hold-ball-route-scheduling.test.js index 9dac77c582..927f037200 100644 --- a/packages/api/test/callback-hold-ball-route-scheduling.test.js +++ b/packages/api/test/callback-hold-ball-route-scheduling.test.js @@ -37,6 +37,7 @@ describe('F167 C1: /api/callbacks/hold-ball scheduling + errors', () => { const registeredDynamic = []; const unregisteredIds = []; const removedIds = []; + const appendedMessages = []; const defaultTemplate = { createSpec(taskId, taskParams) { return { taskId, taskParams }; @@ -74,6 +75,7 @@ describe('F167 C1: /api/callbacks/hold-ball scheduling + errors', () => { // so hold_ball visibility broadcast doesn't emit warn noise in tests. messageStore: { async append(msg) { + appendedMessages.push(msg); return { id: `test-msg-${insertedTasks.length}`, ...msg }; }, }, @@ -84,6 +86,7 @@ describe('F167 C1: /api/callbacks/hold-ball scheduling + errors', () => { _registeredDynamic: registeredDynamic, _unregisteredIds: unregisteredIds, _removedIds: removedIds, + _appendedMessages: appendedMessages, }; return { ...deps, ...overrides }; } @@ -167,6 +170,8 @@ describe('F167 C1: /api/callbacks/hold-ball scheduling + errors', () => { assert.match(task.params.message, /CI still running/); assert.match(task.params.message, /check build status/); assert.equal(task.createdBy, 'hold-ball:codex'); + assert.equal(deps._appendedMessages.length, 1); + assert.equal(deps._appendedMessages[0].idempotencyKey, `hold-ball-waiting:${body.taskId}`); }); test('410 on soft-deleted invocation thread — does not schedule wake task', async () => { @@ -242,6 +247,13 @@ describe('F167 C1: /api/callbacks/hold-ball scheduling + errors', () => { assert.equal(liveTasks[0].id, secondTaskId); assert.match(liveTasks[0].params.message, /wait-B/); assert.match(liveTasks[0].params.message, /continue-B/); + const replacementTerminal = deps._appendedMessages.find( + (message) => message.idempotencyKey === `hold-ball-terminal:${firstTaskId}:retired_by_replacement`, + ); + assert.ok(replacementTerminal, 'replacement must persist a terminal fact for the retired hold'); + assert.equal(replacementTerminal.userId, 'user-hb-replace'); + assert.equal(replacementTerminal.source.meta.taskId, firstTaskId); + assert.equal(replacementTerminal.source.meta.outcome, 'retired_by_replacement'); }); test('F167-G cloud P1: registerDynamic failure rolls back new insert and retains prior hold (atomic swap)', async () => { @@ -307,6 +319,150 @@ describe('F167 C1: /api/callbacks/hold-ball scheduling + errors', () => { ); }); + test('F167-G: waiting History failure rolls back the new hold and retains the prior wake', async () => { + let failWaitingAppend = false; + const deps = makeStubDeps({ + messageStore: { + async append(msg) { + if (failWaitingAppend) throw new Error('simulated History failure'); + deps._appendedMessages.push(msg); + return { id: `test-msg-${deps._appendedMessages.length}`, ...msg }; + }, + }, + }); + const app = await createApp(deps); + const thread = await threadStore.create('user-hb-history-rollback', 'hb-history-rollback'); + const { invocationId, callbackToken } = await registry.create('user-hb-history-rollback', 'codex', thread.id); + const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }; + + const first = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers, + payload: { reason: 'wait-A', nextStep: 'continue-A', wakeAfterMs: 10_000, waitSourceRef: VALID_WAIT_SOURCE_REF }, + }); + assert.equal(first.statusCode, 200); + const firstTaskId = JSON.parse(first.body).taskId; + const firstInsertCount = deps._insertedTasks.length; + + failWaitingAppend = true; + const second = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers, + payload: { reason: 'wait-B', nextStep: 'continue-B', wakeAfterMs: 20_000, waitSourceRef: VALID_WAIT_SOURCE_REF }, + }); + + assert.equal(second.statusCode, 503, 'a hold without its persistent waiting fact must fail closed'); + assert.equal(deps.dynamicTaskStore.getAll().length, 1); + assert.equal(deps.dynamicTaskStore.getAll()[0].id, firstTaskId, 'the prior wake remains authoritative'); + assert.equal(deps._insertedTasks.length, firstInsertCount + 1); + const rolledBackTaskId = deps._insertedTasks[firstInsertCount].id; + assert.ok(deps._unregisteredIds.includes(rolledBackTaskId)); + assert.ok(deps._removedIds.includes(rolledBackTaskId)); + assert.ok(!deps._unregisteredIds.includes(firstTaskId), 'the prior wake is not cancelled before History commits'); + }); + + test('F167-G: a committed waiting row survives a lost append acknowledgement', async () => { + const committed = new Map(); + const deps = makeStubDeps({ + messageStore: { + async append(msg) { + const stored = { id: `test-msg-${committed.size + 1}`, ...msg }; + committed.set(msg.idempotencyKey, stored); + throw new Error('waiting commit acknowledgement lost'); + }, + getByIdempotencyKey(_userId, _threadId, key) { + return committed.get(key) ?? null; + }, + }, + }); + const app = await createApp(deps); + const thread = await threadStore.create('user-hb-history-committed', 'hb-history-committed'); + const { invocationId, callbackToken } = await registry.create('user-hb-history-committed', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'wait after committed write', + nextStep: 'resume from the durable waiting row', + wakeAfterMs: 10_000, + waitSourceRef: VALID_WAIT_SOURCE_REF, + }, + }); + + assert.equal(response.statusCode, 200); + assert.equal(committed.size, 1); + assert.equal(deps.dynamicTaskStore.getAll().length, 1, 'the recoverable scheduler carrier must remain live'); + assert.equal(deps._removedIds.length, 0, 'a committed waiting fact must not be rolled back'); + }); + + test('F167-G: an ambiguous waiting write preserves its recoverable carrier and returns typed 503', async () => { + const deps = makeStubDeps({ + messageStore: { + async append() { + throw new Error('waiting write outcome unknown'); + }, + async getByIdempotencyKey() { + throw new Error('waiting lookup unavailable'); + }, + }, + }); + const app = await createApp(deps); + const thread = await threadStore.create('user-hb-history-unknown', 'hb-history-unknown'); + const { invocationId, callbackToken } = await registry.create('user-hb-history-unknown', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'wait through an ambiguous write', + nextStep: 'recover from the durable carrier', + wakeAfterMs: 10_000, + waitSourceRef: VALID_WAIT_SOURCE_REF, + }, + }); + + assert.equal(response.statusCode, 503); + assert.equal(JSON.parse(response.body).code, 'HOLD_WAITING_HISTORY_OUTCOME_UNKNOWN'); + assert.equal(deps.dynamicTaskStore.getAll().length, 1, 'unknown persistence must retain scheduler custody'); + assert.equal(deps._removedIds.length, 0); + assert.equal(deps._unregisteredIds.length, 0); + }); + + test('F167-G: a live broadcast failure does not roll back a durably persisted waiting fact', async () => { + const deps = makeStubDeps({ + socketManager: { + broadcastToRoom() { + throw new Error('simulated socket failure'); + }, + }, + }); + const app = await createApp(deps); + const thread = await threadStore.create('user-hb-broadcast-failure', 'hb-broadcast-failure'); + const { invocationId, callbackToken } = await registry.create('user-hb-broadcast-failure', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'wait despite socket loss', + nextStep: 'continue from durable History', + wakeAfterMs: 10_000, + waitSourceRef: VALID_WAIT_SOURCE_REF, + }, + }); + + assert.equal(response.statusCode, 200); + assert.equal(deps._appendedMessages.length, 1); + assert.equal(deps.dynamicTaskStore.getAll().length, 1); + assert.equal(deps._removedIds.length, 0); + }); + test('F167-G AC-G3: different cats in same thread do NOT cancel each others holds', async () => { // Single-slot is PER (threadId, catId). Two different cats holding in the // same thread must both have their own pending wake. diff --git a/packages/api/test/callback-hold-ball-route.test.js b/packages/api/test/callback-hold-ball-route.test.js index d59c086444..d98ce2b493 100644 --- a/packages/api/test/callback-hold-ball-route.test.js +++ b/packages/api/test/callback-hold-ball-route.test.js @@ -52,9 +52,18 @@ describe('F167 C1: /api/callbacks/hold-ball auth + body validation', () => { getAll() { return insertedTasks; }, + getById(id) { + return insertedTasks.find((task) => task.id === id); + }, remove() { return true; }, + updateParams(id, params) { + const task = insertedTasks.find((candidate) => candidate.id === id); + if (!task) return false; + task.params = params; + return true; + }, }, messageStore: { async append(msg) { @@ -64,6 +73,14 @@ describe('F167 C1: /api/callbacks/hold-ball auth + body validation', () => { socketManager: { broadcastToRoom() {}, }, + managedCommandWakeRecovery: { + async recordCompletion() { + return 'consumed'; + }, + async recordLost() { + return 'consumed'; + }, + }, _insertedTasks: insertedTasks, _registeredDynamic: registeredDynamic, }; diff --git a/packages/api/test/callback-hold-ball-wakewhen.test.js b/packages/api/test/callback-hold-ball-wakewhen.test.js index df99719551..302450ae4a 100644 --- a/packages/api/test/callback-hold-ball-wakewhen.test.js +++ b/packages/api/test/callback-hold-ball-wakewhen.test.js @@ -501,17 +501,21 @@ describe('F167 Phase P: wakeWhen cancel/replace/delivery tests', () => { assert.equal(managedHold.params.holdLifecycle.status, 'active'); assert.equal(triggerCount, 0, 'ordinary mention cannot dequeue or wake the older holder'); + // F117 (ef94412a5): invokeTrigger success now means admitted into Queue + // custody — the routes layer maps 'dispatched' → 'enqueued' before the + // recovery sweep persists the state. 'dispatched' was the pre-F117 name + // for the same "trigger accepted, wake under Queue custody" terminal. const deadline = Date.now() + 3_000; - while (managedHold?.params.holdLifecycle?.managedCommand?.state !== 'dispatched' && Date.now() < deadline) { + while (managedHold?.params.holdLifecycle?.managedCommand?.state !== 'enqueued' && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 20)); managedHold = deps.dynamicTaskStore.getById(taskId); } await app.close(); - assert.ok(managedHold, 'the typed wake remains queryable until invocation-bound disposition'); + assert.ok(managedHold, 'the typed wake remains queryable until its canonical response terminal'); assert.equal(managedHold.enabled, true); assert.equal(managedHold.params.holdLifecycle.status, 'active'); - assert.equal(managedHold.params.holdLifecycle.managedCommand.state, 'dispatched'); + assert.equal(managedHold.params.holdLifecycle.managedCommand.state, 'enqueued'); assert.equal( managedHold.params.holdLifecycle.managedCommand.result.cancelled, false, @@ -524,13 +528,50 @@ describe('F167 Phase P: wakeWhen cancel/replace/delivery tests', () => { assert.equal( wakeReceipts[0].deliveryStatus, 'queued', - 'the wake remains under Queue custody until invocation-bound disposition', + 'the wake remains under Queue custody until its canonical response terminal', ); assert.equal(wakeReceipts[0].idempotencyKey, `hold-ball-completion:${taskId}`); assert.equal(wakeReceipts[0].source?.meta?.taskId, taskId); assert.match(wakeReceipts[0].content, /completed-after-user-message/); }); + test('ordinary user-message retirement persists one owner-bound terminal fact', async () => { + const deps = makeStubDeps(); + deps._insertedTasks.push({ + id: 'hold-ball-auto-retire', + templateId: 'reminder', + trigger: { type: 'once', fireAt: Date.now() + 60_000 }, + params: { + message: 'resume later', + targetCatId: 'codex', + triggerUserId: 'owner-user', + holdLifecycle: { mode: 'timer', status: 'active', createdBy: 'hold-ball:codex' }, + }, + display: { label: 'hold', category: 'system', description: 'hold', subjectKind: 'none' }, + deliveryThreadId: 'thread-auto-retire', + enabled: true, + createdBy: 'hold-ball:codex', + createdAt: new Date().toISOString(), + }); + + await tryAutoCancelPendingHolds('thread-auto-retire', deps); + + assert.deepEqual(deps._removedIds, ['hold-ball-auto-retire']); + const terminal = deps._appendedMessages.find( + (message) => message.idempotencyKey === 'hold-ball-terminal:hold-ball-auto-retire:retired_by_user_message', + ); + assert.ok(terminal); + assert.equal(terminal.userId, 'owner-user'); + assert.deepEqual(terminal.source.meta, { + managedHold: true, + phase: 'terminal', + taskId: 'hold-ball-auto-retire', + threadId: 'thread-auto-retire', + catId: 'codex', + outcome: 'retired_by_user_message', + }); + }); + test('T8a: a completed command waits behind cancellation reservation until release', async () => { let completionCount = 0; const deps = makeStubDeps({ @@ -747,9 +788,12 @@ describe('F167 Phase P: wakeWhen cancel/replace/delivery tests', () => { assert.strictEqual(body.wakeWhen.pid, null, 'failed admission must report pid: null'); assert.equal(getActiveRunnerCount(), 0, 'no active runner residue after pre-admission failure'); - // Durable admission-fact: a "未启动" message must be appended - const failMessages = deps._appendedMessages.filter((m) => m.content.includes('未启动')); - assert.ok(failMessages.length > 0, 'spawn failure admission fact (未启动) must be durably projected'); + // Spawn failure owns exactly one truthful terminal fact. The periodic + // recovery sweep must not later invent a second "service restart" end. + const terminalMessages = deps._appendedMessages.filter((m) => m.source?.meta?.phase === 'status'); + assert.equal(terminalMessages.length, 1, 'spawn failure must project exactly one terminal status'); + assert.match(terminalMessages[0].content, /未启动/); + assert.doesNotMatch(terminalMessages[0].content, /服务重启/); // P1-2: DynamicTask lifecycle projection must have pid=null for spawn failure const task = deps.dynamicTaskStore.getById(body.taskId); @@ -757,6 +801,99 @@ describe('F167 Phase P: wakeWhen cancel/replace/delivery tests', () => { const mc = task.params.holdLifecycle?.managedCommand; assert.ok(mc, 'managedCommand projection must exist'); assert.strictEqual(mc.pid, null, 'spawn failure must persist pid=null in lifecycle projection'); + assert.equal(mc.state, 'consumed', 'spawn failure must retire instead of waiting for the restart sweep'); + assert.equal(mc.lostReason, 'spawn_failed'); + assert.equal(mc.carrierTerminalReason, 'failed'); + assert.equal(task.enabled, false); + + await app.close(); + } finally { + ManagedRunner.prototype.start = origStart; + } + }); + + test('T8c-3: rejected spawn admission writes one spawn terminal and retires the task', async () => { + const { ManagedRunner } = await import('../dist/infrastructure/managed-runner.js'); + const origStart = ManagedRunner.prototype.start; + ManagedRunner.prototype.start = () => ({ + admission: Promise.resolve({ spawned: false, pid: null, error: 'spawn ENOENT' }), + completion: Promise.resolve({ exitCode: null, timedOut: false, durationMs: 1 }), + }); + + try { + const deps = makeStubDeps(); + const app = await createApp(deps); + const ownerUserId = 'user-spawn-reject'; + const thread = await threadStore.create(ownerUserId, 'spawn-reject'); + const { invocationId, callbackToken } = await registry.create(ownerUserId, 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'spawn rejection test', + nextStep: 'verify single terminal', + wakeWhen: { command: 'missing-command' }, + }, + }); + + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + const terminalMessages = deps._appendedMessages.filter((m) => m.source?.meta?.phase === 'status'); + assert.equal(terminalMessages.length, 1); + assert.match(terminalMessages[0].content, /未启动.*spawn ENOENT/); + assert.doesNotMatch(terminalMessages[0].content, /服务重启/); + const task = deps.dynamicTaskStore.getById(body.taskId); + assert.equal(task.params.holdLifecycle.managedCommand.state, 'consumed'); + assert.equal(task.params.holdLifecycle.managedCommand.lostReason, 'spawn_failed'); + assert.equal(task.enabled, false); + assert.equal(getActiveRunnerCount(), 0); + + await app.close(); + } finally { + ManagedRunner.prototype.start = origStart; + } + }); + + test('T8c-4: post-spawn runner failure records a truthful runner terminal', async () => { + const { ManagedRunner } = await import('../dist/infrastructure/managed-runner.js'); + const origStart = ManagedRunner.prototype.start; + ManagedRunner.prototype.start = () => ({ + admission: Promise.resolve({ spawned: true, pid: 42 }), + completion: Promise.reject(new Error('runner pipe failed')), + }); + + try { + const deps = makeStubDeps(); + const app = await createApp(deps); + const ownerUserId = 'user-runner-failure'; + const thread = await threadStore.create(ownerUserId, 'runner-failure'); + const { invocationId, callbackToken } = await registry.create(ownerUserId, 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'runner failure test', + nextStep: 'verify truthful terminal', + wakeWhen: { command: 'unstable-command' }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + const terminalMessages = deps._appendedMessages.filter((m) => m.source?.meta?.phase === 'status'); + assert.equal(terminalMessages.length, 1); + assert.match(terminalMessages[0].content, /执行进程异常终止.*runner pipe failed/); + assert.doesNotMatch(terminalMessages[0].content, /服务重启/); + const task = deps.dynamicTaskStore.getById(body.taskId); + assert.equal(task.params.holdLifecycle.managedCommand.state, 'consumed'); + assert.equal(task.params.holdLifecycle.managedCommand.lostReason, 'runner_failed'); + assert.equal(task.enabled, false); + assert.equal(getActiveRunnerCount(), 0); await app.close(); } finally { @@ -1011,352 +1148,6 @@ describe('F167 Phase P: wakeWhen cancel/replace/delivery tests', () => { ); }); - test('T10b: admission-fact append failure is recoverable via startup sweep — both live and dead pid converge', async () => { - // RED proof: freeze a real command in command_running, fail ONLY the - // admission-fact append, tear down the original callback owner, construct - // a fresh sweep, and prove the admission fact is re-delivered exactly once - // for both an OS-live and dead process — with zero command re-execution. - // - // Key: use `sleep 60` so the command CANNOT naturally complete during - // the test. This prevents the false-positive where natural completion - // transitions the task out of command_running before the sweep runs. - let appendCount = 0; - const deps = makeStubDeps({ - messageStore: { - getByIdempotencyKey() { - return null; - }, - async append(msg) { - appendCount++; - // First append = visibility "待启动" message → succeed - if (appendCount === 1) return { id: `msg-${appendCount}`, ...msg }; - // Second append = admission-fact → FAIL (simulated durability gap) - if (appendCount === 2) throw new Error('simulated admission-fact append failure'); - // Third+ = completion etc → succeed - return { id: `msg-${appendCount}`, ...msg }; - }, - }, - }); - const app = await createApp(deps); - const thread = await threadStore.create('user-hb-t10b', 'hb-t10b'); - const { invocationId, callbackToken } = await registry.create('user-hb-t10b', 'codex', thread.id); - - const response = await app.inject({ - method: 'POST', - url: '/api/callbacks/hold-ball', - headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - payload: { - reason: 'admission append failure recovery', - nextStep: 'verify durable recovery', - wakeWhen: { command: 'sleep 60' }, - }, - }); - - assert.equal(response.statusCode, 200, 'route must succeed despite admission-fact append failure'); - const body = JSON.parse(response.body); - assert.ok(body.wakeWhen.pid > 0, 'HTTP response must carry real pid from spawn'); - - // ── Pre-sweep assertions: freeze the state BEFORE any recovery ── - const task = deps.dynamicTaskStore.getById(body.taskId); - assert.ok(task, 'DynamicTask must still exist'); - const mc = task.params.holdLifecycle?.managedCommand; - assert.ok(mc, 'managedCommand projection must exist'); - assert.ok(mc.pid > 0, 'managedCommand.pid must record real pid from spawn'); - assert.equal(mc.state, 'command_running', 'pre-sweep: state must be command_running (sleep 60 is still alive)'); - assert.equal(mc.admissionFactAppended, false, 'pre-sweep: admissionFactAppended must be false (append failed)'); - assert.ok( - typeof mc.admissionFact === 'string' && mc.admissionFact.length > 0, - 'pre-sweep: admissionFact text must be persisted for recovery', - ); - // Verify no admission-fact message was actually delivered (append #2 threw) - const admissionMessages = deps._appendedMessages.filter((m) => m.content.includes('已启动')); - assert.equal(admissionMessages.length, 0, 'pre-sweep: no admission-fact message in thread (append failed)'); - - // ── Tear down the original callback/IIFE owner ── - // Kill the sleep process to simulate server restart (process dies) - try { - process.kill(body.wakeWhen.pid, 'SIGKILL'); - } catch { - /* may already be gone */ - } - await app.close(); - - // ── Sweep: re-deliver admission fact for dead pid ── - const { ManagedCommandWakeRecoverySweep } = await import( - '../dist/domains/ball-custody/ManagedCommandWakeRecoverySweep.js' - ); - const sweepAppended = []; - const sweepBroadcasts = []; - const makeSweepDeps = () => ({ - dynamicTaskStore: deps.dynamicTaskStore, - messageStore: { - getById: () => null, - getByIdempotencyKey: () => null, - async append(msg) { - const stored = { id: `sweep-msg-${sweepAppended.length}`, ...msg }; - sweepAppended.push(stored); - return stored; - }, - markCanceled: async () => null, - }, - socketManager: { - broadcastToRoom(_room, _event, payload) { - sweepBroadcasts.push(payload); - }, - }, - taskRunner: { unregister() {} }, - invocationRecordStore: { getByIdempotencyKey: () => null }, - getInvokeTrigger: () => undefined, - now: () => Date.now(), - }); - - const sweep = new ManagedCommandWakeRecoverySweep(makeSweepDeps()); - const stats = await sweep.runOnce(); - - // ── Post-sweep: admission fact re-delivered ── - assert.ok(stats.scanned > 0, 'sweep must scan the undelivered admission task'); - assert.ok(stats.recovered > 0, 'sweep must recover the admission fact'); - assert.equal(sweepAppended.length, 1, 'exactly one admission-fact message re-delivered'); - assert.ok( - sweepAppended[0].content.includes('已启动'), - 'recovered message must contain the original admission fact', - ); - assert.ok(sweepBroadcasts.length > 0, 'admission fact must be broadcast to thread'); - - // Task lifecycle: admissionFactAppended = true, state still command_running - // (sweep does NOT synthesize a completion — the fallback timer handles that) - const taskAfterSweep = deps.dynamicTaskStore.getById(body.taskId); - assert.ok(taskAfterSweep, 'task must still exist (no re-execution, no deletion)'); - const mcAfter = taskAfterSweep.params.holdLifecycle?.managedCommand; - assert.equal(mcAfter?.admissionFactAppended, true, 'admissionFactAppended must be true after recovery'); - - // ── Idempotency: second sweep must NOT re-deliver ── - sweepAppended.length = 0; - const sweep2 = new ManagedCommandWakeRecoverySweep(makeSweepDeps()); - await sweep2.runOnce(); - assert.equal(sweepAppended.length, 0, 'idempotent: second sweep must not re-append'); - // The task is no longer in the undelivered filter (admissionFactAppended=true) - // so undeliveredAdmission count is 0, total scanned excludes it - }); - - test('T10c: R6 P1-1 — route→sweep chain: crash after admission append, before flag, no duplicate', async () => { - // RED proof: go through the actual route to produce the admission-fact - // message (with shared idempotencyKey from buildAdmissionFactIdempotencyKey), - // block the admissionFactAppended=true CAS to simulate a crash at that - // boundary, then run the sweep with the SAME message store. The sweep must - // find the existing message by shared key and skip re-append — exactly one - // admission fact in the timeline. If the route's key is deleted or drifted, - // the sweep's getByIdempotencyKey finds nothing and re-appends a duplicate. - const { ManagedCommandWakeRecoverySweep, buildAdmissionFactIdempotencyKey } = await import( - '../dist/domains/ball-custody/ManagedCommandWakeRecoverySweep.js' - ); - - const deps = makeStubDeps(); - - // Intercept: block the admissionFactAppended=true update to simulate crash - const originalUpdateParams = deps.dynamicTaskStore.updateParams; - deps.dynamicTaskStore.updateParams = (id, params) => { - if (params?.holdLifecycle?.managedCommand?.admissionFactAppended === true) { - return false; // Simulate crash at exactly this boundary - } - return originalUpdateParams(id, params); - }; - - const app = await createApp(deps); - const thread = await threadStore.create('user-hb-t10c', 'hb-t10c'); - const { invocationId, callbackToken } = await registry.create('user-hb-t10c', 'codex', thread.id); - - const response = await app.inject({ - method: 'POST', - url: '/api/callbacks/hold-ball', - headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - payload: { - reason: 'idempotency dedup proof', - nextStep: 'verify no duplicate', - wakeWhen: { command: 'sleep 60' }, - }, - }); - - assert.equal(response.statusCode, 200, 'route must succeed'); - const body = JSON.parse(response.body); - const taskId = body.taskId; - - // ── Pre-sweep: verify the route produced the correct durable state ── - const task = deps.dynamicTaskStore.getById(taskId); - const mc = task.params.holdLifecycle?.managedCommand; - assert.equal(mc.admissionFactAppended, false, 'pre-sweep: flag must be false (update was blocked)'); - assert.ok(mc.admissionFact, 'pre-sweep: admissionFact text must be persisted'); - - // Verify the route's append carried the canonical shared idempotency key - const canonicalKey = buildAdmissionFactIdempotencyKey(taskId); - const admissionMessages = deps._appendedMessages.filter((m) => m.content.includes('已启动')); - assert.equal(admissionMessages.length, 1, 'route must have appended exactly one admission message'); - assert.equal( - admissionMessages[0].idempotencyKey, - canonicalKey, - 'route admission message must carry the canonical shared idempotency key', - ); - - // ── Tear down route owner ── - try { - process.kill(body.wakeWhen.pid, 'SIGKILL'); - } catch { - /* may already be gone */ - } - await app.close(); - - // ── Sweep: reuse same message store — must find existing by key ── - const sweepAppended = []; - const sweepDeps = { - dynamicTaskStore: deps.dynamicTaskStore, - messageStore: { - getById: () => null, - getByIdempotencyKey(_userId, threadId, key) { - // Shared message store: the route's message is findable by key - return deps._appendedMessages.find((m) => m.threadId === threadId && m.idempotencyKey === key) ?? null; - }, - async append(msg) { - const stored = { id: `sweep-msg-${sweepAppended.length}`, ...msg }; - sweepAppended.push(stored); - return stored; - }, - markCanceled: async () => null, - }, - socketManager: { broadcastToRoom() {} }, - taskRunner: { unregister() {} }, - invocationRecordStore: { getByIdempotencyKey: () => null }, - getInvokeTrigger: () => undefined, - now: () => Date.now(), - }; - - const sweep = new ManagedCommandWakeRecoverySweep(sweepDeps); - await sweep.runOnce(); - - // ── Assertions: no duplicate, flag converged ── - assert.equal(sweepAppended.length, 0, 'sweep must NOT re-append — route message found by shared key'); - - const taskAfter = deps.dynamicTaskStore.getById(taskId); - const mcAfter = taskAfter.params.holdLifecycle?.managedCommand; - assert.equal(mcAfter.admissionFactAppended, true, 'admissionFactAppended must converge to true'); - - // Timeline integrity: exactly one admission-fact message total - const totalAdmission = [...deps._appendedMessages, ...sweepAppended].filter((m) => m.content.includes('已启动')); - assert.equal(totalAdmission.length, 1, 'timeline must contain exactly one admission-fact message'); - }); - - test('T10d: R6 P1-2 — condition_met + admissionFactAppended=false restart: admission fact before completion dispatch', async () => { - // RED proof: when a task reaches condition_met while admissionFactAppended - // is false (append crashed + command completed before restart), the sweep - // must re-deliver the admission fact BEFORE publishing the completion - // message. Without the R6 ordering fix, completion publishes first and the - // provider is woken before the timeline shows what happened at spawn. - const { ManagedCommandWakeRecoverySweep } = await import( - '../dist/domains/ball-custody/ManagedCommandWakeRecoverySweep.js' - ); - - const taskId = 'hold-ball-t10d'; - const threadId = 'thread-t10d'; - const admissionFactText = '✅ 已启动 (pid 99999),等待完成…'; - const completionContent = '命令完成 (exit 0, 1200ms)'; - - const insertedTasks = [ - { - id: taskId, - templateId: 'reminder', - enabled: true, - deliveryThreadId: threadId, - createdBy: 'hold-ball:codex', - params: { - triggerUserId: 'user-t10d', - holdLifecycle: { - status: 'active', - mode: 'wake_when', - createdBy: 'hold-ball:codex', - managedCommand: { - state: 'condition_met', - command: 'pnpm test', - startedAt: Date.now() - 30000, - conditionMetAt: Date.now() - 5000, - pid: 99999, - admissionFact: admissionFactText, - admissionFactAppended: false, - wakeContent: completionContent, - wakeSource: 'command_completion', - result: { exitCode: 0, timedOut: false, durationMs: 1200 }, - }, - }, - }, - }, - ]; - - const appendOrder = []; - let appendSeq = 0; - const deps = { - dynamicTaskStore: { - getAll() { - return insertedTasks; - }, - getById(id) { - return insertedTasks.find((t) => t.id === id); - }, - updateParamsIfCurrent(id, expected, params) { - const task = insertedTasks.find((t) => t.id === id); - if (!task || task.params !== expected) return false; - task.params = params; - return true; - }, - setEnabled(id, enabled) { - const task = insertedTasks.find((t) => t.id === id); - if (!task) return false; - task.enabled = enabled; - return true; - }, - }, - messageStore: { - getById: () => null, - getByIdempotencyKey: () => null, - async append(msg) { - appendSeq++; - const stored = { id: `msg-${appendSeq}`, ...msg }; - appendOrder.push({ - seq: appendSeq, - content: stored.content, - userId: stored.userId, - }); - return stored; - }, - markCanceled: async () => null, - }, - socketManager: { broadcastToRoom() {} }, - taskRunner: { unregister() {} }, - invocationRecordStore: { getByIdempotencyKey: () => null }, - getInvokeTrigger: () => undefined, - now: () => Date.now(), - }; - - const sweep = new ManagedCommandWakeRecoverySweep(deps); - await sweep.runOnce(); - - // At least 2 messages: admission fact + completion - assert.ok(appendOrder.length >= 2, `expected at least 2 messages but got ${appendOrder.length}`); - - // Find admission and completion messages by content - const admissionIdx = appendOrder.findIndex((m) => m.content.includes('已启动')); - const completionIdx = appendOrder.findIndex((m) => m.content.includes('[定时任务]')); - - assert.ok(admissionIdx >= 0, 'admission-fact message must be appended'); - assert.ok(completionIdx >= 0, 'completion message must be appended'); - assert.ok( - admissionIdx < completionIdx, - `admission fact (seq=${appendOrder[admissionIdx]?.seq}) must be appended BEFORE ` + - `completion (seq=${appendOrder[completionIdx]?.seq})`, - ); - - // After sweep, admissionFactAppended must be true - const mcAfter = insertedTasks[0].params.holdLifecycle.managedCommand; - assert.equal(mcAfter.admissionFactAppended, true, 'admissionFactAppended must be true after recovery'); - }); - test('T12: queue-full trigger keeps fallback reminder alive after completion message is written', async () => { const deps = makeStubDeps({ invokeTrigger: { diff --git a/packages/api/test/callback-propose-profile-update-origin.test.js b/packages/api/test/callback-propose-profile-update-origin.test.js index f5aa82fbd9..ebbe88afea 100644 --- a/packages/api/test/callback-propose-profile-update-origin.test.js +++ b/packages/api/test/callback-propose-profile-update-origin.test.js @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('F231 approval origin authentication', () => { let app; @@ -48,14 +49,16 @@ describe('F231 approval origin authentication', () => { }); async function appendUserMessage(content) { - return messageStore.append({ - userId: 'alice', - catId: null, - content, - mentions: [], - timestamp: Date.now(), - threadId: 'thread_1', - }); + return messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content, + mentions: [], + timestamp: Date.now(), + threadId: 'thread_1', + }), + ); } async function propose(sourceMessageId, originTriggerMessageId, payload = {}) { diff --git a/packages/api/test/callback-propose-profile-update-routes.test.js b/packages/api/test/callback-propose-profile-update-routes.test.js index 0342323d79..20b6317042 100644 --- a/packages/api/test/callback-propose-profile-update-routes.test.js +++ b/packages/api/test/callback-propose-profile-update-routes.test.js @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; // F231 Phase C Task3: cat-side propose-profile-update callback route. // Pins the current primer as beforeContent + baseContentHash (P1-2 optimistic-lock base), @@ -29,14 +30,16 @@ describe('callback propose-profile-update route', () => { const key = body.clientRequestId ? `${userId}:${catId}:${threadId}:${body.clientRequestId}` : undefined; let origin = key ? originByRequest.get(key) : undefined; if (!origin) { - origin = messageStore.append({ - userId, - catId: null, - content: 'Please update the profile', - mentions: [], - timestamp: Date.now(), - threadId, - }); + origin = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: 'Please update the profile', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); if (key) originByRequest.set(key, origin); } const { invocationId, callbackToken } = await registry.create(userId, catId, threadId, undefined, origin.id); @@ -224,29 +227,33 @@ describe('callback propose-profile-update route', () => { signalProvenance: { kind: 'cat-declared', sourceThreadId: 'thread_1' }, createdBy: 'alice', }); - const cardMessage = await messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'visible profile update card', - mentions: [], - timestamp: Date.now(), - threadId: 'thread_1', - extra: { - rich: { - v: 1, - blocks: [{ id: `profile-update-${existingProposalId}`, kind: 'card', v: 1, title: 'Profile update' }], - }, - }, - }); - for (let i = 0; i < 600; i += 1) { - await messageStore.append({ + const cardMessage = await messageStore.append( + canonicalTestMessageInput({ userId: 'alice', - catId: null, - content: `newer ${i}`, + catId: 'opus', + content: 'visible profile update card', mentions: [], - timestamp: Date.now() + i + 1, + timestamp: Date.now(), threadId: 'thread_1', - }); + extra: { + rich: { + v: 1, + blocks: [{ id: `profile-update-${existingProposalId}`, kind: 'card', v: 1, title: 'Profile update' }], + }, + }, + }), + ); + for (let i = 0; i < 600; i += 1) { + await messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: `newer ${i}`, + mentions: [], + timestamp: Date.now() + i + 1, + threadId: 'thread_1', + }), + ); } const res = await propose({ diff --git a/packages/api/test/callback-propose-profile-update-socket-event.test.js b/packages/api/test/callback-propose-profile-update-socket-event.test.js index 03ad247e30..53598261b2 100644 --- a/packages/api/test/callback-propose-profile-update-socket-event.test.js +++ b/packages/api/test/callback-propose-profile-update-socket-event.test.js @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; // F246 v2: proposal_created socket event regression (cloud review P2). // Ensures F231 emits the generic proposal_created event that the Approval Hub @@ -29,14 +30,16 @@ describe('F246 v2: proposal_created socket event for F231', () => { const key = body.clientRequestId ? `${userId}:${catId}:${threadId}:${body.clientRequestId}` : undefined; let origin = key ? originByRequest.get(key) : undefined; if (!origin) { - origin = messageStore.append({ - userId, - catId: null, - content: 'Please update the profile', - mentions: [], - timestamp: Date.now(), - threadId, - }); + origin = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: 'Please update the profile', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); if (key) originByRequest.set(key, origin); } const { invocationId, callbackToken } = await registry.create(userId, catId, threadId, undefined, origin.id); diff --git a/packages/api/test/callback-routes-agent-key.test.js b/packages/api/test/callback-routes-agent-key.test.js index 6ce2c6b7b1..6c7e5a9962 100644 --- a/packages/api/test/callback-routes-agent-key.test.js +++ b/packages/api/test/callback-routes-agent-key.test.js @@ -5,6 +5,7 @@ import path from 'node:path'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { const messages = []; @@ -425,15 +426,17 @@ describe('Callback routes: agent-key auth path', () => { test('thread-context with agent-key can read owned soft-deleted thread tombstones', async () => { const deletedThread = await threadStore.create(TEST_USER, 'Deleted Readable Thread'); - messageStore.append({ - userId: TEST_USER, - catId: TEST_CAT, - content: 'context survives deletion', - mentions: [], - origin: 'callback', - timestamp: Date.now(), - threadId: deletedThread.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: TEST_USER, + catId: TEST_CAT, + content: 'context survives deletion', + mentions: [], + origin: 'callback', + timestamp: Date.now(), + threadId: deletedThread.id, + }), + ); assert.equal(await threadStore.softDelete(deletedThread.id), true); const app = await createApp(); const { secret } = await issueKey(); @@ -527,17 +530,19 @@ describe('Callback routes: agent-key auth path', () => { const app = await createApp(); const { secret } = await issueKey(); - const queued = messageStore.append({ - userId: TEST_USER, - catId: TEST_CAT, - content: 'same queued smoke report', - mentions: [], - origin: 'callback', - timestamp: Date.now(), - threadId: ownedThreadId, - deliveryStatus: 'queued', - extra: { isExplicitPost: true }, - }); + const queued = messageStore.append( + canonicalTestMessageInput({ + userId: TEST_USER, + catId: TEST_CAT, + content: 'same queued smoke report', + mentions: [], + origin: 'callback', + timestamp: Date.now(), + threadId: ownedThreadId, + deliveryStatus: 'queued', + extra: { isExplicitPost: true }, + }), + ); const res = await app.inject({ method: 'POST', diff --git a/packages/api/test/callback-routes.test.js b/packages/api/test/callback-routes.test.js index 7f6bb3f569..69ed2c90c0 100644 --- a/packages/api/test/callback-routes.test.js +++ b/packages/api/test/callback-routes.test.js @@ -8,7 +8,11 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; +import { + adaptInvocationQueue, + adaptMessageStore, + appendTestLifecycleResponseSource, +} from './helpers/message-from-fixtures.js'; import './helpers/setup-cat-registry.js'; // Mock SocketManager @@ -73,7 +77,6 @@ describe('Callback Routes', () => { let featIndexProvider; let labelStore; let invocationQueue; - let queueCustodyCoordinator; beforeEach(async () => { const { InvocationRegistry } = await import( @@ -85,12 +88,11 @@ describe('Callback Routes', () => { const { BacklogStore } = await import('../dist/domains/cats/services/stores/ports/BacklogStore.js'); registry = new InvocationRegistry(); - messageStore = new MessageStore(); + messageStore = adaptMessageStore(new MessageStore()); threadStore = new ThreadStore(); taskStore = new TaskStore(); backlogStore = new BacklogStore(); invocationQueue = undefined; - queueCustodyCoordinator = undefined; const { createLabelStore } = await import('../dist/domains/cats/services/stores/factories/LabelStoreFactory.js'); labelStore = createLabelStore(); socketManager = createMockSocketManager(); @@ -172,13 +174,69 @@ describe('Callback Routes', () => { if (invocationQueue !== undefined) { options.invocationQueue = invocationQueue; } - if (queueCustodyCoordinator !== undefined) { - options.queueCustodyCoordinator = queueCustodyCoordinator; - } await app.register(callbacksRoutes, options); return app; } + async function createQueuedReadProcessor({ + threadId, + invocationId, + parentInvocationId = invocationId, + catId = 'opus', + userId = 'user-1', + }) { + const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); + const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); + const invocationTracker = new InvocationTracker(); + invocationTracker.start(threadId, catId, userId, [catId], parentInvocationId); + const response = appendTestLifecycleResponseSource(messageStore, { + invocationId, + catId, + threadId, + userId, + timestamp: 101, + }); + assert.equal( + invocationTracker.bindLifecycleActiveRun( + { + threadId, + targetId: catId, + invocationId, + responseMessageId: response.id, + inputEntryIds: [], + inputMessageIds: [], + privateInputEntryIds: [], + startedAt: 101, + }, + parentInvocationId, + ), + true, + ); + const processor = new QueueProcessor({ + queue: invocationQueue, + invocationTracker, + invocationRecordStore: {}, + router: {}, + socketManager, + messageStore, + log: { info() {}, warn() {}, error() {} }, + }); + return { processor, response, invocationTracker }; + } + + test('composed callbacks register latest-main owner and content-editor subroutes', async (t) => { + const app = await createApp({ + namedCatContentHolder: {}, + requestReviewOwnerDeps: {}, + }); + t.after(() => app.close()); + + for (const url of ['/api/callbacks/content-editor/inspect', '/api/callbacks/request-review-owner/facts']) { + const response = await app.inject({ method: 'POST', url, payload: {} }); + assert.equal(response.statusCode, 401, `${url} must be registered behind callback auth`); + } + }); + // ---- POST /api/callbacks/post-message ---- test('POST post-message succeeds with valid credentials', async () => { @@ -475,7 +533,7 @@ describe('Callback Routes', () => { }); }); - test('POST post-message checks queued continue-current work against the callback outer parent', async () => { + test('POST post-message does not turn queued continue-current work into a hidden send veto', async () => { const { DeliveryCursorStore } = await import('../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'); const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const deliveryCursorStore = new DeliveryCursorStore(); @@ -490,8 +548,9 @@ describe('Callback Routes', () => { threadId, }); await deliveryCursorStore.ackSeenCursor('user-1', 'opus', threadId, baseline.id); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); invocationQueue.enqueue({ + kind: 'conversation_input', ownerAuthProvenance: 'strict', threadId, userId: 'user-1', @@ -510,19 +569,20 @@ describe('Callback Routes', () => { method: 'POST', url: '/api/callbacks/post-message', headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - payload: { content: 'do not publish before reading current work' }, + payload: { content: 'publish independently of unread work' }, }); assert.equal(response.statusCode, 200); - assert.equal(JSON.parse(response.body).status, 'held'); - assert.equal(JSON.parse(response.body).reason, 'newer_messages_available'); + assert.equal(JSON.parse(response.body).status, 'ok'); assert.equal( - messageStore.getByThread(threadId, 10, 'user-1').some((message) => message.content.includes('do not publish')), - false, + messageStore + .getByThread(threadId, 10, 'user-1') + .some((message) => message.content.includes('publish independently')), + true, ); }); - test('POST post-message holds on unread visible other-cat stream-origin speech in play mode', async () => { + test('POST post-message remains a send primitive when other-cat speech is unread', async () => { const { DeliveryCursorStore } = await import('../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'); const deliveryCursorStore = new DeliveryCursorStore(); const thread = threadStore.create('user-1', 'Persisted cat freshness'); @@ -552,12 +612,17 @@ describe('Callback Routes', () => { method: 'POST', url: '/api/callbacks/post-message', headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - payload: { content: 'must read the cat answer first' }, + payload: { content: 'send without an inbox side effect' }, }); assert.equal(response.statusCode, 200); - assert.equal(JSON.parse(response.body).status, 'held'); - assert.equal(JSON.parse(response.body).reason, 'newer_messages_available'); + assert.equal(JSON.parse(response.body).status, 'ok'); + assert.equal( + messageStore + .getByThread(thread.id, 10, 'user-1') + .some((message) => message.content === 'send without an inbox side effect'), + true, + ); }); test('POST post-message rejects auth and durable child scope mismatch', async () => { @@ -1080,7 +1145,7 @@ describe('Callback Routes', () => { ); }); - test('POST post-message single content @mention ignores extra explicit targetCats (A2A fail-closed)', async () => { + test('POST post-message content @mention outside declared targetCats → HELD (routing mismatch)', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); @@ -1095,17 +1160,14 @@ describe('Callback Routes', () => { }); assert.equal(response.statusCode, 200); - - const recent = messageStore.getRecent(10); - assert.equal(recent.length, 1); - // Single content mention should win; extras from explicit targetCats are pruned. - const mentions = recent[0].mentions; - assert.ok(mentions.includes('codex'), 'content @mention should be included'); - assert.equal(mentions.includes('gpt52'), false, 'extra explicit targetCats should be pruned'); - assert.deepEqual(recent[0].extra?.targetCats, ['gpt52']); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held', 'declared/parsed mismatch must be HELD, not silently arbitrated'); + assert.equal(body.reason, 'routing_mismatch'); + assert.deepEqual(body.unexpectedTargets, ['codex']); + assert.equal(messageStore.getRecent(10).length, 0, 'held message must not be stored'); }); - test('POST post-message keeps merged targets when content has multiple @mentions', async () => { + test('POST post-message multi-mention content outside declared targetCats → HELD (routing mismatch)', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); @@ -1119,13 +1181,34 @@ describe('Callback Routes', () => { }, }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held'); + assert.equal(body.reason, 'routing_mismatch'); + assert.deepEqual([...body.unexpectedTargets].sort(), ['codex', 'gpt52']); + assert.equal(messageStore.getRecent(10).length, 0); + }); + + test('POST post-message content @mention within declared targetCats narrows normally', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + content: '同步一下\n@codex', + targetCats: ['codex', 'gpt52'], + }, + }); + assert.equal(response.statusCode, 200); const recent = messageStore.getRecent(10); assert.equal(recent.length, 1); const mentions = recent[0].mentions; - assert.ok(mentions.includes('codex')); - assert.ok(mentions.includes('gpt52')); - assert.ok(mentions.includes('gemini'), 'multi-mention content should still merge explicit targetCats'); + assert.ok(mentions.includes('codex'), 'content @mention should be included'); + assert.equal(mentions.includes('gpt52'), false, 'declared superset narrows to the single content mention'); }); test('POST post-message rejects cross-thread send to another user thread', async () => { @@ -4442,7 +4525,7 @@ describe('Callback Routes', () => { const { InMemoryTurnExecutionStore } = await import( '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' ); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); const callerThreadId = 'thread-cross-read-caller'; const targetThreadId = 'thread-cross-read-target'; const { invocationId, callbackToken } = await registry.create('user-1', 'opus', callerThreadId); @@ -4474,6 +4557,7 @@ describe('Callback Routes', () => { deliveryStatus: 'queued', }); const queued = invocationQueue.enqueue({ + kind: 'conversation_input', ownerAuthProvenance: 'unknown', threadId: targetThreadId, userId: 'user-1', @@ -4510,8 +4594,8 @@ describe('Callback Routes', () => { false, ); const queuedSnapshot = invocationQueue.getEntrySnapshot(targetThreadId, 'user-1', queued.entry.id); - assert.deepEqual(queuedSnapshot.queuedSeenByCatIds, undefined); - assert.deepEqual(queuedSnapshot.queuedBodyExposures, undefined); + assert.equal(queuedSnapshot.delivery.seenAt, undefined); + assert.equal('bodyExposures' in queuedSnapshot.delivery, false); assert.equal( socketManager.getUserEvents().some((event) => event.event === 'queue_updated'), false, @@ -4519,117 +4603,97 @@ describe('Callback Routes', () => { ); }); - test('durable queue exposure remains exact-readable after child seal/runtime replacement without cross-cat or cross-thread leakage', async () => { - const sourceThreadId = 'thread-durable-exposure-source'; - const callerThreadId = 'thread-durable-exposure-caller'; - const otherThreadId = 'thread-durable-exposure-other'; - const opus = await registry.create('user-1', 'opus', callerThreadId); - const codex = await registry.create('user-1', 'codex', callerThreadId); - const before = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'published before', - mentions: [], - timestamp: 1000, - threadId: sourceThreadId, - }); - const exposed = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'durably exposed queued body', - mentions: ['opus'], - timestamp: 2000, - threadId: sourceThreadId, - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-durable-exposure', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'sealed-child-opus' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'sealed-child-opus', seenAt: 2100 }], - }), - }); - const after = messageStore.append({ - userId: 'user-1', - catId: 'codex', - content: 'published after', - mentions: [], - timestamp: 3000, - threadId: sourceThreadId, - }); - const foreign = messageStore.append({ + test('full context keeps message-less typed custody readable and pending without manufacturing delivery evidence', async () => { + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); + const threadId = 'thread-message-less-custody'; + const queued = invocationQueue.enqueue({ + kind: 'private_input', + ownerAuthProvenance: 'unknown', + threadId, userId: 'user-1', - catId: null, - content: 'other-thread exposed body', - mentions: ['opus'], - timestamp: 2500, - threadId: otherThreadId, - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-other-thread', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'sealed-child-other' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'sealed-child-other', seenAt: 2550 }], - }), + content: 'structured A2A custody without a Message row', + source: 'agent', + callerCatId: 'codex', + sourceCategory: 'a2a', + targetCats: ['opus'], + intent: 'execute', }); - const custodyBefore = structuredClone(messageStore.getById(exposed.id).queueCustody); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); const app = await createApp(); - for (let attempt = 0; attempt < 2; attempt += 1) { - const full = await app.inject({ - method: 'GET', - url: `/api/callbacks/thread-context?threadId=${sourceThreadId}&responseMode=full&limit=20`, - headers: { 'x-invocation-id': opus.invocationId, 'x-callback-token': opus.callbackToken }, - }); - assert.equal(full.statusCode, 200, full.body); - const messages = JSON.parse(full.body).messages; - assert.deepEqual( - messages.map((message) => message.id), - [before.id, exposed.id, after.id], - 'full history must preserve chronological position and exclude another thread', - ); - const projected = messages.find((message) => message.id === exposed.id); - assert.equal(projected.speaker, 'co-creator'); - assert.equal(projected.content, exposed.content); - assert.ok(!messages.some((message) => message.id === foreign.id)); - } - assert.deepEqual( - messageStore.getById(exposed.id).queueCustody, - custodyBefore, - 'historical repeat reads must not mutate or re-ack queue custody', - ); - - const windowed = await app.inject({ + const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?threadId=${sourceThreadId}&messageId=${exposed.id}&before=1&after=1&responseMode=full`, - headers: { 'x-invocation-id': opus.invocationId, 'x-callback-token': opus.callbackToken }, + url: '/api/callbacks/thread-context?responseMode=full', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); - assert.equal(windowed.statusCode, 200, windowed.body); + + assert.equal(response.statusCode, 200, response.body); assert.deepEqual( - JSON.parse(windowed.body).messages.map((message) => message.id), - [before.id, exposed.id, after.id], + JSON.parse(response.body).messages.map((message) => message.id), + [`queued:${queued.entry.id}`], ); + const snapshot = invocationQueue.getEntrySnapshot(threadId, 'user-1', queued.entry.id); + assert.equal(snapshot.status, 'queued'); + assert.equal(snapshot.delivery.seenInvocationId, undefined); + assert.equal(snapshot.delivery.seenAt, undefined); + }); - const otherCatFull = await app.inject({ - method: 'GET', - url: `/api/callbacks/thread-context?threadId=${sourceThreadId}&responseMode=full&limit=20`, - headers: { 'x-invocation-id': codex.invocationId, 'x-callback-token': codex.callbackToken }, + test('full context omits an unadoptable conversation body without hiding readable History', async () => { + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); + const threadId = 'thread-benign-adoption-race'; + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); + const before = messageStore.append({ + userId: 'user-1', + catId: 'codex', + content: 'readable history before queued work', + mentions: [], + timestamp: 100, + threadId, }); - assert.equal(otherCatFull.statusCode, 200, otherCatFull.body); - assert.ok(!JSON.parse(otherCatFull.body).messages.some((message) => message.id === exposed.id)); + const admission = await invocationQueue.appendAndEnqueueDurable( + messageStore, + { + userId: 'user-1', + from: { kind: 'user', userId: 'user-1' }, + content: 'queued body whose live run already changed', + mentions: ['opus'], + timestamp: 200, + threadId, + deliveryStatus: 'queued', + }, + { + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + threadId, + userId: 'user-1', + content: 'queued body whose live run already changed', + from: { kind: 'user', userId: 'user-1' }, + targetCats: ['opus'], + authorIntentByCatId: { + opus: { requested: 'continue_current', boundParentInvocationId: invocationId }, + }, + intent: 'execute', + }, + ); + assert.equal(admission.outcome, 'enqueued'); + const app = await createApp(); - const otherCatWindow = await app.inject({ + const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?threadId=${sourceThreadId}&messageId=${exposed.id}&responseMode=full`, - headers: { 'x-invocation-id': codex.invocationId, 'x-callback-token': codex.callbackToken }, + url: '/api/callbacks/thread-context?responseMode=full', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); - assert.equal(otherCatWindow.statusCode, 404); - }); - // ---- F236 Track-1: responseMode=anchor|full on thread-context ---- + assert.equal(response.statusCode, 200, response.body); + assert.deepEqual( + JSON.parse(response.body).messages.map((message) => message.id), + [before.id], + ); + assert.equal(invocationQueue.getEntrySnapshot(threadId, 'user-1', admission.entry.id)?.status, 'queued'); + assert.equal(messageStore.getById(admission.message.id).deliveryStatus, 'queued'); + }); test('GET thread-context responseMode=full returns full content instead of preview (F236 Track-1)', async () => { const app = await createApp(); @@ -4764,17 +4828,17 @@ describe('Callback Routes', () => { invocationQueue = new InvocationQueue(); const threadId = 'thread-f236-oversized-queued'; const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); - const queued = invocationQueue.enqueue({ + const queued = invocationQueue.enqueueDurableNow({ + kind: 'conversation_input', ownerAuthProvenance: 'strict', threadId, userId: 'user-1', content: `queued-oversized-${'q'.repeat(80_000)}`, - source: 'user', + from: { kind: 'user', userId: 'user-1' }, targetCats: ['opus'], - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: invocationId }, - }, + authorIntentByCatId: { opus: { requested: 'continue_current', boundParentInvocationId: invocationId } }, intent: 'execute', + sourceId: 'unpersisted-oversized-work', }); const app = await createApp(); @@ -4793,426 +4857,10 @@ describe('Callback Routes', () => { assert.equal('content' in body.messages[0], false); assert.equal('drillDown' in body.messages[0], false); assert.match(body.messages[0].drillUnavailableReason, /no persisted message anchor/); - assert.deepEqual( - invocationQueue.getEntrySnapshot(threadId, 'user-1', queued.entry.id).queuedSeenByCatIds ?? [], - [], - ); - }); - - test('F236 regression: an oversized persisted queued anchor drills the exact body and binds the current child', async (t) => { - const { turnCustodyAdoptionRegistry } = await import('../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { QueuedMessageCustodyCoordinator, createInitialQueuedMessageCustody } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' - ); - const { InMemoryTurnExecutionStore } = await import( - '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' - ); - const { InvocationRecordStore } = await import( - '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js' - ); - - turnCustodyAdoptionRegistry.resetForTest(); - invocationQueue = new InvocationQueue(); - const threadId = 'thread-f236-oversized-persisted-queued'; - const primaryContent = `managed-wake-${'q'.repeat(80_000)}`; - const mergedContent = 'merged rich follow-up'; - const content = `${primaryContent}\n${mergedContent}`; - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); - const stored = messageStore.append({ - userId: 'scheduler', - catId: null, - content: primaryContent, - contentBlocks: [ - { type: 'text', text: primaryContent }, - { type: 'image', url: '/uploads/f236-primary.png' }, - ], - mentions: ['opus'], - timestamp: 100, - threadId, - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-f236-oversized-drill', threadId, catId: 'opus', wakeWhen: true }, - }, - }); - const merged = messageStore.append({ - userId: 'user-1', - catId: null, - content: mergedContent, - contentBlocks: [ - { type: 'text', text: mergedContent }, - { type: 'image', url: 'https://assets.example/f236-merged.png' }, - ], - mentions: ['opus'], - timestamp: 101, - threadId, - deliveryStatus: 'queued', - }); - const queued = invocationQueue.enqueue({ - ownerAuthProvenance: 'strict', - threadId, - userId: 'user-1', - content, - source: 'connector', - sourceCategory: 'scheduled', - targetCats: ['opus'], - intent: 'execute', - messageId: stored.id, - }); - invocationQueue.backfillMessageId(threadId, 'user-1', queued.entry.id, merged.id); - const queuedSnapshot = invocationQueue.getEntrySnapshot(threadId, 'user-1', queued.entry.id); - assert.ok(queuedSnapshot); - await messageStore.initializeQueueCustody(stored.id, createInitialQueuedMessageCustody(queuedSnapshot)); - await messageStore.initializeQueueCustody(merged.id, createInitialQueuedMessageCustody(queuedSnapshot)); - queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const turnExecutionStore = new InMemoryTurnExecutionStore(); - await turnExecutionStore.createRunning({ - invocationId, - parentInvocationId: invocationId, - threadId, - userId: 'user-1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 101, - }); - const queueProcessor = new QueueProcessor({ - queue: invocationQueue, - invocationTracker: new InvocationTracker(), - invocationRecordStore: new InvocationRecordStore(), - turnExecutionStore, - messageStore, - queueCustodyCoordinator, - socketManager, - log: { info() {}, warn() {}, error() {} }, - router: { - async *routeExecution() { - assert.fail('a read drill must not invoke a provider'); - }, - }, - }); - const managedHoldDisposition = { - state: 'single_canonical_pending', - candidates: [{ sourceMessageId: stored.id, taskId: 'task-f236-oversized-drill' }], - }; - let failDispositionDescription = false; - const app = await createApp({ - turnExecutionStore, - queueProcessor, - holdBallDeps: { - registry, - managedHoldDispositionService: { - describe: async () => { - if (failDispositionDescription) throw new Error('disposition store unavailable'); - return managedHoldDisposition; - }, - }, - }, - }); - const adopted = []; - let unregister; - t.after(async () => { - await unregister?.(); - turnCustodyAdoptionRegistry.resetForTest(); - await app.close(); - }); - - const baseUrl = await app.listen({ host: '127.0.0.1', port: 0 }); - const request = async (path, credentials) => { - const response = await fetch(`${baseUrl}${path}`, { - headers: { - 'x-invocation-id': credentials.invocationId, - 'x-callback-token': credentials.callbackToken, - }, - }); - return { statusCode: response.status, body: await response.text() }; - }; - - const page = await request('/api/callbacks/thread-context?limit=100&responseMode=full', { - invocationId, - callbackToken, - }); - assert.equal(page.statusCode, 200, page.body); - const anchor = JSON.parse(page.body).messages.find((message) => message.id === stored.id); - assert.equal(anchor.oversized, true); - assert.deepEqual(anchor.drillDown.args, { messageId: stored.id, mode: 'full' }); - assert.deepEqual(messageStore.getById(stored.id).queueCustody.bodyExposures, undefined); - - const missingHandler = await request(`/api/callbacks/get-message?messageId=${stored.id}&mode=full`, { - invocationId, - callbackToken, - }); - assert.equal(missingHandler.statusCode, 409, missingHandler.body); - assert.equal(JSON.parse(missingHandler.body).code, 'TURN_CUSTODY_ADOPTION_UNAVAILABLE'); - assert.deepEqual( - messageStore.getById(stored.id).queueCustody.bodyExposures, - undefined, - 'a failed adoption must not manufacture a durable body-exposure witness', - ); - assert.deepEqual(messageStore.getById(merged.id).queueCustody.bodyExposures, undefined); - - const unregisterThrowingHandler = turnCustodyAdoptionRegistry.register(invocationId, async () => { - throw new Error('adoption preparation failed'); - }); - const throwingHandler = await request(`/api/callbacks/get-message?messageId=${stored.id}&mode=full`, { - invocationId, - callbackToken, - }); - assert.equal(throwingHandler.statusCode, 503, throwingHandler.body); - assert.equal(JSON.parse(throwingHandler.body).code, 'TURN_CUSTODY_ADOPTION_UNAVAILABLE'); - assert.deepEqual(messageStore.getById(stored.id).queueCustody.bodyExposures, undefined); - assert.deepEqual(messageStore.getById(merged.id).queueCustody.bodyExposures, undefined); - await unregisterThrowingHandler(); - - let preparationStartedResolve; - let releasePreparationResolve; - const preparationStarted = new Promise((resolve) => { - preparationStartedResolve = resolve; - }); - const releasePreparation = new Promise((resolve) => { - releasePreparationResolve = resolve; - }); - const prepareAdoption = turnCustodyAdoptionRegistry.prepare; - let gateFirstPreparation = true; - turnCustodyAdoptionRegistry.prepare = async (...args) => { - const reservation = await prepareAdoption.call(turnCustodyAdoptionRegistry, ...args); - if (gateFirstPreparation) { - gateFirstPreparation = false; - preparationStartedResolve(); - await releasePreparation; - } - return reservation; - }; - t.after(() => { - turnCustodyAdoptionRegistry.prepare = prepareAdoption; - }); - unregister = turnCustodyAdoptionRegistry.register(invocationId, async (wakes) => { - const prepared = [...wakes]; - return () => adopted.push(...prepared); - }); - - const assertSuccessfulDrill = (drill, { disposition = true } = {}) => { - assert.equal(drill.statusCode, 200, drill.body); - const body = JSON.parse(drill.body); - assert.equal(body.message.id, stored.id); - assert.equal(body.message.content, content); - assert.equal(body.message.contentLength, content.length); - assert.equal(body.message.truncated, false); - assert.equal(body.message.deliveryStatus, 'queued'); - assert.equal(body.message.queueEntryId, queued.entry.id); - assert.deepEqual(body.message.mergedMessageIds, [merged.id]); - assert.deepEqual(body.message.contentBlocks, [ - { type: 'text', text: primaryContent }, - { type: 'image', url: '/uploads/f236-primary.png' }, - { type: 'text', text: mergedContent }, - { type: 'image', url: 'https://assets.example/f236-merged.png' }, - ]); - assert.equal( - body.message.imagePaths.some((imagePath) => imagePath.endsWith('/uploads/f236-primary.png')), - true, - ); - assert.equal( - body.message.imageUrls.some((url) => url.endsWith('/uploads/f236-primary.png')), - true, - ); - assert.equal(body.message.imageUrls.includes('https://assets.example/f236-merged.png'), true); - if (disposition) assert.deepEqual(body.managedHoldDisposition, managedHoldDisposition); - else assert.equal('managedHoldDisposition' in body, false); - }; - - const racedDrillPromise = request(`/api/callbacks/get-message?messageId=${stored.id}&mode=full`, { - invocationId, - callbackToken, - }); - await preparationStarted; - let unregisterSettled = false; - const unregisterPromise = unregister().then(() => { - unregisterSettled = true; - }); - unregister = undefined; - try { - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(unregisterSettled, false, 'route teardown must wait for the prepared adoption transaction'); - } finally { - releasePreparationResolve(); - } - assertSuccessfulDrill(await racedDrillPromise); - await unregisterPromise; - turnCustodyAdoptionRegistry.prepare = prepareAdoption; - - unregister = turnCustodyAdoptionRegistry.register(invocationId, async (wakes) => { - const prepared = [...wakes]; - return () => adopted.push(...prepared); - }); - const repeatDrill = await request(`/api/callbacks/get-message?messageId=${stored.id}&mode=full`, { - invocationId, - callbackToken, - }); - assertSuccessfulDrill(repeatDrill); - - failDispositionDescription = true; - const dispositionFailure = await request(`/api/callbacks/get-message?messageId=${stored.id}&mode=full`, { - invocationId, - callbackToken, - }); - assertSuccessfulDrill(dispositionFailure, { disposition: false }); - failDispositionDescription = false; - - const firstExposure = messageStore.getById(stored.id).queueCustody.bodyExposures[0]; - assert.deepEqual(messageStore.getById(stored.id).queueCustody.bodyExposures, [firstExposure]); - assert.deepEqual(messageStore.getById(merged.id).queueCustody.bodyExposures, [firstExposure]); - assert.deepEqual( - { targetCatId: firstExposure.targetCatId, invocationId: firstExposure.invocationId }, - { targetCatId: 'opus', invocationId }, - ); - assert.equal(adopted.length >= 1, true); - assert.equal(turnCustodyAdoptionRegistry.snapshot(invocationId).length, 1); - - await unregister(); - unregister = undefined; - const replacement = await registry.create('user-1', 'opus', threadId); - await turnExecutionStore.createRunning({ - invocationId: replacement.invocationId, - parentInvocationId: replacement.invocationId, - threadId, - userId: 'user-1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 102, - }); - const replacementAdopted = []; - const unregisterReplacement = turnCustodyAdoptionRegistry.register(replacement.invocationId, async (wakes) => { - const prepared = [...wakes]; - return () => replacementAdopted.push(...prepared); - }); - try { - const replacementDrill = await request(`/api/callbacks/get-message?messageId=${stored.id}&mode=full`, { - invocationId: replacement.invocationId, - callbackToken: replacement.callbackToken, - }); - assert.equal(replacementDrill.statusCode, 200, replacementDrill.body); - assert.equal(JSON.parse(replacementDrill.body).message.content, content); - assert.deepEqual( - messageStore.getById(stored.id).queueCustody.bodyExposures.map((exposure) => ({ - targetCatId: exposure.targetCatId, - invocationId: exposure.invocationId, - })), - [ - { targetCatId: 'opus', invocationId }, - { targetCatId: 'opus', invocationId: replacement.invocationId }, - ], - 'a prior child exposure must not substitute for the current child drill witness', - ); - assert.equal(replacementAdopted.length >= 1, true); - assert.equal(turnCustodyAdoptionRegistry.snapshot(replacement.invocationId).length, 1); - } finally { - await unregisterReplacement(); - } - }); - - test('F236 queued drill rejects wrong turn scope, foreign cats, foreign threads, and unbound queued rows', async () => { - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { createInitialQueuedMessageCustody } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' + assert.equal( + 'bodyExposures' in invocationQueue.getEntrySnapshot(threadId, 'user-1', queued.entry.id).delivery, + false, ); - invocationQueue = new InvocationQueue(); - const threadId = 'thread-f236-queued-drill-scope'; - const parentInvocationId = 'parent-f236-queued-drill-scope'; - const caller = await registry.create('user-1', 'opus', threadId, parentInvocationId); - const stored = messageStore.append({ - userId: 'user-1', - catId: null, - content: `scope-bound-${'s'.repeat(80_000)}`, - mentions: ['opus'], - timestamp: 100, - threadId, - deliveryStatus: 'queued', - }); - const queued = invocationQueue.enqueue({ - ownerAuthProvenance: 'strict', - threadId, - userId: 'user-1', - content: stored.content, - source: 'user', - targetCats: ['opus'], - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: parentInvocationId }, - }, - intent: 'execute', - messageId: stored.id, - }); - await messageStore.initializeQueueCustody(stored.id, createInitialQueuedMessageCustody(queued.entry)); - const app = await createApp({ - turnExecutionStore: { - async get(invocationId) { - return { - invocationId, - parentInvocationId: 'wrong-parent', - threadId, - userId: 'user-1', - catId: 'opus', - executionKind: 'ordinary', - status: 'running', - startedAt: 101, - }; - }, - }, - queueProcessor: { - async markPromptMessagesSeen() { - assert.fail('scope rejection must happen before exposure persistence'); - }, - }, - }); - - const wrongTurn = await app.inject({ - method: 'GET', - url: `/api/callbacks/get-message?messageId=${stored.id}&mode=full`, - headers: { 'x-invocation-id': caller.invocationId, 'x-callback-token': caller.callbackToken }, - }); - assert.equal(wrongTurn.statusCode, 409); - assert.equal(JSON.parse(wrongTurn.body).code, 'TURN_EXECUTION_SCOPE_MISMATCH'); - assert.deepEqual(messageStore.getById(stored.id).queueCustody.bodyExposures, undefined); - - const foreignCat = await registry.create('user-1', 'codex', threadId, parentInvocationId); - const foreignCatRead = await app.inject({ - method: 'GET', - url: `/api/callbacks/get-message?messageId=${stored.id}&mode=full`, - headers: { - 'x-invocation-id': foreignCat.invocationId, - 'x-callback-token': foreignCat.callbackToken, - }, - }); - assert.equal(foreignCatRead.statusCode, 404); - - const foreignThread = await registry.create('user-1', 'opus', 'thread-f236-foreign', parentInvocationId); - const foreignThreadRead = await app.inject({ - method: 'GET', - url: `/api/callbacks/get-message?messageId=${stored.id}&mode=full`, - headers: { - 'x-invocation-id': foreignThread.invocationId, - 'x-callback-token': foreignThread.callbackToken, - }, - }); - assert.equal(foreignThreadRead.statusCode, 404); - - const unbound = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'persisted but not bound to a live queue entry', - mentions: ['opus'], - timestamp: 200, - threadId, - deliveryStatus: 'queued', - }); - const unboundRead = await app.inject({ - method: 'GET', - url: `/api/callbacks/get-message?messageId=${unbound.id}&mode=full`, - headers: { 'x-invocation-id': caller.invocationId, 'x-callback-token': caller.callbackToken }, - }); - assert.equal(unboundRead.statusCode, 404); }); test('F236 regression: continuation cursor is rejected when its read scope changes or it is malformed', async () => { @@ -5304,6 +4952,7 @@ describe('Callback Routes', () => { assert.equal(first.statusCode, 200, first.body); assert.ok(Buffer.byteLength(first.body, 'utf8') <= 24_000); const firstBody = JSON.parse(first.body); + assert.equal(firstBody.contextScope, 'unread_delta'); assert.ok(firstBody.messages.length > 0 && firstBody.messages.length < unread.length); assert.ok(firstBody.messages.every((message) => message.content.startsWith('fresh-unread-'))); assert.equal(firstBody.hasMore, true); @@ -5336,11 +4985,19 @@ describe('Callback Routes', () => { ); const finalSeenCursor = await deliveryCursorStore.getSeenCursor('user-1', 'opus', threadId); assert.ok(finalSeenCursor.includes(unread.at(-1).id)); + + const history = await app.inject({ + method: 'GET', + url: '/api/callbacks/thread-context?limit=1&responseMode=full', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + assert.equal(history.statusCode, 200, history.body); + assert.equal(JSON.parse(history.body).contextScope, 'recent_history'); }); - test('full thread-context projects published queued cat speech only once while recording queue read evidence', async () => { + test('full thread-context adopts published A2A custody and removes the delivered target from Queue', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); const threadId = 'thread-queued-cat-dedup'; const stored = messageStore.append({ userId: 'user-1', @@ -5352,6 +5009,7 @@ describe('Callback Routes', () => { deliveryStatus: 'queued', }); const queued = invocationQueue.enqueue({ + kind: 'message_wake', ownerAuthProvenance: 'unknown', threadId, userId: 'user-1', @@ -5363,8 +5021,9 @@ describe('Callback Routes', () => { intent: 'execute', callerCatId: 'codex', }); - const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); + const { processor } = await createQueuedReadProcessor({ threadId, invocationId }); + const app = await createApp({ queueProcessor: processor }); const response = await app.inject({ method: 'GET', @@ -5376,27 +5035,38 @@ describe('Callback Routes', () => { const body = JSON.parse(response.body); assert.equal(body.messages.filter((message) => message.id === stored.id).length, 1); assert.equal(body.messages.find((message) => message.id === stored.id).content, stored.content); - assert.deepEqual(invocationQueue.getEntrySnapshot(threadId, 'user-1', queued.entry.id).queuedSeenByCatIds, [ - 'opus', - ]); + assert.equal(invocationQueue.getEntrySnapshot(threadId, 'user-1', queued.entry.id), null); + assert.equal(await invocationQueue.getDurableEntry(threadId, queued.entry.id), null); + const source = messageStore.getById(stored.id); + assert.equal(source.lifecycle.dispatchRefs.length, 1); + assert.equal(source.lifecycle.dispatchRefs[0].targetId, 'opus'); + assert.equal(source.lifecycle.dispatchRefs[0].phase, 'dispatched'); + assert.equal(typeof source.lifecycle.dispatchRefs[0].dispatchedAt, 'number'); }); - test('F254/F264: queued freshness reaches current full read while an unread sibling becomes successor work', async () => { + test('F254/F264: full queued read adopts only this target into the existing response', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { checkFreshnessForPostMessage, createQueueChecker } = await import( '../dist/domains/cats/services/freshness/checkFreshnessForPostMessage.js' ); - const { QueuedMessageCustodyCoordinator, createInitialQueuedMessageCustody } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' - ); const { InMemoryTurnExecutionStore } = await import( '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' ); const queuedTelemetry = await import('../dist/domains/cats/services/freshness/freshness-queue-telemetry.js'); queuedTelemetry.resetFreshnessQueueTelemetryForTest(); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-queued-d12a'); + const storedQueuedMessage = messageStore.append({ + userId: 'user-1', + catId: null, + content: 'queued body visible only in full read', + mentions: [], + timestamp: 100, + threadId: 'thread-queued-d12a', + deliveryStatus: 'queued', + }); const queued = invocationQueue.enqueue({ + kind: 'conversation_input', ownerAuthProvenance: 'unknown', threadId: 'thread-queued-d12a', userId: 'user-1', @@ -5407,21 +5077,14 @@ describe('Callback Routes', () => { opus: { requested: 'continue_current', boundParentInvocationId: invocationId }, }, intent: 'execute', + messageId: storedQueuedMessage.id, }); - const storedQueuedMessage = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'queued body visible only in full read', - mentions: [], - timestamp: 100, + const turnExecutionStore = new InMemoryTurnExecutionStore(); + const { processor, response: lifecycleResponse } = await createQueuedReadProcessor({ threadId: 'thread-queued-d12a', - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(queued.entry), + invocationId, }); - invocationQueue.backfillMessageId('thread-queued-d12a', 'user-1', queued.entry.id, storedQueuedMessage.id); - queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const turnExecutionStore = new InMemoryTurnExecutionStore(); - const app = await createApp({ turnExecutionStore }); + const app = await createApp({ turnExecutionStore, queueProcessor: processor }); const freshnessDecision = await checkFreshnessForPostMessage({ userId: 'user-1', catId: 'opus', @@ -5434,17 +5097,24 @@ describe('Callback Routes', () => { }); assert.equal(freshnessDecision.decision, 'held', 'queued current work must first surface as freshness'); assert.equal(freshnessDecision.reason, 'queued_messages_pending'); - const activeEntry = invocationQueue.getEntrySnapshot('thread-queued-d12a', 'user-1', queued.entry.id); - await queueCustodyCoordinator.requestReminder(activeEntry, 'opus', invocationId, 'reminder-read-boundary'); - - const missingLedgerResponse = await app.inject({ + const changedRunResponse = await app.inject({ method: 'GET', url: '/api/callbacks/thread-context?responseMode=full', headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); - assert.equal(missingLedgerResponse.statusCode, 409); - assert.equal(JSON.parse(missingLedgerResponse.body).code, 'TURN_EXECUTION_NOT_FOUND'); - assert.deepEqual(messageStore.getById(storedQueuedMessage.id).queueCustody.bodyExposures, undefined); + assert.equal(changedRunResponse.statusCode, 200, changedRunResponse.body); + assert.equal( + JSON.parse(changedRunResponse.body).messages.some( + (message) => message.id === storedQueuedMessage.id || message.queueEntryId === queued.entry.id, + ), + false, + 'a benign Active Run race omits only the queued body', + ); + assert.equal(invocationQueue.getEntrySnapshot('thread-queued-d12a', 'user-1', queued.entry.id)?.status, 'queued'); + assert.equal( + 'bodyExposures' in invocationQueue.getEntrySnapshot('thread-queued-d12a', 'user-1', queued.entry.id).delivery, + false, + ); await turnExecutionStore.createRunning({ invocationId, @@ -5455,6 +5125,21 @@ describe('Callback Routes', () => { executionKind: 'ordinary', startedAt: 101, }); + const realAdoptExposedQueuedEntries = processor.adoptExposedQueuedEntries.bind(processor); + processor.adoptExposedQueuedEntries = async () => ({ + outcome: 'rejected', + reason: 'persistence_unavailable', + }); + const unavailableResponse = await app.inject({ + method: 'GET', + url: '/api/callbacks/thread-context?responseMode=full', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + assert.equal(unavailableResponse.statusCode, 503, unavailableResponse.body); + assert.equal(JSON.parse(unavailableResponse.body).code, 'QUEUE_ADOPTION_UNAVAILABLE'); + assert.equal(invocationQueue.getEntrySnapshot('thread-queued-d12a', 'user-1', queued.entry.id)?.status, 'queued'); + processor.adoptExposedQueuedEntries = realAdoptExposedQueuedEntries; + const response = await app.inject({ method: 'GET', url: '/api/callbacks/thread-context?responseMode=full', @@ -5470,13 +5155,13 @@ describe('Callback Routes', () => { assert.equal(queuedMessage.id, storedQueuedMessage.id); assert.equal( messageStore.getById(storedQueuedMessage.id).deliveryStatus, - 'queued', - 'read must not mark queued messages delivered', + 'delivered', + 'the exact active child now owns the delivered input', ); assert.equal( invocationQueue.list('thread-queued-d12a', 'user-1').some((entry) => entry.id === queued.entry.id), - true, - 'read must not consume the queued work item', + false, + 'the consumed target must leave the active Queue immediately', ); assert.equal( invocationQueue.getQueuedFreshnessMessagesForCat('thread-queued-d12a', 'user-1', 'opus', { @@ -5490,29 +5175,19 @@ describe('Callback Routes', () => { 1, 'first full contiguous read should count one queued_seen transition', ); - assert.deepEqual(messageStore.getById(storedQueuedMessage.id).queueCustody.seenByCatIds, ['opus']); + assert.equal(queuedTelemetry.getFreshnessQueueTelemetrySnapshot().queuedHandledTotal, 1); + assert.equal(queuedTelemetry.getFreshnessQueueTelemetrySnapshot().queuedHandledFullyConsumedTotal, 1); assert.equal( - messageStore.getById(storedQueuedMessage.id).queueCustody.seenInvocationIdByCatId.opus, - invocationId, - 'thread-context must persist exact read evidence before returning it', + await invocationQueue.getDurableEntry('thread-queued-d12a', queued.entry.id), + null, + 'actual delivery removes the final pending target instead of leaving a Queue receipt tombstone', ); - const firstExposure = messageStore.getById(storedQueuedMessage.id).queueCustody.bodyExposures?.[0]; - assert.equal(firstExposure?.targetCatId, 'opus'); - assert.equal(firstExposure?.invocationId, invocationId); - assert.equal(Number.isFinite(firstExposure?.seenAt), true); - assert.equal( - messageStore.getById(storedQueuedMessage.id).queueCustody.reminderAttempts[0].state, - 'seen', - 'exact body exposure must close the matching reminder attempt as seen', - ); - const receiptUpdate = socketManager - .getUserEvents() - .find((event) => event.event === 'queue_updated' && event.data.action === 'queued_seen'); - assert.ok(receiptUpdate, 'full body read must update the original timeline receipt without waiting for completion'); - assert.equal(receiptUpdate.data.queue[0].queueReceipt.targets[0].state, 'seen'); - assert.equal(receiptUpdate.data.queue[0].queueReceipt.targets[0].invocationId, invocationId); - assert.equal(receiptUpdate.data.queue[0].queueReceipt.targets[0].seenAt, firstExposure.seenAt); - assert.equal(receiptUpdate.data.queue[0].queueReceipt.reminderAttempts[0].state, 'seen'); + assert.deepEqual(messageStore.getById(lifecycleResponse.id).lifecycle.inputMessageIds, [storedQueuedMessage.id]); + const [dispatchRef] = messageStore.getById(storedQueuedMessage.id).lifecycle.dispatchRefs; + assert.equal(dispatchRef.targetId, 'opus'); + assert.equal(dispatchRef.phase, 'dispatched'); + assert.equal(dispatchRef.statusMessageId, lifecycleResponse.id); + assert.equal(typeof dispatchRef.dispatchedAt, 'number'); const secondResponse = await app.inject({ method: 'GET', @@ -5523,157 +5198,25 @@ describe('Callback Routes', () => { assert.equal(secondResponse.statusCode, 200); const secondBody = JSON.parse(secondResponse.body); const secondQueuedMessage = secondBody.messages.find((message) => message.queueEntryId === queued.entry.id); - assert.ok(secondQueuedMessage, 'queued_seen must not make unresolved queued body unreadable'); - assert.equal(secondQueuedMessage.content, 'queued body visible only in full read'); + assert.equal(secondQueuedMessage, undefined, 'an adopted target must not remain projected as Queue work'); + assert.ok( + secondBody.messages.some( + (message) => + message.id === storedQueuedMessage.id && message.content === 'queued body visible only in full read', + ), + 'the original message remains readable from durable History', + ); assert.equal( queuedTelemetry.getFreshnessQueueTelemetrySnapshot().queuedSeenTotal, 1, 'repeat full read should refresh evidence if needed but not double-count queued_seen', ); - assert.deepEqual( - messageStore.getById(storedQueuedMessage.id).queueCustody.bodyExposures, - [firstExposure], - 'repeat exposure by the same child must preserve the first exact seenAt', - ); - - const handled = invocationQueue.markQueuedHandledForCatAcrossUsers('thread-queued-d12a', 'opus', invocationId); - assert.equal(handled.length, 1, 'the exact reading invocation closes current-read custody'); - assert.equal(invocationQueue.peekNextQueued('thread-queued-d12a', 'user-1'), null); - - const unread = invocationQueue.enqueue({ - ownerAuthProvenance: 'strict', - threadId: 'thread-queued-d12a', - userId: 'user-1', - content: 'unread before the parent ended', - source: 'user', - targetCats: ['opus'], - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: invocationId }, - }, - intent: 'execute', - }); - invocationQueue.fallbackAuthorIntentsForParentAcrossUsers('thread-queued-d12a', 'opus', invocationId, 200); - assert.deepEqual( - invocationQueue.getQueuedBodyMessagesForCat('thread-queued-d12a', 'user-1', 'opus', invocationId), - [], - 'an unread message must not leak back into its closed parent', - ); - assert.equal(invocationQueue.peekNextQueued('thread-queued-d12a', 'user-1')?.id, unread.entry.id); - assert.equal( - invocationQueue.markProcessing('thread-queued-d12a', 'user-1')?.id, - unread.entry.id, - 'the unread entry remains eligible for the typed successor path', - ); - }); - - test('F167: full-context read binds an adopted managed hold to the active route before returning its body', async () => { - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const { QueuedMessageCustodyCoordinator, createInitialQueuedMessageCustody } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' - ); - const { InMemoryTurnExecutionStore } = await import( - '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' - ); - const { turnCustodyAdoptionRegistry } = await import('../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js'); - turnCustodyAdoptionRegistry.resetForTest(); - invocationQueue = new InvocationQueue(); - const threadId = 'thread-adopt-managed-hold'; - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); - const queued = invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId, - userId: 'user-1', - content: 'managed command completed', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: ['opus'], - intent: 'execute', - }); - const stored = messageStore.append({ - userId: 'user-1', - catId: null, - content: queued.entry.content, - mentions: ['opus'], - timestamp: 100, - threadId, - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-full-read-adopted', threadId, catId: 'opus', wakeWhen: true }, - }, - queueCustody: createInitialQueuedMessageCustody(queued.entry), - }); - invocationQueue.backfillMessageId(threadId, 'user-1', queued.entry.id, stored.id); - queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const turnExecutionStore = new InMemoryTurnExecutionStore(); - await turnExecutionStore.createRunning({ - invocationId, - parentInvocationId: invocationId, - threadId, - userId: 'user-1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 101, - }); - const wake = { - kind: 'structured', - protocol: 'hold', - subjectKey: `ball:thread:${threadId}`, - holderCatId: 'opus', - sourceMessageId: stored.id, - taskId: 'task-full-read-adopted', - }; - const queueProcessor = { - onInvocationComplete: async () => {}, - tryAutoExecute: async () => {}, - registerEntryCompleteHook: () => {}, - unregisterEntryCompleteHook: () => {}, - resolvePromptMessageCustodyWakes: async () => [wake], - }; - const app = await createApp({ turnExecutionStore, queueProcessor }); - - const unbound = await app.inject({ - method: 'GET', - url: '/api/callbacks/thread-context?responseMode=full', - headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - }); - assert.equal(unbound.statusCode, 409); - assert.equal(JSON.parse(unbound.body).code, 'TURN_CUSTODY_ADOPTION_UNAVAILABLE'); - - const adopted = []; - const unregister = turnCustodyAdoptionRegistry.register(invocationId, async (wakes) => adopted.push(...wakes)); - let released = false; - try { - const response = await app.inject({ - method: 'GET', - url: '/api/callbacks/thread-context?responseMode=full', - headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - }); - assert.equal(response.statusCode, 200, response.body); - assert.ok(JSON.parse(response.body).messages.some((message) => message.id === stored.id)); - assert.deepEqual(adopted, [wake]); - - await unregister(); - released = true; - const afterRelease = await app.inject({ - method: 'GET', - url: '/api/callbacks/thread-context?responseMode=full', - headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - }); - assert.equal(afterRelease.statusCode, 409); - assert.equal(JSON.parse(afterRelease.body).code, 'TURN_CUSTODY_ADOPTION_UNAVAILABLE'); - assert.deepEqual(adopted, [wake], 'released route ownership must reject later adoption'); - } finally { - if (!released) await unregister(); - turnCustodyAdoptionRegistry.resetForTest(); - } + assert.equal(await invocationQueue.getDurableEntry('thread-queued-d12a', queued.entry.id), null); }); - test('F254/F264: queued body exposure and handled closure use the exact child invocation id', async () => { + test('F254/F264: queued body adoption binds the exact child response in History', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - invocationQueue = new InvocationQueue(); - const app = await createApp(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); const outerParentInv = 'outer-parent-d12b-token'; const { invocationId: innerInv, callbackToken } = await registry.create( 'user-1', @@ -5692,6 +5235,7 @@ describe('Callback Routes', () => { }); const queued = invocationQueue.enqueue({ + kind: 'conversation_input', ownerAuthProvenance: 'unknown', threadId: 'thread-queued-d12b-token', userId: 'user-1', @@ -5704,6 +5248,12 @@ describe('Callback Routes', () => { intent: 'execute', messageId: storedQueuedMessage.id, }); + const { processor, response: lifecycleResponse } = await createQueuedReadProcessor({ + threadId: 'thread-queued-d12b-token', + invocationId: innerInv, + parentInvocationId: outerParentInv, + }); + const app = await createApp({ queueProcessor: processor }); const response = await app.inject({ method: 'GET', @@ -5718,31 +5268,24 @@ describe('Callback Routes', () => { 'full read must include the queued body before marking seen', ); - const parentHandled = invocationQueue.markQueuedHandledForCatAcrossUsers( - 'thread-queued-d12b-token', - 'opus', - outerParentInv, - ); - assert.deepEqual(parentHandled, [], 'parent aggregate identity must not impersonate the child that read the body'); - const handled = invocationQueue.markQueuedHandledForCatAcrossUsers('thread-queued-d12b-token', 'opus', innerInv); - assert.equal(handled.length, 1, 'exact child completion must close its queued_seen evidence'); - assert.equal(handled[0].fullyConsumed, true); - assert.equal( - invocationQueue.list('thread-queued-d12b-token', 'user-1').some((entry) => entry.id === queued.entry.id), - false, - 'handled closure should consume the queued entry when completion evidence uses the exact child id', - ); + assert.equal(await invocationQueue.getDurableEntry('thread-queued-d12b-token', queued.entry.id), null); + const responseLifecycle = messageStore.getById(lifecycleResponse.id).lifecycle; + assert.equal(responseLifecycle.invocationId, innerInv); + assert.notEqual(responseLifecycle.invocationId, outerParentInv); + assert.deepEqual(responseLifecycle.inputEntryIds, [queued.entry.id]); + assert.deepEqual(responseLifecycle.inputMessageIds, [storedQueuedMessage.id]); }); test('F254 D1.2a: sparse thread-context read does not include queued body or mark queued_seen', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const queuedTelemetry = await import('../dist/domains/cats/services/freshness/freshness-queue-telemetry.js'); queuedTelemetry.resetFreshnessQueueTelemetryForTest(); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-queued-sparse'); invocationQueue.enqueue({ + kind: 'conversation_input', ownerAuthProvenance: 'unknown', threadId: 'thread-queued-sparse', userId: 'user-1', diff --git a/packages/api/test/callback-start-vote.test.js b/packages/api/test/callback-start-vote.test.js index 50b9d98a3e..79f379748a 100644 --- a/packages/api/test/callback-start-vote.test.js +++ b/packages/api/test/callback-start-vote.test.js @@ -7,6 +7,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; import './helpers/setup-cat-registry.js'; +import { adaptInvocationQueue } from './helpers/message-from-fixtures.js'; describe('POST /api/callbacks/start-vote', () => { let registry; @@ -125,8 +126,9 @@ describe('POST /api/callbacks/start-vote', () => { }, }); - assert.equal(persistedMessages.length, 1, 'should persist 1 notification message'); - const msg = persistedMessages[0]; + const persisted = messageStore.getByThread(thread.id, 10, 'user-1'); + assert.equal(persisted.length, 1, 'should persist 1 notification message'); + const msg = persisted[0]; assert.ok(msg.content.includes('投票请求'), 'notification should contain vote prompt'); assert.ok(msg.content.includes('[VOTE:'), 'notification should contain VOTE example'); assert.equal(msg.catId, 'opus', 'catId should be the initiating cat'); @@ -276,7 +278,7 @@ describe('POST /api/callbacks/start-vote', () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const invocationRecords = []; - const invocationQueue = new InvocationQueue(); + const invocationQueue = adaptInvocationQueue(new InvocationQueue()); const app = Fastify(); await app.register(callbacksRoutes, { registry, @@ -307,7 +309,7 @@ describe('POST /api/callbacks/start-vote', () => { invocationQueue, queueProcessor: { onInvocationComplete: async () => {}, - tryAutoExecute: async () => {}, + requestDrain: async () => {}, registerEntryCompleteHook: () => {}, unregisterEntryCompleteHook: () => {}, }, @@ -366,7 +368,7 @@ describe('POST /api/callbacks/start-vote', () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const dispatchedCats = []; - const invocationQueue = new InvocationQueue(); + const invocationQueue = adaptInvocationQueue(new InvocationQueue()); const app = Fastify(); await app.register(callbacksRoutes, { registry, @@ -397,7 +399,7 @@ describe('POST /api/callbacks/start-vote', () => { invocationQueue, queueProcessor: { onInvocationComplete: async () => {}, - tryAutoExecute: async () => {}, + requestDrain: async () => {}, registerEntryCompleteHook: () => {}, unregisterEntryCompleteHook: () => {}, }, @@ -423,6 +425,14 @@ describe('POST /api/callbacks/start-vote', () => { // At least the queue should have been populated or fallback dispatch triggered const dispatched = queueEntries.length > 0 || dispatchedCats.length > 0; assert.ok(dispatched, 'voter cats should be dispatched after start-vote'); + const [notification] = messageStore.getByThread(thread.id, 10, 'user-1'); + assert.ok(notification, 'vote notification must be durable'); + assert.equal(notification.deliveryStatus, undefined, 'Agent speech remains ordinary published History'); + assert.deepEqual(notification.lifecycle.dispatchRefs, []); + assert.ok( + queueEntries.every((entry) => entry.payload.messageId === notification.id), + 'every voter row must bind the exact atomically-published notification', + ); }); test('voters > MAX_QUEUE_DEPTH: all enqueued (F175: agent source bypasses depth limit)', async () => { @@ -430,7 +440,7 @@ describe('POST /api/callbacks/start-vote', () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const fallbackTargets = []; - const invocationQueue = new InvocationQueue(); + const invocationQueue = adaptInvocationQueue(new InvocationQueue()); const app = Fastify(); await app.register(callbacksRoutes, { registry, @@ -462,7 +472,7 @@ describe('POST /api/callbacks/start-vote', () => { invocationQueue, queueProcessor: { onInvocationComplete: async () => {}, - tryAutoExecute: async () => {}, + requestDrain: async () => {}, registerEntryCompleteHook: () => {}, unregisterEntryCompleteHook: () => {}, }, @@ -485,102 +495,12 @@ describe('POST /api/callbacks/start-vote', () => { assert.equal(res.statusCode, 200); - // F175: agent source bypasses MAX_QUEUE_DEPTH — all 6 voters should be enqueued + // F175: agent source bypasses MAX_QUEUE_DEPTH — all voters share one source row. const queueEntries = invocationQueue.listAutoExecute?.(thread.id) ?? []; - assert.equal(queueEntries.length, 6, 'all 6 voters should be enqueued (agent bypasses depth limit)'); + assert.equal(queueEntries.length, 1, 'all voters should share one source Queue row'); + assert.deepEqual(queueEntries[0].targets, ['codex', 'gemini', 'sonnet', 'gpt52', 'spark', 'antig-opus']); // No fallback needed — all fit in queue assert.equal(fallbackTargets.length, 0, 'no fallback needed when agent source bypasses depth limit'); }); - - // F216 AC-D5: coalesced voters must NOT be counted as missed → no direct dispatch fallback. - test('coalesced voter does NOT trigger direct dispatch fallback (F216 AC-D5)', async () => { - const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - - const fallbackTargets = []; - const invocationQueue = new InvocationQueue(); - - // Pre-enqueue an entry for 'codex' from caller 'opus' — second handoff will coalesce. - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: '', // placeholder — will be set by the real thread below - userId: 'user-1', - content: 'pre-existing task for codex', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - - const app = Fastify(); - await app.register(callbacksRoutes, { - registry, - messageStore, - socketManager, - threadStore, - router: { - routeExecution: async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - invocationRecordStore: { - create: async (opts) => { - fallbackTargets.push(...opts.targetCats); - return { outcome: 'created', invocationId: `inv-${Date.now()}` }; - }, - update: async () => {}, - }, - invocationTracker: { - start: () => new AbortController(), - startAll: () => new AbortController(), - tryStartThreadAll: () => new AbortController(), - complete: () => {}, - completeAll: () => {}, - has: () => false, - getActiveSlots: () => [], - }, - invocationQueue, - queueProcessor: { - onInvocationComplete: async () => {}, - tryAutoExecute: async () => {}, - registerEntryCompleteHook: () => {}, - unregisterEntryCompleteHook: () => {}, - }, - }); - - const thread = threadStore.create('user-1', 'Test'); - - // Now enqueue with the real threadId so coalesce can find it - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: thread.id, - userId: 'user-1', - content: 'pre-existing task for codex', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', thread.id); - - const res = await app.inject({ - method: 'POST', - url: '/api/callbacks/start-vote', - headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, - payload: { - question: 'Coalesce test?', - options: ['A', 'B'], - voters: ['codex', 'gemini'], // codex will coalesce, gemini will enqueue - }, - }); - - assert.equal(res.statusCode, 200); - // 'codex' should have coalesced (not missed), 'gemini' should have enqueued (not missed). - // Neither should have triggered direct dispatch fallback. - assert.equal(fallbackTargets.length, 0, 'coalesced voter must NOT trigger direct fallback (AC-D5)'); - }); }); diff --git a/packages/api/test/callbacks-f182-c.test.js b/packages/api/test/callbacks-f182-c.test.js index db03507892..1180c99cb0 100644 --- a/packages/api/test/callbacks-f182-c.test.js +++ b/packages/api/test/callbacks-f182-c.test.js @@ -379,9 +379,12 @@ describe("F182 C2 - A' class: multi_mention disabled target → 400", () => { let mockRouter; let app; let creds; + let invocationQueue; beforeEach(async () => { const { resetMultiMentionOrchestrator } = await import('../dist/routes/callback-multi-mention-routes.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); resetMultiMentionOrchestrator(); // Simple mock registry (follows multi-mention-routes.test.js pattern) @@ -390,7 +393,14 @@ describe("F182 C2 - A' class: multi_mention disabled target → 400", () => { register(catId, threadId, userId) { const id = `inv-${records.size}`; const token = `tok-${records.size}`; - records.set(id, { catId, threadId, userId, invocationId: id, callbackToken: token }); + records.set(id, { + catId, + threadId, + userId, + invocationId: id, + callbackToken: token, + ownerAuthProvenance: 'unknown', + }); return { invocationId: id, callbackToken: token }; }, async verify(invocationId, callbackToken) { @@ -408,7 +418,8 @@ describe("F182 C2 - A' class: multi_mention disabled target → 400", () => { }; mockSocket = createMockSocketManager(); - mockMessageStore = { append: async (m) => ({ id: 'msg-0', ...m }), getById: async () => null }; + mockMessageStore = new MessageStore(); + invocationQueue = new InvocationQueue(); mockInvocationRecordStore = { create: async () => null, get: async () => null, @@ -422,6 +433,26 @@ describe("F182 C2 - A' class: multi_mention disabled target → 400", () => { }; creds = mockRegistry.register('opus', 'thread-1', 'user-1'); + await mockMessageStore.append({ + from: { kind: 'agent', catId: 'opus' }, + userId: 'user-1', + content: 'checking target availability', + mentions: [], + origin: 'stream', + timestamp: 100, + threadId: 'thread-1', + idempotencyKey: `message-lifecycle-response:${creds.invocationId}`, + lifecycle: { + kind: 'response', + orderKey: `100:${creds.invocationId}`, + invocationId: creds.invocationId, + targetId: 'opus', + inputEntryIds: [], + inputMessageIds: [], + status: 'processing', + startedAt: 100, + }, + }); const { registerCallbackAuthHook } = await import('../dist/routes/callback-auth-prehandler.js'); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); @@ -433,6 +464,8 @@ describe("F182 C2 - A' class: multi_mention disabled target → 400", () => { socketManager: mockSocket, router: mockRouter, invocationRecordStore: mockInvocationRecordStore, + invocationQueue, + queueProcessor: { async requestDrain() {} }, }); await app.ready(); }); diff --git a/packages/api/test/caller-dispatch-observation-registry.test.js b/packages/api/test/caller-dispatch-observation-registry.test.js new file mode 100644 index 0000000000..bfadb75293 --- /dev/null +++ b/packages/api/test/caller-dispatch-observation-registry.test.js @@ -0,0 +1,326 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { CallerDispatchObservationRegistry, callerDispatchObservationKey } = await import( + '../dist/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.js' +); +const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + +function inputMessage({ refs, from = { kind: 'agent', catId: 'caller' }, id = 'source-1' }) { + return { + id, + threadId: 'thread-1', + userId: 'owner-1', + from, + content: 'please investigate', + timestamp: 1, + lifecycle: { kind: 'input', orderKey: `1:${id}`, dispatchRefs: refs }, + }; +} + +function responseMessage(id, targetId, status, content = `${targetId} result`, sourceMessageId = 'source-1') { + return { + id, + threadId: 'thread-1', + userId: 'owner-1', + from: { kind: 'agent', catId: targetId }, + content, + timestamp: 2, + lifecycle: { + kind: 'response', + orderKey: `2:${id}`, + invocationId: `inv-${targetId}`, + targetId, + inputEntryIds: ['entry-1'], + inputMessageIds: [sourceMessageId], + status, + startedAt: 2, + ...(status === 'processing' ? {} : { completedAt: 3 }), + }, + }; +} + +const scope = { ownerId: 'owner-1', threadId: 'thread-1', callerCatId: 'caller' }; + +describe('CallerDispatchObservationRegistry', () => { + it('projects exact terminal/open targets and clears only terminal keys included in the prompt', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ + refs: [ + { targetId: 'b', phase: 'settled', statusMessageId: 'response-b', dispatchedAt: 2 }, + { targetId: 'c', phase: 'dispatched', statusMessageId: 'response-c', dispatchedAt: 2 }, + ], + }); + registry.registerPersistedSource(source, ['b', 'c']); + registry.registerPersistedSource(source, ['b']); + + const messages = new Map([ + [source.id, source], + ['response-b', responseMessage('response-b', 'b', 'completed')], + ['response-c', responseMessage('response-c', 'c', 'processing')], + ]); + const projection = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + + assert.match(projection.prompt, /source-1 → b: completed/); + assert.match(projection.prompt, /source-1 → c: executing/); + assert.deepEqual( + projection.included.filter((item) => item.terminal).map((item) => item.key), + [callerDispatchObservationKey({ ...scope, sourceMessageId: 'source-1', targetId: 'b' })], + ); + + const currentTurnSource = inputMessage({ + id: 'source-current-turn', + refs: [{ targetId: 'd', phase: 'dispatched', statusMessageId: 'response-d', dispatchedAt: 4 }], + }); + registry.registerPersistedSource(currentTurnSource, ['d']); + registry.acknowledge(projection.included); + assert.deepEqual( + registry.list(scope).map((pointer) => pointer.targetId), + ['c', 'd'], + ); + }); + + it('follows the source ref when delivery failure replaces the original response identity', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ + refs: [{ targetId: 'b', phase: 'settled', statusMessageId: 'failure-b', dispatchedAt: 2 }], + }); + const failure = { + id: 'failure-b', + threadId: 'thread-1', + userId: 'owner-1', + from: { kind: 'system', service: 'message-delivery' }, + catId: null, + content: 'b could not start', + timestamp: 3, + lifecycle: { + kind: 'delivery_failure', + orderKey: '3:failure-b', + status: 'failed', + sourceEntryId: 'entry-1', + inputMessageId: 'source-1', + requestedTargets: ['b'], + reason: 'prestart_timeout', + createdAt: 3, + }, + }; + registry.registerPersistedSource(source, ['b']); + const messages = new Map([ + [source.id, source], + [failure.id, failure], + ]); + + const projection = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + assert.match(projection.prompt, /delivery_failure\(prestart_timeout\)/); + assert.match(projection.prompt, /b could not start/); + assert.equal(projection.included.filter((item) => item.terminal).length, 1); + }); + + it('keeps same-target dispatches from different sources independent and projects every response terminal', async () => { + const registry = new CallerDispatchObservationRegistry(); + const terminals = ['failed', 'canceled', 'interrupted']; + const messages = new Map(); + for (const [index, status] of terminals.entries()) { + const sourceId = `source-${status}`; + const responseId = `response-${status}`; + const source = inputMessage({ + id: sourceId, + refs: [{ targetId: 'b', phase: 'settled', statusMessageId: responseId, dispatchedAt: index + 2 }], + }); + const response = responseMessage(responseId, 'b', status, `${status} body`, sourceId); + messages.set(sourceId, source); + messages.set(responseId, response); + registry.registerPersistedSource(source, ['b']); + } + + const projection = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + for (const status of terminals) { + assert.match(projection.prompt, new RegExp(`source-${status} → b: ${status}`)); + assert.match(projection.prompt, new RegExp(`${status} body`)); + } + assert.equal(projection.included.filter((item) => item.terminal).length, terminals.length); + assert.equal(new Set(projection.included.map((item) => item.key)).size, terminals.length); + }); + + it('keeps two pending sources for the same target as independent caller view items', async () => { + const registry = new CallerDispatchObservationRegistry(); + const first = inputMessage({ id: 'source-m1', refs: [] }); + const second = inputMessage({ id: 'source-m2', refs: [] }); + registry.registerInitialSource(first, ['b']); + registry.registerInitialSource(second, ['b']); + const messages = new Map([ + [first.id, first], + [second.id, second], + ]); + + const projection = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + + assert.match(projection.prompt, /source-m1 → b: pending/); + assert.match(projection.prompt, /source-m2 → b: pending/); + assert.equal(projection.included.length, 2); + assert.equal(new Set(projection.included.map((item) => item.key)).size, 2); + }); + + it('retains unknown and budget-truncated terminal observations', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ + refs: [{ targetId: 'b', phase: 'settled', statusMessageId: 'response-b', dispatchedAt: 2 }], + }); + registry.registerPersistedSource(source, ['b']); + + const unknown = await registry.project({ getById: async () => null }, scope); + assert.match(unknown.prompt, /unknown/); + assert.equal(unknown.included.length, 1); + assert.equal(unknown.included[0].terminal, false); + + const messages = new Map([ + [source.id, source], + ['response-b', responseMessage('response-b', 'b', 'completed', 'x'.repeat(2_000))], + ]); + const truncated = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope, 80); + assert.equal(truncated.truncated, true); + assert.deepEqual(truncated.included, []); + assert.equal(registry.list(scope).length, 1); + }); + + it('does not expose or consume a deleted terminal response fetched by exact id', async () => { + const registry = new CallerDispatchObservationRegistry(); + const store = new MessageStore(); + const source = store.append( + inputMessage({ + refs: [{ targetId: 'b', phase: 'settled', statusMessageId: 'response-b', dispatchedAt: 2 }], + }), + ); + const response = store.append(responseMessage('ignored-id', 'b', 'completed', 'deleted secret', source.id)); + source.lifecycle.dispatchRefs[0] = { + targetId: 'b', + phase: 'settled', + statusMessageId: response.id, + dispatchedAt: 2, + }; + registry.registerPersistedSource(source, ['b']); + store.softDelete(response.id, 'owner-1'); + + const projection = await registry.project(store, scope); + assert.match(projection.prompt, /unknown/); + assert.doesNotMatch(projection.prompt, /deleted secret/); + assert.equal(projection.included.length, 1); + assert.equal(projection.included[0].terminal, false); + assert.equal(registry.list(scope).length, 1); + }); + + it('retains unknown when a settled ref points at another target or source lineage', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ + refs: [{ targetId: 'b', phase: 'settled', statusMessageId: 'response-other', dispatchedAt: 2 }], + }); + registry.registerPersistedSource(source, ['b']); + const wrongTarget = responseMessage('response-other', 'c', 'completed', 'wrong target result'); + const messages = new Map([ + [source.id, source], + [wrongTarget.id, wrongTarget], + ]); + + const targetMismatch = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + assert.match(targetMismatch.prompt, /unknown/); + assert.doesNotMatch(targetMismatch.prompt, /wrong target result/); + assert.equal(targetMismatch.included.length, 1); + assert.equal(targetMismatch.included[0].terminal, false); + + messages.set( + 'response-other', + responseMessage('response-other', 'b', 'completed', 'wrong source result', 'source-2'), + ); + const sourceMismatch = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + assert.match(sourceMismatch.prompt, /unknown/); + assert.doesNotMatch(sourceMismatch.prompt, /wrong source result/); + assert.equal(sourceMismatch.included.length, 1); + assert.equal(sourceMismatch.included[0].terminal, false); + }); + + it('prioritizes changed terminal facts over still-open facts when the prompt budget is tight', async () => { + const source = inputMessage({ + refs: [ + { targetId: 'open', phase: 'dispatched', statusMessageId: 'response-open', dispatchedAt: 2 }, + { targetId: 'done', phase: 'settled', statusMessageId: 'response-done', dispatchedAt: 2 }, + ], + }); + const messages = new Map([ + [source.id, source], + ['response-open', responseMessage('response-open', 'open', 'processing')], + ['response-done', responseMessage('response-done', 'done', 'completed', 'done body')], + ]); + const store = { getById: async (id) => messages.get(id) ?? null }; + const terminalOnly = new CallerDispatchObservationRegistry(); + terminalOnly.registerPersistedSource(source, ['done']); + const terminalBudget = (await terminalOnly.project(store, scope)).prompt.length; + + const registry = new CallerDispatchObservationRegistry(); + registry.registerPersistedSource(source, ['open', 'done']); + const projection = await registry.project(store, scope, terminalBudget); + assert.match(projection.prompt, /source-1 → done: completed/); + assert.doesNotMatch(projection.prompt, /source-1 → open: executing/); + assert.equal(projection.included.filter((item) => item.terminal).length, 1); + assert.equal(projection.truncated, true); + assert.ok(projection.prompt.length <= terminalBudget); + }); + + it('does not consume a target that becomes terminal after this turn projection was built', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ + refs: [{ targetId: 'b', phase: 'dispatched', statusMessageId: 'response-b', dispatchedAt: 2 }], + }); + const response = responseMessage('response-b', 'b', 'processing'); + const messages = new Map([ + [source.id, source], + [response.id, response], + ]); + registry.registerPersistedSource(source, ['b']); + + const beforeTerminal = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + assert.match(beforeTerminal.prompt, /source-1 → b: executing/); + assert.equal(beforeTerminal.included.filter((item) => item.terminal).length, 0); + + source.lifecycle.dispatchRefs[0] = { + targetId: 'b', + phase: 'settled', + statusMessageId: 'response-b', + dispatchedAt: 2, + }; + response.lifecycle.status = 'completed'; + response.lifecycle.completedAt = 3; + registry.acknowledge(beforeTerminal.included); + + const nextTurn = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + assert.match(nextTurn.prompt, /source-1 → b: completed/); + assert.equal(nextTurn.included.filter((item) => item.terminal).length, 1); + }); + + it('acknowledges an unchanged pending version without deleting it or repeating it', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ refs: [] }); + registry.registerInitialSource(source, ['b']); + const store = { getById: async (id) => (id === source.id ? source : null) }; + + const first = await registry.project(store, scope); + assert.match(first.prompt, /source-1 → b: pending; selectedBy=initial/); + assert.equal(first.included.length, 1); + const pendingRevision = first.included[0].includedRevision; + registry.acknowledge(first.included); + + const second = await registry.project(store, scope); + assert.equal(second.prompt, ''); + assert.equal(second.included.length, 0); + assert.equal(registry.list(scope).length, 1); + + source.lifecycle.dispatchRefs.push({ + targetId: 'b', + phase: 'dispatched', + statusMessageId: 'response-b', + dispatchedAt: 2, + }); + const changed = await registry.project(store, scope); + assert.match(changed.prompt, /source-1 → b: executing/); + assert.ok(changed.included[0].includedRevision > pendingRevision); + }); +}); diff --git a/packages/api/test/caller-dispatch-observation-rich-terminal.test.js b/packages/api/test/caller-dispatch-observation-rich-terminal.test.js new file mode 100644 index 0000000000..a52a7bc576 --- /dev/null +++ b/packages/api/test/caller-dispatch-observation-rich-terminal.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { CallerDispatchObservationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.js' +); + +const scope = { ownerId: 'owner-1', threadId: 'thread-1', callerCatId: 'caller' }; + +function sourceFor(responseId) { + return { + id: 'source-rich', + threadId: scope.threadId, + userId: scope.ownerId, + from: { kind: 'agent', catId: scope.callerCatId }, + content: 'inspect the artifact', + timestamp: 1, + lifecycle: { + kind: 'input', + orderKey: '1:source-rich', + dispatchRefs: [{ targetId: 'worker', phase: 'settled', statusMessageId: responseId, dispatchedAt: 2 }], + }, + }; +} + +function terminal(responseId, contentBlocks) { + return { + id: responseId, + threadId: scope.threadId, + userId: scope.ownerId, + from: { kind: 'agent', catId: 'worker' }, + content: '', + contentBlocks, + timestamp: 2, + lifecycle: { + kind: 'response', + orderKey: `2:${responseId}`, + invocationId: 'inv-worker', + targetId: 'worker', + inputEntryIds: ['entry-rich'], + inputMessageIds: ['source-rich'], + status: 'completed', + startedAt: 1, + completedAt: 2, + }, + }; +} + +describe('caller dispatch rich terminal projection', () => { + it('summarizes a rich-only terminal before consuming its observation', async () => { + const registry = new CallerDispatchObservationRegistry(); + const response = terminal('response-rich', [ + { type: 'image', url: '/uploads/result.png', alt: 'verification screenshot' }, + { type: 'file', url: '/uploads/report.pdf', fileName: 'report.pdf', mimeType: 'application/pdf', fileSize: 42 }, + ]); + const source = sourceFor(response.id); + const messages = new Map([ + [source.id, source], + [response.id, response], + ]); + registry.registerPersistedSource(source, ['worker']); + + const projection = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + + assert.match(projection.prompt, /completed/); + assert.match(projection.prompt, /\[图片: verification screenshot\]/); + assert.match(projection.prompt, /\[文件: report\.pdf\]/); + assert.equal(projection.included[0]?.terminal, true); + registry.acknowledge(projection.included); + assert.equal(registry.list(scope).length, 0); + }); + + it('retains a terminal whose visible payload cannot be represented', async () => { + const registry = new CallerDispatchObservationRegistry(); + const response = terminal('response-tool-only', [ + { type: 'tool_result', toolId: 'tool-1', result: { secretStructuredResult: true } }, + ]); + const source = sourceFor(response.id); + const messages = new Map([ + [source.id, source], + [response.id, response], + ]); + registry.registerPersistedSource(source, ['worker']); + + const projection = await registry.project({ getById: async (id) => messages.get(id) ?? null }, scope); + + assert.match(projection.prompt, /unknown/); + assert.doesNotMatch(projection.prompt, /secretStructuredResult/); + assert.equal(projection.included[0]?.terminal, false); + registry.acknowledge(projection.included); + assert.equal(registry.list(scope).length, 1); + }); +}); diff --git a/packages/api/test/caller-dispatch-observation-selection.test.js b/packages/api/test/caller-dispatch-observation-selection.test.js new file mode 100644 index 0000000000..85f3b319af --- /dev/null +++ b/packages/api/test/caller-dispatch-observation-selection.test.js @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { CallerDispatchObservationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.js' +); + +function inputMessage({ refs, from = { kind: 'agent', catId: 'caller' }, id = 'source-1' }) { + return { + id, + threadId: 'thread-1', + userId: 'owner-1', + from, + content: 'please investigate', + timestamp: 1, + lifecycle: { kind: 'input', orderKey: `1:${id}`, dispatchRefs: refs }, + }; +} + +const scope = { ownerId: 'owner-1', threadId: 'thread-1', callerCatId: 'caller' }; + +describe('CallerDispatchObservationRegistry selection and generation state', () => { + it('retains initial and Steer selection facts after a target leaves Queue', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ refs: [] }); + registry.registerInitialSource(source, ['c', 'd']); + registry.registerSteerChanges(source, { addedTargetIds: ['e'], removedTargetIds: ['c'] }); + + const entries = new Map(registry.list(scope).map((entry) => [entry.targetId, entry])); + assert.equal(entries.get('c').firstAddedBy, 'initial'); + assert.equal(entries.get('c').selectionChange, 'removed'); + assert.equal(entries.get('c').selectionChangedBy, 'steer'); + assert.equal(entries.get('e').firstAddedBy, 'steer'); + assert.equal(entries.get('e').selectionChange, 'added'); + assert.equal(entries.get('e').selectionChangedBy, 'steer'); + + const projection = await registry.project({ getById: async (id) => (id === source.id ? source : null) }, scope); + assert.match(projection.prompt, /source-1 → c: not_delivered\(withdrawn by committed Steer\)/); + assert.match(projection.prompt, /source-1 → d: pending; selectedBy=initial/); + assert.match(projection.prompt, /source-1 → e: pending; selectedBy=steer/); + }); + + it('does not guess the original selection source when this runtime first sees a removal', () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ refs: [] }); + registry.registerSteerChanges(source, { addedTargetIds: [], removedTargetIds: ['c'] }); + + assert.equal(registry.list(scope)[0].firstAddedBy, 'unknown'); + assert.equal(registry.list(scope)[0].selectionChange, 'removed'); + assert.equal(registry.list(scope)[0].selectionChangedBy, 'steer'); + }); + + it('distinguishes whole-Queue cancellation from a Steer target removal', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ refs: [] }); + registry.registerInitialSource(source, ['b']); + registry.registerQueueWithdrawalByIdentity({ + ownerId: source.userId, + threadId: source.threadId, + callerCatId: 'caller', + sourceMessageId: source.id, + targetIds: ['b'], + }); + + const pointer = registry.list(scope)[0]; + assert.equal(pointer.selectionChange, 'removed'); + assert.equal(pointer.selectionChangedBy, 'queue_withdrawal'); + const projection = await registry.project({ getById: async () => source }, scope); + assert.match(projection.prompt, /not_delivered\(withdrawn by committed Queue cancellation\)/); + assert.doesNotMatch(projection.prompt, /Steer/); + }); + + it('does not attach a newer Steer revision to a line read from an older snapshot', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ refs: [] }); + registry.registerInitialSource(source, ['c']); + let releaseRead; + let markReadStarted; + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const release = new Promise((resolve) => { + releaseRead = resolve; + }); + + const projecting = registry.project( + { + getById: async () => { + markReadStarted(); + await release; + return source; + }, + }, + scope, + ); + await readStarted; + registry.registerSteerChanges(source, { addedTargetIds: [], removedTargetIds: ['c'] }); + releaseRead(); + + const stale = await projecting; + assert.equal(stale.prompt, ''); + assert.equal(stale.included.length, 0); + const next = await registry.project({ getById: async () => source }, scope); + assert.match(next.prompt, /not_delivered/); + }); + + it('allocates a newer revision when an acknowledged key is recreated', async () => { + const registry = new CallerDispatchObservationRegistry(); + const source = inputMessage({ refs: [] }); + registry.registerInitialSource(source, ['c']); + registry.registerSteerChanges(source, { addedTargetIds: [], removedTargetIds: ['c'] }); + const store = { getById: async () => source }; + + const withdrawn = await registry.project(store, scope); + const withdrawnRevision = withdrawn.included[0].includedRevision; + registry.acknowledge(withdrawn.included); + assert.equal(registry.list(scope).length, 0); + + registry.registerSteerChanges(source, { addedTargetIds: ['c'], removedTargetIds: [] }); + const recreated = registry.list(scope)[0]; + assert.ok(recreated.revision > withdrawnRevision); + assert.equal(recreated.firstAddedBy, 'steer'); + }); + + it('ignores user-authored sources because observations belong to the dispatching member', () => { + const registry = new CallerDispatchObservationRegistry(); + registry.registerPersistedSource( + inputMessage({ + from: { kind: 'user', userId: 'owner-1' }, + refs: [{ targetId: 'b', phase: 'settled', statusMessageId: 'response-b', dispatchedAt: 2 }], + }), + ['b'], + ); + assert.equal(registry.list(scope).length, 0); + }); + + it('projects one neutral process-start notice per scope and exact process generation', () => { + const registry = new CallerDispatchObservationRegistry(); + const first = registry.projectProcessStartNotice(scope, 'api:123:7'); + assert.match(first.prompt, /process_start processGeneration=api:123:7/); + assert.match(first.prompt, /仅覆盖本 API 进程登记的 dispatch/); + assert.match(first.prompt, /canonical History/); + assert.doesNotMatch(first.prompt, /runtime_restart|recoveryRequired|丢失/); + + registry.acknowledgeProcessStartNotice(scope, 'api:wrong:8'); + assert.match(registry.projectProcessStartNotice(scope, 'api:123:7').prompt, /process_start/); + registry.acknowledgeProcessStartNotice(scope, 'api:123:7'); + assert.equal(registry.projectProcessStartNotice(scope, 'api:123:7').prompt, ''); + assert.match(registry.projectProcessStartNotice(scope, 'api:124:9').prompt, /processGeneration=api:124:9/); + }); + + it('acknowledges process-start without clearing current-process observations', () => { + const registry = new CallerDispatchObservationRegistry(); + registry.registerPersistedSource( + inputMessage({ refs: [{ targetId: 'b', phase: 'dispatched', statusMessageId: 'response-b', dispatchedAt: 2 }] }), + ['b'], + ); + + registry.projectProcessStartNotice(scope, 'api:123:7'); + registry.acknowledgeProcessStartNotice(scope, 'api:123:7'); + assert.equal(registry.projectProcessStartNotice(scope, 'api:123:7').prompt, ''); + assert.equal(registry.list(scope).length, 1); + }); + + it('bounds retained items and History reads for one caller slot', async () => { + const registry = new CallerDispatchObservationRegistry(); + const messages = new Map(); + for (let index = 0; index < 140; index += 1) { + const source = inputMessage({ id: `source-${index}`, refs: [] }); + messages.set(source.id, source); + registry.registerInitialSource(source, ['b']); + } + assert.equal(registry.list(scope).length, 128); + + let reads = 0; + const projection = await registry.project( + { + getById: async (id) => { + reads += 1; + return messages.get(id) ?? null; + }, + }, + scope, + 100_000, + ); + assert.equal(reads, 64); + assert.equal(projection.included.length, 64); + assert.equal(projection.truncated, true); + }); +}); diff --git a/packages/api/test/cancel-broadcast.test.js b/packages/api/test/cancel-broadcast.test.js index 418ddf1af8..f65b6ed7d2 100644 --- a/packages/api/test/cancel-broadcast.test.js +++ b/packages/api/test/cancel-broadcast.test.js @@ -3,8 +3,8 @@ * Verifies that cancel_invocation produces correct catId-aware broadcasts. * * Tests the REAL production buildCancelMessages function (not a copy). - * - Single system_info message (no "cancel chorus") * - Per-cat done messages to clear each cat's loading state + * - No duplicate system row: the durable response bubble owns the terminal label */ import './helpers/setup-cat-registry.js'; @@ -15,35 +15,29 @@ const { InvocationTracker } = await import('../dist/domains/cats/services/agents const { buildCancelMessages } = await import('../dist/infrastructure/websocket/SocketManager.js'); describe('buildCancelMessages (production function)', () => { - test('single cat cancel: 1 system_info + 1 done with correct catId', () => { + test('single cat cancel: only 1 done with correct catId', () => { const tracker = new InvocationTracker(); // start(threadId, catId, userId, catIds) tracker.start('t1', 'gemini', 'user1', ['gemini']); const result = tracker.cancel('t1', 'gemini', 'user1'); const messages = buildCancelMessages(result); - assert.equal(messages.length, 2); - assert.equal(messages[0].type, 'system_info'); + assert.equal(messages.length, 1); + assert.equal(messages[0].type, 'done'); assert.equal(messages[0].catId, 'gemini'); - assert.equal(messages[1].type, 'done'); - assert.equal(messages[1].catId, 'gemini'); - assert.equal(messages[1].isFinal, true); + assert.equal(messages[0].isFinal, true); }); - test('multi-cat cancel: 1 system_info + N done (no cancel chorus)', () => { + test('multi-cat cancel: only N done messages (no duplicate system row)', () => { const tracker = new InvocationTracker(); // start(threadId, catId, userId, catIds) — primary cat is opus tracker.start('t1', 'opus', 'user1', ['opus', 'codex', 'gemini']); const result = tracker.cancel('t1', 'opus', 'user1'); const messages = buildCancelMessages(result); - // 1 system_info + 3 done = 4 total - assert.equal(messages.length, 4); - - // Only one system_info (not three!) + assert.equal(messages.length, 3); const systemInfos = messages.filter((m) => m.type === 'system_info'); - assert.equal(systemInfos.length, 1); - assert.equal(systemInfos[0].catId, 'opus'); + assert.equal(systemInfos.length, 0); // Three done messages, one per cat const dones = messages.filter((m) => m.type === 'done'); @@ -77,9 +71,8 @@ describe('buildCancelMessages (production function)', () => { const result = tracker.cancel('t1', 'opus', 'user1'); const messages = buildCancelMessages(result); - assert.equal(messages.length, 2); + assert.equal(messages.length, 1); assert.equal(messages[0].catId, 'opus'); - assert.equal(messages[1].catId, 'opus'); }); test('failed cancel: no messages', () => { @@ -92,7 +85,8 @@ describe('buildCancelMessages (production function)', () => { // F32-a: createCatId accepts any non-empty string, so unknown catId no longer throws const result = { cancelled: true, catIds: ['unknown-cat'] }; const messages = buildCancelMessages(result); - assert.equal(messages.length, 2); // system + done + assert.equal(messages.length, 1); + assert.equal(messages[0].type, 'done'); assert.equal(messages[0].catId, 'unknown-cat'); }); }); diff --git a/packages/api/test/cancel-scope-guard.test.js b/packages/api/test/cancel-scope-guard.test.js index ffffd32026..bdac9637d1 100644 --- a/packages/api/test/cancel-scope-guard.test.js +++ b/packages/api/test/cancel-scope-guard.test.js @@ -89,6 +89,7 @@ function makeQueueProcessor() { releaseSlot: (tid, cid) => actions.push({ op: 'releaseSlot', tid, cid }), releaseThread: (tid) => actions.push({ op: 'releaseThread', tid }), retireThreadPrestartProcessingGroups: async () => ({ outcome: 'none', retiredCatIds: [] }), + hasProcessingSlotReservation: () => false, hasActiveExecution: () => false, isCatBusy: () => false, actions, @@ -162,7 +163,7 @@ describe('force-reset: does not affect other users processingSlots (P1 scope gua headers: { 'x-cat-cafe-user': USER_A }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 200, res.body); const body = JSON.parse(res.body); assert.equal(body.canceledRecords, 1); diff --git a/packages/api/test/capability-evolution-preparation-message.test.js b/packages/api/test/capability-evolution-preparation-message.test.js index 8a5d035935..ee717bc3b1 100644 --- a/packages/api/test/capability-evolution-preparation-message.test.js +++ b/packages/api/test/capability-evolution-preparation-message.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { adaptMessageStore } from './helpers/message-from-fixtures.js'; const PROGRAM_ID = 'evolution-program:0123456789abcdef0123456789abcdef'; const source = { ownerFeatureId: 'F117', ownerStateRef: 'message:source', version: 'v1' }; @@ -66,7 +67,7 @@ describe('F117 protected F311 preparation submission carrier', () => { const { MessageStore, deriveEvolutionPreparationSubmissionRevision } = await import( '../dist/domains/cats/services/stores/ports/MessageStore.js' ); - const store = new MessageStore(); + const store = adaptMessageStore(new MessageStore()); const payload = draft(); const submission = { ...payload, revision: deriveEvolutionPreparationSubmissionRevision(payload) }; const stored = store.append({ @@ -99,7 +100,7 @@ describe('F117 protected F311 preparation submission carrier', () => { const payload = draft(); const submission = { ...payload, revision: deriveEvolutionPreparationSubmissionRevision(payload) }; assert.throws(() => - new MessageStore().append({ + adaptMessageStore(new MessageStore()).append({ userId: 'operator', threadId: 'thread-preparation', catId: 'codex-terra', diff --git a/packages/api/test/capability-evolution-preparation-recovery.test.js b/packages/api/test/capability-evolution-preparation-recovery.test.js index e9daeab48d..7948dff75c 100644 --- a/packages/api/test/capability-evolution-preparation-recovery.test.js +++ b/packages/api/test/capability-evolution-preparation-recovery.test.js @@ -2,7 +2,6 @@ import './helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { before, beforeEach, describe, it } from 'node:test'; import { humanChoiceBody, objectBody, successBody } from './helpers/capability-evolution-preparation-bodies.js'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; describe('F311 preparation saga recovery and invalidation', () => { let EvolutionProgramService; @@ -115,10 +114,11 @@ describe('F311 preparation saga recovery and invalidation', () => { }); function humanInput(overrides = {}) { + const userId = overrides.userId ?? 'operator'; return messageStore.append({ - userId: 'operator', + userId, + from: { kind: 'user', userId }, threadId: thread.id, - catId: null, content: '保留当前预算,本轮继续比较路由策略。', mentions: [], timestamp: 1, @@ -165,7 +165,7 @@ describe('F311 preparation saga recovery and invalidation', () => { it('rejects forged, wrong-author, cross-workspace and unavailable human input before any commit', async () => { const programId = (await createProgram()).projection.program.programId; for (const overrides of [ - { catId: 'codex-terra' }, + { from: { kind: 'agent', catId: 'codex-terra' } }, { userId: 'another-user' }, { origin: 'callback' }, { sourceParseFailure: true }, @@ -253,31 +253,15 @@ describe('F311 preparation saga recovery and invalidation', () => { assert.equal(messageStore.getRecent(10, 'operator').length, 1); }); - for (const loss of ['delete', 'recall']) { + for (const loss of ['delete']) { it(`recovers the committed human-backed body after a crash and input ${loss}`, async () => { const programId = (await createProgram()).projection.program.programId; - const queueCustody = makeQueuedMessageCustody({ - status: 'terminal', - pendingTargetCats: [], - handledByCatIds: ['opus', 'codex'], - }); - const input = humanInput({ deliveryStatus: 'queued', queueCustody }); + const input = humanInput({ deliveryStatus: 'queued' }); assert.equal(messageStore.markDelivered(input.id, 1).deliveryTransitioned, true); const command = submit(programId, { body: humanChoiceBody(input, thread.id) }); await assert.rejects(() => createCrashingPreparation().submitPreparation(command), /simulated F117 outage/); const committed = (await eventLog.read(programId))[1]; - if (loss === 'delete') messageStore.softDelete(input.id, 'operator'); - else - assert.equal( - messageStore.recallMessageToComposerDraft(input.id, { - ownerUserId: 'operator', - threadId: thread.id, - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 2, - }).kind, - 'recalled', - ); + messageStore.softDelete(input.id, 'operator'); const recovered = await createPreparation().submitPreparation(command); const current = recovered.projection.preparation.sections.object_map.current; diff --git a/packages/api/test/cat-config-loader.test.js b/packages/api/test/cat-config-loader.test.js index 34b9844ca0..4e446a3615 100644 --- a/packages/api/test/cat-config-loader.test.js +++ b/packages/api/test/cat-config-loader.test.js @@ -17,6 +17,7 @@ const { getDefaultCatId, buildCatIdToBreedIndex, getCatEffort, + resolveCatCarrier, getAcpConfig, getCatFamily, bootstrapDefaultCatCatalog, @@ -93,22 +94,65 @@ describe('cat-config-loader', () => { } }); - it('rejects cli.carrier on non-openai variants but keeps it for openai', () => { - const bad = validConfig(); - bad.breeds[0].variants[0].cli = { command: 'claude', outputFormat: 'stream-json', carrier: 'app_server' }; - assert.throws(() => loadCatConfig(writeTempConfig(bad)), /codex-only/i); - - const good = validConfig(); - good.breeds[0].variants[0].clientId = 'openai'; - good.breeds[0].variants[0].defaultModel = 'gpt-5.6-sol'; - good.breeds[0].variants[0].cli = { command: 'codex', outputFormat: 'json', carrier: 'app_server' }; - const loaded = loadCatConfig(writeTempConfig(good)); - assert.equal(loaded.breeds[0].variants[0].cli.carrier, 'app_server'); + it('resolves the canonical member carrier at the config boundary only', () => { + assert.equal(resolveCatCarrier({ carrier: 'sdk', transport: 'acp' }), 'sdk'); + assert.equal(resolveCatCarrier({ transport: 'acp' }), 'acp'); + assert.equal(resolveCatCarrier({ cli: { carrier: 'app_server' } }), 'cli'); + assert.equal(resolveCatCarrier({ acp: { command: 'opencode', startupArgs: ['acp'] } }), 'cli'); + assert.equal(resolveCatCarrier({}), 'cli'); + + const config = validConfig(); + config.breeds[0].variants[0].carrier = 'sdk'; + config.breeds[0].variants[0].transport = 'acp'; + const loaded = loadCatConfig(writeTempConfig(config)); + const resolved = toAllCatConfigs(loaded).opus; + assert.equal(resolved.carrier, 'sdk'); + assert.equal(resolved.cli.carrier, undefined, 'provider CLI config must not retain carrier selection'); + }); + + it('rejects the retired OpenCode server carrier instead of migrating it', () => { + const config = validConfig(); + config.breeds[0].variants[0].clientId = 'opencode'; + config.breeds[0].variants[0].defaultModel = 'anthropic/claude-test'; + config.breeds[0].variants[0].carrier = 'server'; + + assert.throws(() => loadCatConfig(writeTempConfig(config)), /Invalid cat config/); + }); + + it('identifies the invalid carrier path in a version 2 startup config', () => { + const config = validConfig(); + config.version = 2; + config.roster = { + opus: { + family: 'ragdoll', + roles: ['assistant'], + lead: true, + available: true, + evaluation: 'test member', + }, + }; + config.reviewPolicy = { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }; + config.breeds[0].variants[0].clientId = 'opencode'; + config.breeds[0].variants[0].defaultModel = 'anthropic/claude-test'; + config.breeds[0].variants[0].carrier = 'server'; + + assert.throws( + () => loadCatConfig(writeTempConfig(config)), + /breeds\.0\.variants\.0\.carrier: .*received 'server'/, + ); }); - it('loads default project config when no path/env provided', () => { + it('loads resolved project config when no explicit file path is provided', () => { + const projectDir = mkdtempSync(join(tmpdir(), 'cat-default-project-')); + const templatePath = join(projectDir, 'cat-template.json'); + writeFileSync(templatePath, JSON.stringify(validConfig())); const saved = process.env.CAT_TEMPLATE_PATH; - delete process.env.CAT_TEMPLATE_PATH; + process.env.CAT_TEMPLATE_PATH = templatePath; try { const config = loadCatConfig(); // F032: version can be 1 or 2 now @@ -344,12 +388,19 @@ describe('cat-config-loader', () => { assert.throws(() => loadCatConfig(path), /Invalid cat config/); }); - it('rejects wrong version', () => { + it('rejects incomplete version 2 config', () => { const bad = { ...validConfig(), version: 2 }; const path = writeTempConfig(bad); assert.throws(() => loadCatConfig(path), /Invalid cat config/); }); + it('rejects missing and unsupported versions at the discriminator path', () => { + const missing = validConfig(); + delete missing.version; + assert.throws(() => loadCatConfig(writeTempConfig(missing)), /version: Invalid input/); + assert.throws(() => loadCatConfig(writeTempConfig({ ...validConfig(), version: 3 })), /version: Invalid input/); + }); + it('throws clear error when file not found', () => { assert.throws(() => loadCatConfig('/nonexistent/cat-template.json'), /Failed to read cat config/); }); diff --git a/packages/api/test/cats-routes-runtime-catalog.test.js b/packages/api/test/cats-routes-runtime-catalog.test.js index d20f1beef9..7f2ec7b929 100644 --- a/packages/api/test/cats-routes-runtime-catalog.test.js +++ b/packages/api/test/cats-routes-runtime-catalog.test.js @@ -187,6 +187,44 @@ describe('cats routes read runtime catalog', { concurrency: false }, () => { assert.deepEqual(runtimeCat.mentionPatterns, ['@runtime-cat']); }); + it('GET /api/cats advertises guide-reply for queued-internal active-invocation append', async () => { + const projectRoot = createRuntimeCatalogProject(makeCatalog('runtime-cat', '运行时猫')); + process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); + + const Fastify = (await import('fastify')).default; + const { catsRoutes } = await import('../dist/routes/cats.js'); + const app = Fastify(); + await app.register(catsRoutes, { + resolveCarrierCapability: (catId) => + catId === 'runtime-cat' + ? { + provider: 'anthropic', + carrier: 'claude_agent_sdk', + activeInvocationGuidance: 'supported', + deliverySemantics: 'queued_internal_turn', + } + : { + provider: 'anthropic', + carrier: 'claude_print_sdk', + activeInvocationGuidance: 'unsupported', + deliverySemantics: 'unsupported', + }, + }); + + try { + const res = await app.inject({ method: 'GET', url: '/api/cats' }); + assert.equal(res.statusCode, 200, res.body); + const body = JSON.parse(res.body); + const runtimeCat = body.cats.find((cat) => cat.id === 'runtime-cat'); + assert.equal(runtimeCat.isDefaultResponder, true); + assert.deepEqual(runtimeCat.messageDeliveryCapabilities, { guideReply: true }); + const other = body.cats.find((cat) => cat.id !== 'runtime-cat'); + if (other) assert.deepEqual(other.messageDeliveryCapabilities, { guideReply: false }); + } finally { + await app.close(); + } + }); + it('GET /api/cat-templates returns template cats even when runtime catalog has additional members', async () => { const templateConfig = makeVersion2Config('template-cat', '模板猫', { family: 'ragdoll', diff --git a/packages/api/test/cats-routes-runtime-crud.test.js b/packages/api/test/cats-routes-runtime-crud.test.js index 9ae9ea7c99..34af3ffb7b 100644 --- a/packages/api/test/cats-routes-runtime-crud.test.js +++ b/packages/api/test/cats-routes-runtime-crud.test.js @@ -1074,7 +1074,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { } }); - it('POST and PATCH /api/cats handle cli.carrier as an openai-only override', async () => { + it('POST and PATCH /api/cats persist canonical carrier and enforce the client matrix', async () => { const projectRoot = createProjectRoot(); process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); @@ -1108,11 +1108,13 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { ...baseBody, catId: 'runtime-codex-carrier', mentionPatterns: ['@runtime-codex-carrier'], - cli: { command: 'codex', outputFormat: 'json', carrier: 'app_server' }, + carrier: 'app_server', + cli: { command: 'codex', outputFormat: 'json' }, }), }); assert.equal(createRes.statusCode, 201, createRes.body); - assert.equal(JSON.parse(createRes.body).cat.cli?.carrier, 'app_server'); + assert.equal(JSON.parse(createRes.body).cat.carrier, 'app_server'); + assert.equal(JSON.parse(createRes.body).cat.cli?.carrier, undefined); const invalidEnumRes = await app.inject({ method: 'POST', @@ -1122,7 +1124,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { ...baseBody, catId: 'runtime-codex-carrier-invalid', mentionPatterns: ['@runtime-codex-carrier-invalid'], - cli: { command: 'codex', outputFormat: 'json', carrier: 'carrier_pigeon' }, + carrier: 'carrier_pigeon', }), }); assert.equal(invalidEnumRes.statusCode, 400); @@ -1138,36 +1140,54 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { defaultModel: 'kimi-k2.5', catId: 'runtime-kimi-carrier', mentionPatterns: ['@runtime-kimi-carrier'], - cli: { command: 'kimi', outputFormat: 'stream-json', carrier: 'app_server' }, + carrier: 'app_server', + cli: { command: 'kimi', outputFormat: 'stream-json' }, }), }); assert.equal(kimiCreateRes.statusCode, 400); assert.match(JSON.parse(kimiCreateRes.body).error, /carrier/i); + const openCodeServerCreateRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers, + body: JSON.stringify({ + ...baseBody, + clientId: 'opencode', + accountRef: 'opencode', + defaultModel: 'anthropic/claude-test', + catId: 'runtime-opencode-server-carrier', + mentionPatterns: ['@runtime-opencode-server-carrier'], + carrier: 'server', + cli: { command: 'opencode', outputFormat: 'json' }, + }), + }); + assert.equal(openCodeServerCreateRes.statusCode, 400); + const patchRes = await app.inject({ method: 'PATCH', url: '/api/cats/runtime-codex-carrier', headers, - body: JSON.stringify({ cli: { carrier: 'exec_json' } }), + body: JSON.stringify({ carrier: 'cli' }), }); assert.equal(patchRes.statusCode, 200, patchRes.body); - assert.equal(JSON.parse(patchRes.body).cat.cli?.carrier, 'exec_json'); + assert.equal(JSON.parse(patchRes.body).cat.carrier, 'cli'); const clearRes = await app.inject({ method: 'PATCH', url: '/api/cats/runtime-codex-carrier', headers, - body: JSON.stringify({ cli: { carrier: null } }), + body: JSON.stringify({ carrier: 'app_server' }), }); assert.equal(clearRes.statusCode, 200, clearRes.body); - assert.equal(JSON.parse(clearRes.body).cat.cli?.carrier, undefined); + assert.equal(JSON.parse(clearRes.body).cat.carrier, 'app_server'); // Carrier is model-independent: a model switch must preserve the override. const modelSwitchRes = await app.inject({ method: 'PATCH', url: '/api/cats/runtime-codex-carrier', headers, - body: JSON.stringify({ cli: { carrier: 'app_server' } }), + body: JSON.stringify({ carrier: 'app_server' }), }); assert.equal(modelSwitchRes.statusCode, 200, modelSwitchRes.body); const modelChangeRes = await app.inject({ @@ -1177,7 +1197,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { body: JSON.stringify({ defaultModel: 'gpt-5.5' }), }); assert.equal(modelChangeRes.statusCode, 200, modelChangeRes.body); - assert.equal(JSON.parse(modelChangeRes.body).cat.cli?.carrier, 'app_server'); + assert.equal(JSON.parse(modelChangeRes.body).cat.carrier, 'app_server'); } finally { await app.close(); } @@ -1321,7 +1341,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { } }); - it('GET /api/cats exposes effective codexCarrier truth (per-cat > env > default)', async () => { + it('GET /api/cats exposes the canonical member carrier and ignores the retired global selector', async () => { const projectRoot = createProjectRoot(); process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); const savedCarrier = process.env.CAT_CAFE_CODEX_CARRIER; @@ -1357,32 +1377,31 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { }), }); assert.equal(createRes.statusCode, 201, createRes.body); - assert.deepEqual(JSON.parse(createRes.body).cat.codexCarrier, { effective: 'exec_json', source: 'default' }); + assert.equal(JSON.parse(createRes.body).cat.carrier, 'cli'); - // Env applies when no per-cat override exists. + // The retired global selector must not change per-member routing truth. process.env.CAT_CAFE_CODEX_CARRIER = 'app_server'; const envRes = await app.inject({ method: 'GET', url: '/api/cats', headers }); assert.equal(envRes.statusCode, 200, envRes.body); const envCat = JSON.parse(envRes.body).cats.find((cat) => cat.id === 'runtime-carrier-truth'); - assert.deepEqual(envCat.codexCarrier, { effective: 'app_server', source: 'env' }); + assert.equal(envCat.carrier, 'cli'); - // Per-cat override beats env — including an explicit exec_json override. + // The canonical top-level carrier is the only writable selector. const patchRes = await app.inject({ method: 'PATCH', url: '/api/cats/runtime-carrier-truth', headers, - body: JSON.stringify({ cli: { carrier: 'exec_json' } }), + body: JSON.stringify({ carrier: 'app_server' }), }); assert.equal(patchRes.statusCode, 200, patchRes.body); - assert.deepEqual(JSON.parse(patchRes.body).cat.codexCarrier, { effective: 'exec_json', source: 'per-cat' }); + assert.equal(JSON.parse(patchRes.body).cat.carrier, 'app_server'); - // Non-openai cats never carry the field (opus is the anthropic template cat). + // Every member carries one explicit access mode, regardless of provider. const claudeCat = JSON.parse(envRes.body).cats.find((cat) => cat.id === 'opus'); assert.equal(claudeCat.clientId, 'anthropic'); - assert.equal(claudeCat.codexCarrier, undefined); + assert.equal(claudeCat.carrier, 'cli'); - // openai + generic ACP (env=app_server): the assembly checks getAcpConfig - // first, so AcpAgentService runs and the Codex carrier never applies. + // ACP is its own explicit client/carrier; OpenAI does not silently become ACP. const acpRes = await app.inject({ method: 'POST', url: '/api/cats', @@ -1395,18 +1414,15 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#16a34a', secondary: '#bbf7d0' }, mentionPatterns: ['@runtime-carrier-acp'], roleDescription: '审查', - clientId: 'openai', + clientId: 'acp', accountRef: 'codex', defaultModel: 'gpt-5.4', - cli: { effort: 'xhigh', carrier: 'exec_json' }, - cliConfigArgs: ['--config stale=true'], acp: { command: 'codex', startupArgs: ['acp'] }, }), }); assert.equal(acpRes.statusCode, 201, acpRes.body); const acpCat = JSON.parse(acpRes.body).cat; - assert.equal(acpCat.adapterMode, 'acp'); - assert.equal(acpCat.codexCarrier, undefined, 'ACP cats bypass the Codex carrier entirely'); + assert.equal(acpCat.carrier, 'acp'); assert.equal(acpCat.cli?.effort, undefined, 'ACP create must not persist CLI effort'); assert.equal(acpCat.cli?.carrier, undefined, 'ACP create must not persist a CLI carrier override'); assert.deepEqual(acpCat.cliConfigArgs ?? [], [], 'ACP create must not persist extra CLI arguments'); @@ -1416,18 +1432,15 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { url: '/api/cats/runtime-carrier-truth', headers, body: JSON.stringify({ - acp: { command: 'codex', startupArgs: ['acp'] }, - cli: { effort: 'xhigh', carrier: 'app_server' }, - cliConfigArgs: ['--config stale=true'], + carrier: 'cli', }), }); assert.equal(switchToAcpRes.statusCode, 200, switchToAcpRes.body); const switchedToAcp = JSON.parse(switchToAcpRes.body).cat; - assert.equal(switchedToAcp.cli?.effort, undefined, 'ACP patch must clear CLI effort'); - assert.equal(switchedToAcp.cli?.carrier, undefined, 'ACP patch must clear CLI carrier override'); - assert.deepEqual(switchedToAcp.cliConfigArgs ?? [], [], 'ACP patch must clear extra CLI arguments'); + assert.equal(switchedToAcp.carrier, 'cli'); - // Cloud-only (F247 KD-17): cli removed → no local dispatch → no carrier truth. + // Cloud-only members still expose their configured access mode even though + // the cloud provider bypasses local dispatch. const cloudRes = await app.inject({ method: 'POST', url: '/api/cats', @@ -1450,7 +1463,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(cloudRes.statusCode, 201, cloudRes.body); const cloudCat = JSON.parse(cloudRes.body).cat; assert.equal(cloudCat.cli, undefined); - assert.equal(cloudCat.codexCarrier, undefined, 'cloud-only cats never reach the Codex carrier'); + assert.equal(cloudCat.carrier, 'cli'); } finally { if (savedCarrier === undefined) { delete process.env.CAT_CAFE_CODEX_CARRIER; @@ -3372,7 +3385,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(createRes.statusCode, 201, `Expected 201 but got ${createRes.statusCode}: ${createRes.body}`); const body = JSON.parse(createRes.body); - assert.equal(body.cat.adapterMode, 'acp', 'generic ACP member should have adapterMode=acp'); + assert.equal(body.cat.carrier, 'acp', 'generic ACP member should have carrier=acp'); assert.equal( body.cat.mcpSupport, true, @@ -3622,7 +3635,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { }); assert.equal(switchRes.statusCode, 200, `client switch failed: ${switchRes.body}`); const switchBody = JSON.parse(switchRes.body); - assert.equal(switchBody.cat.adapterMode, 'cli', 'after switch should be cli mode'); + assert.equal(switchBody.cat.carrier, 'cli', 'after switch should be cli mode'); }); it('PATCH /api/cats/:id gates httpstream ACP transport behind explicit experimental opt-in', async () => { @@ -3736,7 +3749,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(switchRes.statusCode, 200, `client switch failed: ${switchRes.body}`); const switchBody = JSON.parse(switchRes.body); assert.equal(switchBody.cat.clientId, 'openai'); - assert.equal(switchBody.cat.adapterMode, 'cli', 'after switch should be cli mode'); + assert.equal(switchBody.cat.carrier, 'cli', 'after switch should be cli mode'); assert.equal(switchBody.cat.acp, undefined, 'response must not expose stale ACP config'); const listRes = await app.inject({ method: 'GET', url: '/api/cats' }); @@ -3744,7 +3757,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { const listBody = JSON.parse(listRes.body); const listed = listBody.cats.find((cat) => cat.id === 'acp-switch'); assert.equal(listed.clientId, 'openai'); - assert.equal(listed.adapterMode, 'cli', 'listed cat should be cli mode after switch'); + assert.equal(listed.carrier, 'cli', 'listed cat should be cli mode after switch'); assert.equal(listed.acp, undefined, 'catalog must not retain stale ACP config'); }); diff --git a/packages/api/test/cicd-router.test.js b/packages/api/test/cicd-router.test.js index 4cbca52cfa..6487e3297f 100644 --- a/packages/api/test/cicd-router.test.js +++ b/packages/api/test/cicd-router.test.js @@ -161,13 +161,13 @@ describe('CiCdRouter F280 typed waits', () => { assert.equal(first.kind, 'notified'); assert.notEqual(replay.kind, 'notified'); assert.match(first.content, /CI pending → pass/); - assert.equal(messageStore.getByThread('thread_1').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 1); }); test('CI pass does not wake a reviewer waiting only for a new HEAD', async () => { const { router, messageStore, taskStore, task } = await setup([{ kind: 'pr_head_changed' }]); assert.equal((await router.route(poll())).kind, 'skipped'); - assert.equal(messageStore.getByThread('thread_1').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 0); assert.equal((await taskStore.get(task.id)).automationState.ci.lastBucket, 'pass'); }); @@ -219,7 +219,11 @@ describe('CiCdRouter F280 typed waits', () => { assert.equal(projected.length, 1); assert.equal(projected[0].headSha, headSha); assert.equal((await taskStore.get(task.id)).automationState.ci.headSha, headSha); - assert.equal(messageStore.getByThread('thread_1').length, 1, 'only the original HEAD wake is delivered'); + assert.equal( + messageStore.getByThreadIncludingQueued('thread_1').length, + 1, + 'only the original HEAD wake is delivered', + ); }); test('merged PR consumes the wait, marks done, and emits world truth once', async () => { diff --git a/packages/api/test/claude-bg-carrier.test.js b/packages/api/test/claude-bg-carrier.test.js index 245aed9af7..c7bfe5bbc3 100644 --- a/packages/api/test/claude-bg-carrier.test.js +++ b/packages/api/test/claude-bg-carrier.test.js @@ -89,6 +89,27 @@ test('throws CarrierError when claude --bg exits non-zero', async () => { ); }); +test('sanitizes claude --bg stderr before taking its head excerpt', async () => { + const token = `sk-${'X'.repeat(40)}`; + const fakeSpawn = buildFakeSpawn({ + exitCode: 1, + stderr: `${'A'.repeat(290)}${token}${'B'.repeat(500)}`, + }); + const service = new ClaudeBgCarrierService({ + l0CompilerFn: fakeL0Compiler, + spawnFn: fakeSpawn, + model: 'claude-test-model', + }); + await assert.rejects( + () => service.startJob('hi'), + (error) => { + assert.doesNotMatch(error.message, /sk-X+/); + assert.match(error.message, /\[TOKEN_RE/); + return true; + }, + ); +}); + test('throws CarrierError when short id cannot be parsed', async () => { const fakeSpawn = buildFakeSpawn({ stdout: 'Starting background service…\nrandom output line\nno match here\n', diff --git a/packages/api/test/cloud-delivery-retry.test.js b/packages/api/test/cloud-delivery-retry.test.js new file mode 100644 index 0000000000..15f6ecb8ce --- /dev/null +++ b/packages/api/test/cloud-delivery-retry.test.js @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import Fastify from 'fastify'; + +describe('cloud delivery retry', () => { + let app; + let messageStore; + let invocationQueue; + let drainRequests; + + beforeEach(async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { InvocationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const { InMemoryQueueLedgerStore } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js' + ); + const { messagesRoutes } = await import('../dist/routes/messages.js'); + + messageStore = new MessageStore(); + invocationQueue = new InvocationQueue(new InMemoryQueueLedgerStore()); + drainRequests = []; + app = Fastify(); + await app.register(messagesRoutes, { + registry: new InvocationRegistry(), + messageStore, + invocationQueue, + queueProcessor: { + requestDrain(threadId) { + drainRequests.push(threadId); + }, + }, + socketManager: { emitToUser() {} }, + router: { + async resolveExplicitTargets(targets) { + return targets.includes('gpt-pro') ? [...targets] : []; + }, + }, + }); + await app.ready(); + }); + + afterEach(async () => { + await app?.close(); + }); + + function seedRecoverableSource() { + const source = messageStore.append({ + from: { kind: 'user', userId: 'default-user' }, + userId: 'default-user', + threadId: 'thread-cloud-retry', + content: '@gpt-pro please retry this exact body', + contentBlocks: [{ type: 'text', text: '@gpt-pro please retry this exact body' }], + mentions: ['gpt-pro'], + timestamp: 1_000, + }); + messageStore.append({ + from: { kind: 'system', service: 'system-info-warning' }, + userId: 'system', + threadId: source.threadId, + content: '这条消息还没有发送', + mentions: [], + timestamp: 1_100, + replyTo: source.id, + source: { + connector: 'cloud-bridge-status', + label: '云端猫投递', + icon: '⚠️', + meta: { + cloudBridgeRecovery: { + v: 1, + kind: 'needs_binding', + sourceMessageId: source.id, + targetCatId: 'gpt-pro', + dispatchInvocationId: 'dispatch-failed-1', + }, + }, + }, + }); + return source; + } + + it('creates one new hidden source and pending Queue attempt without mutating the failed source', async () => { + const source = seedRecoverableSource(); + const response = await app.inject({ + method: 'POST', + url: `/api/messages/${source.id}/delivery-targets/gpt-pro/retry`, + payload: { attemptId: 'dispatch-failed-1' }, + }); + + assert.equal(response.statusCode, 202, response.body); + const body = response.json(); + assert.notEqual(body.retryMessageId, source.id); + assert.equal((await messageStore.getById(source.id)).content, source.content); + const retry = await messageStore.getById(body.retryMessageId); + assert.equal(retry.deliveryStatus, 'queued'); + assert.equal(retry.content, source.content); + assert.deepEqual(retry.contentBlocks, source.contentBlocks); + assert.deepEqual(retry.mentions, ['gpt-pro']); + const [entry] = invocationQueue.list(source.threadId, 'default-user'); + assert.equal(entry.payload.sourceRecordId, retry.id); + assert.deepEqual(entry.targets, ['gpt-pro']); + assert.deepEqual(drainRequests, [source.threadId]); + }); + + it('rejects a forged attempt and does not create Queue work', async () => { + const source = seedRecoverableSource(); + const response = await app.inject({ + method: 'POST', + url: `/api/messages/${source.id}/delivery-targets/gpt-pro/retry`, + payload: { attemptId: 'different-attempt' }, + }); + + assert.equal(response.statusCode, 409); + assert.equal(response.json().code, 'DELIVERY_RETRY_AUTHORITY_STALE'); + assert.deepEqual(invocationQueue.list(source.threadId, 'default-user'), []); + }); + + it('allows one retry per failed delivery attempt', async () => { + const source = seedRecoverableSource(); + const request = { + method: 'POST', + url: `/api/messages/${source.id}/delivery-targets/gpt-pro/retry`, + payload: { attemptId: 'dispatch-failed-1' }, + }; + assert.equal((await app.inject(request)).statusCode, 202); + const replay = await app.inject(request); + assert.equal(replay.statusCode, 409); + assert.equal(replay.json().code, 'DELIVERY_RETRY_AUTHORITY_STALE'); + assert.equal(invocationQueue.list(source.threadId, 'default-user').length, 1); + }); +}); diff --git a/packages/api/test/codex-agent-service.test.js b/packages/api/test/codex-agent-service.test.js index bda8a0f8b5..f9a9d8a5ac 100644 --- a/packages/api/test/codex-agent-service.test.js +++ b/packages/api/test/codex-agent-service.test.js @@ -6,7 +6,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, mock, test } from 'node:test'; import { catRegistry } from '@cat-cafe/shared'; @@ -16,6 +16,7 @@ const { CodexAgentService: ProductionCodexAgentService, buildCodexReasoningArgs, isGitRepositoryPath, + resolveCodexApiKeyIsolationRoot, } = await import('../dist/domains/cats/services/agents/providers/CodexAgentService.js'); const { _resetCachedConfig } = await import('../dist/config/cat-config-loader.js'); @@ -2583,6 +2584,23 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { assert.ok(args.includes('model="qwen-plus"'), 'model must be passed as-is via --config'); assert.ok(args.includes('model_provider="custom"'), 'must set model_provider=custom'); assert.ok(!args.includes('model_provider="openai_https"'), 'custom provider must not force OpenAI OAuth provider'); + const spawnOptions = spawnFn.mock.calls[0].arguments[2]; + assert.equal( + dirname(spawnOptions.env.HOME), + resolveCodexApiKeyIsolationRoot(), + 'API-key invocation must launch from an owner cache home that Codex accepts', + ); + }); + + test('API-key isolation lives under user cache rather than the OS temporary directory', () => { + assert.equal( + resolveCodexApiKeyIsolationRoot({ HOME: '/Users/example', XDG_CACHE_HOME: '/Users/example/.cache' }, 'darwin'), + '/Users/example/Library/Caches/clowder-ai/codex-api-key-homes', + ); + assert.equal( + resolveCodexApiKeyIsolationRoot({ HOME: '/home/example', XDG_CACHE_HOME: '/var/cache/example' }, 'linux'), + '/var/cache/example/clowder-ai/codex-api-key-homes', + ); }); test('custom provider: multi-segment model slug preserved as-is', async () => { @@ -2751,6 +2769,56 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { ); }); + test('#1398: service treats the configured display name as a canonical signature alias', async () => { + const savedConfigs = catRegistry.getAllConfigs(); + const catId = 'cat-signature-alias-test'; + catRegistry.reset(); + catRegistry.register(catId, { + id: catId, + name: 'dragon-li', + displayName: '狸花猫', + nickname: '小狸花', + avatar: '', + color: 'brown', + mentionPatterns: ['@小狸花'], + clientId: 'openai', + defaultModel: 'deepseek-chat', + mcpSupport: true, + roleDescription: 'test', + personality: 'test', + }); + + try { + const proc = createMockProcess(); + const service = new CodexAgentService({ + catId, + l0CompilerFn: fakeL0Compiler, + spawnFn: createMockSpawnFn(proc), + model: 'deepseek-chat', + carrierMode: 'exec_json', + }); + const promise = collect(service.invoke('Hello')); + emitCodexEvents(proc, [ + { type: 'thread.started', thread_id: 'thread-signature-alias' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'Hello~ 狸花猫在 🐾\n\n[狸花猫/deepseek-chat🐾]' }, + }, + { type: 'turn.completed', usage: { input_tokens: 10, output_tokens: 20 } }, + ]); + + const messages = await promise; + const text = messages + .filter((message) => message.type === 'text') + .map((message) => message.content) + .join(''); + assert.equal(text, 'Hello~ 狸花猫在 🐾\n\n[小狸花/deepseek-chat🐾]'); + } finally { + catRegistry.reset(); + for (const [id, config] of Object.entries(savedConfigs)) catRegistry.register(id, config); + } + }); + test('#1272: multiple completed turns keep the canonical signature at the real stream end', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); diff --git a/packages/api/test/codex-app-server-interaction.test.js b/packages/api/test/codex-app-server-interaction.test.js index 9629b15d49..e764fd27a7 100644 --- a/packages/api/test/codex-app-server-interaction.test.js +++ b/packages/api/test/codex-app-server-interaction.test.js @@ -1215,6 +1215,41 @@ test('F306 omits a collaboration override for a fresh execute provider thread', await run; }); +test('custom app-server provider inherits its configured model without a duplicate thread model lookup', async () => { + const wire = new InteractionWire(); + let sessionOptions; + const service = new CodexAgentService({ + carrierMode: 'app_server', + cliCommand: process.execPath, + l0CompilerFn: fakeL0Compiler, + model: 'deepseek-chat', + }); + const run = collect( + service.invoke('use the custom provider model', { + callbackEnv: { + OPENAI_BASE_URL: 'https://api.deepseek.com', + OPENAI_API_KEY: 'test-key', + CODEX_AUTH_MODE: 'api_key', + }, + agentCarrierSessionFactory: async (options) => { + sessionOptions = options; + return wire; + }, + }), + ); + + await waitFor(() => wire.writes.some((message) => message.method === 'turn/start')); + assert.ok(sessionOptions.args.includes('model="deepseek-chat"')); + const threadStart = wire.writes.find((message) => message.method === 'thread/start'); + assert.equal(Object.hasOwn(threadStart.params, 'model'), false); + + wire.inbox.push({ + method: 'turn/completed', + params: { threadId: 'provider-thread', turn: { id: 'provider-turn', status: 'completed' } }, + }); + await run; +}); + test('F306 explicitly restores Default when execute resumes a provider thread after ideate', async () => { const wire = new InteractionWire(); const service = new CodexAgentService({ diff --git a/packages/api/test/codex-app-server-pooling.test.js b/packages/api/test/codex-app-server-pooling.test.js index f0136179c5..56b5431c9f 100644 --- a/packages/api/test/codex-app-server-pooling.test.js +++ b/packages/api/test/codex-app-server-pooling.test.js @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, test } from 'node:test'; import { + buildCodexActiveWriterWaitSignal, CodexAgentService, codexConfigObjectFromArgs, } from '../dist/domains/cats/services/agents/providers/CodexAgentService.js'; @@ -91,8 +92,8 @@ class PoolWire { } class ActiveWriterWire extends PoolWire { - constructor(threadId) { - super(threadId); + constructor(threadId, reusedSessionHost = false) { + super(threadId, reusedSessionHost); this.previousThreadId = threadId; } @@ -231,6 +232,39 @@ function callbackEnv(invocationId, callbackToken) { }; } +test('active-writer ownership wait becomes a transient non-History status only after 10 seconds', () => { + const event = { + type: 'app_server.recovery', + reason: 'active_writer_retry', + attempt: 7, + retryBudget: null, + elapsedMs: 10_000, + threadId: 'native-old', + }; + assert.equal( + buildCodexActiveWriterWaitSignal({ + catId: 'codex', + metadata: { provider: 'openai', model: 'gpt-5.6-sol' }, + event: { ...event, elapsedMs: 9_999 }, + }), + null, + ); + const signal = buildCodexActiveWriterWaitSignal({ + catId: 'codex', + metadata: { provider: 'openai', model: 'gpt-5.6-sol' }, + event, + timestamp: 123, + }); + assert.equal(signal.type, 'provider_signal'); + assert.equal(signal.timestamp, 123); + assert.deepEqual(JSON.parse(signal.content), { + type: 'warning', + presentation: 'transient_status', + message: '正在等待该成员的原生会话释放,可点 Stop 取消', + }); + assert.equal(signal.metadata.diagnostics.appServerRecovery.elapsedMs, 10_000); +}); + async function drain(iterable) { const values = []; for await (const value of iterable) values.push(value); @@ -344,10 +378,11 @@ test('CodexAgentService hides a recovered model-capacity failure from the Clowde assert.equal(retryTurn.params.additionalContext?.['cat-cafe.capacity-recovery']?.kind, 'application'); }); -test('CodexAgentService exposes active-writer refusal without minting a replacement session', async () => { - const first = new ActiveWriterWire('native-old'); +test('CodexAgentService waits out active-writer ownership without a public failure or replacement session', async () => { + const first = new ActiveWriterWire('native-old', true); const second = new ActiveWriterWire('native-old'); - const wires = [first, second]; + const third = new PoolWire('native-old'); + const wires = [first, second, third]; let factoryCalls = 0; const service = new CodexAgentService({ carrierMode: 'app_server', @@ -364,40 +399,27 @@ test('CodexAgentService exposes active-writer refusal without minting a replacem }), ); - assert.equal(factoryCalls, 2, 'one failed resume may receive only one bounded same-session retry'); - assert.equal( - output.some((message) => message.type === 'session_init'), - false, - ); + assert.equal(factoryCalls, 3, 'the same native session remains pending until its writer releases'); assert.equal( output.filter((message) => message.type === 'session_init' && message.sessionReplacement).length, 0, 'active-writer refusal must not mint replacement provenance or a new native session', ); assert.equal( - output.some( - (message) => - message.type === 'error' && - message.error.includes('拒绝自动替换或封存当前会话') && - message.error.includes('请稍后重试'), - ), - true, + output.some((message) => message.type === 'error'), + false, + 'writer ownership is transient scheduling state, not a public task failure', ); assert.equal( output.filter((message) => message.metadata?.diagnostics?.appServerRecovery?.reason === 'active_writer_retry') .length, - 1, - 'the bounded retry must preserve diagnostics without claiming a replacement', + 2, + 'each internal wait attempt remains observable without claiming a replacement', ); - const terminalError = output.find((message) => message.type === 'error'); - assert.equal(terminalError.metadata?.cliDiagnostics?.reasonCode, 'active_writer_recovery'); - assert.equal(terminalError.metadata?.cliDiagnostics?.activeWriterRecovery?.state, 'owner_busy'); - assert.equal(terminalError.metadata?.cliDiagnostics?.publicSummary, '原生会话仍绑定原 writer host'); - assert.match(terminalError.metadata?.cliDiagnostics?.publicHint, /原 Session 已保留/); - assert.doesNotMatch( - JSON.stringify(terminalError.metadata?.cliDiagnostics), - /thread native-old already has an active writer/, - 'typed diagnostics must not expose raw upstream active-writer text', + assert.equal( + output.some((message) => message.metadata?.diagnostics?.appServerLifecycle?.failureReason), + false, + 'discarded ownership probes must not project a failed lifecycle into the active invocation', ); }); diff --git a/packages/api/test/codex-app-server-transport.test.js b/packages/api/test/codex-app-server-transport.test.js index 49cf4d6e4a..2821229e46 100644 --- a/packages/api/test/codex-app-server-transport.test.js +++ b/packages/api/test/codex-app-server-transport.test.js @@ -3,7 +3,6 @@ import { test } from 'node:test'; import { setTimeout as delay } from 'node:timers/promises'; import { CodexAppServerClient } from '../dist/domains/cats/services/agents/providers/CodexAppServerClient.js'; import { runCodexAppServerWithRecovery } from '../dist/domains/cats/services/agents/providers/CodexAppServerRunner.js'; -import { buildCodexAppServerThreadParams } from '../dist/domains/cats/services/agents/providers/codex-app-server-client-helpers.js'; import { CodexAppServerRpcError } from '../dist/domains/cats/services/agents/providers/codex-app-server-rpc-error.js'; import { createDirectAgentCarrierSession } from '../dist/domains/cats/services/agents/providers/DirectAgentCarrierSession.js'; import { captureCodexActiveWriterDetection } from '../dist/domains/cats/services/runtime-session/CodexSessionReplacementProvenance.js'; @@ -36,6 +35,28 @@ async function waitFor(predicate, timeoutMs = 1_000) { } } +function createActiveWriterWire(threadId) { + const wire = new ProtocolWire(); + const originalWrite = wire.write.bind(wire); + wire.write = async (message) => { + if (message.method === 'thread/read') { + wire.writes.push(message); + wire.inbox.push({ + id: message.id, + result: { thread: { id: threadId, status: { type: 'idle' }, turns: [] } }, + }); + return; + } + if (message.method !== 'thread/resume') return originalWrite(message); + wire.writes.push(message); + wire.inbox.push({ + id: message.id, + error: { code: -32600, message: `thread ${threadId} already has an active writer` }, + }); + }; + return wire; +} + class AsyncInbox { #values = []; #waiters = []; @@ -138,6 +159,9 @@ class ProtocolWire { case 'turn/interrupt': this.inbox.push({ id: message.id, result: {} }); break; + case 'turn/steer': + this.inbox.push({ id: message.id, result: { turnId: message.params.expectedTurnId } }); + break; } } @@ -152,10 +176,77 @@ class ProtocolWire { } } -test('F306 preserves every native approvalsReviewer route literal without a Clowder AI reviewer taxonomy', () => { - for (const approvalsReviewer of ['user', 'auto_review', 'guardian_subagent']) { - assert.equal(buildCodexAppServerThreadParams({ approvalsReviewer }).approvalsReviewer, approvalsReviewer); - } +test('app-server registers one exact active-run dispatcher and fences it at turn terminal', async () => { + const wire = new ProtocolWire(); + let dispatcher; + let releaseCount = 0; + const client = new CodexAppServerClient({ wire }); + const outputPromise = collect( + client.run({ + prompt: frozenPrompt('initial work'), + thread: { kind: 'start' }, + activeRunDispatch: { + invocationId: 'turn-invocation-1', + register: (candidate) => { + dispatcher = candidate; + return () => { + releaseCount++; + }; + }, + }, + }), + ); + + await waitFor(() => dispatcher !== undefined); + assert.deepEqual(dispatcher.capabilities, { append: true, steer: true }); + assert.deepEqual(dispatcher.handle, { + provider: 'openai_codex', + carrier: 'codex_app_server', + threadId: 'thread-1', + turnId: 'turn-1', + }); + + const accepted = await dispatcher.dispatch( + { + text: 'follow-up while still working', + imagePaths: ['/tmp/follow-up.png'], + messageIds: ['message-2'], + }, + { force: false, expectedInvocationId: 'turn-invocation-1' }, + ); + assert.deepEqual(accepted, { + accepted: true, + handle: dispatcher.handle, + }); + const steer = wire.writes.find( + (message) => message.method === 'turn/steer' && message.params.input?.[0]?.text === 'follow-up while still working', + ); + assert.deepEqual(steer.params, { + threadId: 'thread-1', + expectedTurnId: 'turn-1', + input: [ + { type: 'text', text: 'follow-up while still working' }, + { type: 'localImage', path: '/tmp/follow-up.png' }, + ], + }); + + const mismatched = await dispatcher.dispatch( + { text: 'stale', messageIds: ['message-3'] }, + { force: false, expectedInvocationId: 'turn-invocation-stale' }, + ); + assert.deepEqual(mismatched, { accepted: false, reason: 'active_run_mismatch' }); + + wire.inbox.push({ + method: 'turn/completed', + params: { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed' } }, + }); + await outputPromise; + assert.equal(releaseCount, 1); + const terminal = await dispatcher.dispatch( + { text: 'too late', messageIds: ['message-4'] }, + { force: false, expectedInvocationId: 'turn-invocation-1' }, + ); + assert.deepEqual(terminal, { accepted: false, reason: 'active_run_closed' }); }); test('F306 keeps sticky controls single-writer while mapping approved native parameters', async () => { @@ -292,7 +383,6 @@ test('F306 preserves typed upstream rejection for outputSchema at turn/start', a assert.equal(outcome.error?.code, -32602); assert.equal(outcome.error?.message, 'outputSchema is not supported by the selected model'); }); - test('F299 app-server awaits durable request evidence before turn/start', async () => { const wire = new ProtocolWire(); let recorded; @@ -361,6 +451,29 @@ test('direct app-server carrier frames JSONL on LF only', async () => { } }); +test('direct app-server carrier sanitizes stderr before taking its tail excerpt', async () => { + const token = `sk-${'X'.repeat(40)}`; + const childScript = `process.stderr.write(${JSON.stringify(`${'A'.repeat(1_100)}${token}${'B'.repeat(980)}`)}); process.exit(1);`; + const session = await createDirectAgentCarrierSession({ + command: process.execPath, + args: ['--input-type=module', '-e', childScript], + invocationId: 'stderr-sanitize-before-tail', + }); + + try { + await assert.rejects( + () => collect(session.read()), + (error) => { + assert.doesNotMatch(error.message, /X{8,}/); + assert.match(error.message, /\[TOKEN_REDACTED\]/); + return true; + }, + ); + } finally { + await session.close(); + } +}); + test('app-server pump failure rejects only the invocation without an unhandled rejection', async () => { const transportError = new Error('transport exploded'); const wire = { @@ -723,8 +836,9 @@ test('pre-turn transport recovery preserves thread identity and typed capability assert.equal(output.filter((event) => event.type === 'app_server.recovery').length, 1); }); -test('exact active-writer resume failure backs off once and preserves the native session identity', async () => { +test('exact active-writer resume failure retires the conflicting host and preserves native session identity', async () => { const first = new ProtocolWire(); + first.reusedSessionHost = true; const firstWrite = first.write.bind(first); first.write = async (message) => { if (message.method === 'thread/resume') { @@ -784,6 +898,8 @@ test('exact active-writer resume failure backs off once and preserves the native const output = await run; assert.equal(factoryCalls, 2); + assert.equal(first.terminateCalls, 1, 'the conflicting warm host must be retired before same-session retry'); + assert.equal(first.closeCalls, 0, 'an active-writer host must never return to the warm pool'); assert.equal(first.writes.filter((message) => message.method === 'thread/resume').length, 1); assert.equal(second.writes.filter((message) => message.method === 'thread/resume').length, 1); assert.equal(second.writes.filter((message) => message.method === 'thread/start').length, 0); @@ -815,7 +931,7 @@ test('exact active-writer resume failure backs off once and preserves the native assert.equal(first.writes.filter((message) => message.method === 'thread/read').length, 1); }); -test('active-writer diagnostics classify a reused healthy affinity host as a local live lease', async () => { +test('active-writer diagnostics do not mistake a reused warm affinity host for a local live lease', async () => { const first = new ProtocolWire(); first.reusedSessionHost = true; const firstWrite = first.write.bind(first); @@ -864,9 +980,9 @@ test('active-writer diagnostics classify a reused healthy affinity host as a loc }); const output = await run; const diagnostics = output.find((event) => event.type === 'app_server.recovery').activeWriter.diagnostics; - assert.equal(diagnostics.classification, 'local_live_lease'); - assert.equal(diagnostics.confidence, 'high'); - assert.deepEqual(diagnostics.localHostLease, { state: 'live', source: 'carrier_affinity' }); + assert.equal(diagnostics.classification, 'native_active_turn_without_local_lease'); + assert.equal(diagnostics.confidence, 'medium'); + assert.deepEqual(diagnostics.localHostLease, { state: 'not_observed', source: 'carrier_affinity' }); }); test('active-writer diagnostics remain external-or-unknown when thread/read has no active-turn evidence', async () => { @@ -987,51 +1103,99 @@ test('active-writer wording after provider output fails closed without replay', assert.equal(second.writes.length, 0); }); -test('a repeated active-writer refusal fails closed without falling through or starting a fresh thread', async () => { - const activeWriterWire = (threadId) => { - const wire = new ProtocolWire(); - const originalWrite = wire.write.bind(wire); - wire.write = async (message) => { - if (message.method === 'thread/read') { - wire.writes.push(message); - wire.inbox.push({ - id: message.id, - result: { thread: { id: threadId, status: { type: 'idle' }, turns: [] } }, - }); - return; - } - if (message.method !== 'thread/resume') return originalWrite(message); - wire.writes.push(message); - wire.inbox.push({ - id: message.id, - error: { code: -32600, message: `thread ${threadId} already has an active writer` }, - }); - }; - return wire; - }; - const first = activeWriterWire('019f-old'); - const second = activeWriterWire('019f-old'); - const wires = [first, second]; +test('repeated active-writer refusal waits for the same native thread instead of failing publicly', async () => { + const first = createActiveWriterWire('019f-old'); + first.reusedSessionHost = true; + const second = createActiveWriterWire('019f-old'); + const third = new ProtocolWire({ turnId: 'turn-recovered' }); + const wires = [first, second, third]; + const projectedLifecycle = []; let factoryCalls = 0; - const run = collectFailure( + const run = collect( runCodexAppServerWithRecovery({ sessionFactory: async () => { const wire = wires[factoryCalls++]; - if (!wire) throw new Error('unexpected third app-server transport'); + if (!wire) throw new Error('unexpected fourth app-server transport'); return wire; }, - sessionOptions: { command: 'codex', args: ['app-server', '--stdio'], invocationId: 'inv-active-bounded' }, + sessionOptions: { command: 'codex', args: ['app-server', '--stdio'], invocationId: 'inv-active-wait' }, runInput: { prompt: frozenPrompt('continue safely'), thread: { kind: 'resume', threadId: '019f-old' } }, - retryBudget: 2, + retryBudget: 0, activeWriterRetryDelayMs: 1, + clientDeps: { + onLifecycle: (snapshot) => projectedLifecycle.push(snapshot), + }, }), ); - const { error } = await run; - assert.match(error.message, /thread 019f-old already has an active writer/); - assert.equal(factoryCalls, 2, 'exact active-writer must receive only one bounded same-session retry'); + await waitFor(() => third.writes.some((message) => message.method === 'turn/start')); + third.inbox.push({ + method: 'turn/completed', + params: { threadId: '019f-old', turn: { id: 'turn-recovered', status: 'completed' } }, + }); + const output = await run; + + assert.equal(factoryCalls, 3, 'the invocation must keep waiting until the existing writer releases'); + assert.equal(first.terminateCalls, 1, 'the first potentially stale host is retired exactly once'); + assert.equal(second.terminateCalls, 0, 'an external-writer observer must not be repeatedly killed'); + assert.equal(second.closeCalls, 1, 'the observer connection returns normally before the next wait attempt'); assert.equal(first.writes.filter((message) => message.method === 'thread/start').length, 0); assert.equal(second.writes.filter((message) => message.method === 'thread/start').length, 0); + assert.equal(third.writes.filter((message) => message.method === 'thread/start').length, 0); + assert.equal( + output.some((event) => event.type === 'app_server.recovery'), + true, + ); + assert.equal( + output.find((event) => event.type === 'app_server.recovery')?.retryBudget, + null, + 'writer ownership waits are independent of the bounded transport retry budget', + ); + const ownershipWaits = output.filter( + (event) => event.type === 'app_server.recovery' && event.reason === 'active_writer_retry', + ); + assert.equal(ownershipWaits.length, 2); + assert.equal(ownershipWaits[0].elapsedMs, 0); + assert.equal(ownershipWaits[1].elapsedMs >= ownershipWaits[0].elapsedMs, true); + assert.equal( + projectedLifecycle.some( + (snapshot) => + snapshot.turnAccepted === false && + (snapshot.stage === 'failed' || snapshot.stage === 'closing' || snapshot.stage === 'closed'), + ), + false, + 'discarded ownership probes must not publish terminal lifecycle through the direct callback', + ); +}); + +test('explicit cancellation stops an active-writer ownership wait before another carrier is acquired', async () => { + const controller = new AbortController(); + let factoryCalls = 0; + const run = runCodexAppServerWithRecovery({ + sessionFactory: async () => { + factoryCalls++; + return createActiveWriterWire('019f-cancel'); + }, + sessionOptions: { command: 'codex', args: ['app-server', '--stdio'], invocationId: 'inv-active-cancel' }, + runInput: { + prompt: frozenPrompt('continue safely'), + thread: { kind: 'resume', threadId: '019f-cancel' }, + signal: controller.signal, + }, + activeWriterRetryDelayMs: 5_000, + }); + + let error = null; + try { + for await (const event of run) { + if (event.type === 'app_server.recovery') controller.abort(new Error('owner stopped')); + } + } catch (caught) { + error = caught; + } + + assert.match(error?.message ?? '', /owner stopped/); + assert.equal(factoryCalls, 1, 'cancellation must stop before the next ownership probe'); }); test('model-capacity failure retries the accepted turn on the same thread without leaking the failed attempt', async () => { diff --git a/packages/api/test/codex-carrier-truth.test.js b/packages/api/test/codex-carrier-truth.test.js index 83df81127f..8c99718f69 100644 --- a/packages/api/test/codex-carrier-truth.test.js +++ b/packages/api/test/codex-carrier-truth.test.js @@ -1,49 +1,28 @@ -import './helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { describe, it } from 'node:test'; -const { resolveCodexCarrierTruth } = await import('../dist/config/codex-cli.js'); +const { resolveCatCarrier } = await import('../dist/config/cat-config-loader.js'); -describe('resolveCodexCarrierTruth (F254 D2)', () => { - it('per-cat override beats env and default', () => { - assert.deepEqual(resolveCodexCarrierTruth('app_server', {}), { effective: 'app_server', source: 'per-cat' }); - assert.deepEqual(resolveCodexCarrierTruth('exec_json', { CAT_CAFE_CODEX_CARRIER: 'app_server' }), { - effective: 'exec_json', - source: 'per-cat', - }); - }); - - it('env applies when no per-cat override exists', () => { - assert.deepEqual(resolveCodexCarrierTruth(undefined, { CAT_CAFE_CODEX_CARRIER: 'app_server' }), { - effective: 'app_server', - source: 'env', - }); - assert.deepEqual(resolveCodexCarrierTruth(undefined, { CAT_CAFE_CODEX_CARRIER: 'exec_json' }), { - effective: 'exec_json', - source: 'env', - }); - // Unknown values fail closed to exec_json but are still env-sourced. - assert.deepEqual(resolveCodexCarrierTruth(undefined, { CAT_CAFE_CODEX_CARRIER: 'garbage' }), { - effective: 'exec_json', - source: 'env', - }); - }); - - it('falls back to the exec_json default when nothing is set', () => { - assert.deepEqual(resolveCodexCarrierTruth(undefined, {}), { effective: 'exec_json', source: 'default' }); +describe('resolveCatCarrier', () => { + it('keeps legacy reads at one boundary and always returns the canonical vocabulary', () => { + assert.equal(resolveCatCarrier({ carrier: 'app_server', transport: 'cli' }), 'app_server'); + assert.equal(resolveCatCarrier({ transport: 'acp' }), 'acp'); + assert.equal(resolveCatCarrier({ clientId: 'openai', cli: { carrier: 'app_server' } }), 'app_server'); + assert.equal(resolveCatCarrier({ clientId: 'openai' }, 'app_server'), 'app_server'); + assert.equal(resolveCatCarrier({ clientId: 'openai' }, 'exec_json'), 'cli'); + assert.equal(resolveCatCarrier({ clientId: 'openai', acp: { command: 'opencode' } }, 'app_server'), 'acp'); + assert.equal(resolveCatCarrier({ clientId: 'anthropic', cli: { carrier: 'app_server' } }), 'cli'); + assert.equal(resolveCatCarrier({}), 'cli'); }); }); -describe('production assembly wiring guard (F254 D2)', () => { - // F254 history: implementation existed and tests were green while the - // production wiring silently dropped the carrier. This source guard fails if - // the syncAgentRegistry openai case stops feeding per-cat carrier truth into - // CodexAgentService. - it('index.ts openai case passes resolveCodexCarrierTruth(config.cli?.carrier) into CodexAgentService', () => { +describe('production assembly carrier wiring', () => { + it('index.ts routes Codex solely from canonical config.carrier', () => { const source = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8'); const openaiCase = source.match(/case 'openai': \{[\s\S]*?break;\s*\}/); assert.ok(openaiCase, 'openai case not found in syncAgentRegistry'); - assert.match(openaiCase[0], /carrierMode:\s*resolveCodexCarrierTruth\(config\.cli\?\.carrier\)\.effective/); + assert.match(openaiCase[0], /carrierMode:\s*config\.carrier === 'app_server' \? 'app_server' : 'exec_json'/); + assert.doesNotMatch(openaiCase[0], /CAT_CAFE_CODEX_CARRIER|cli\?\.carrier/); }); }); diff --git a/packages/api/test/codex-event-transform.test.js b/packages/api/test/codex-event-transform.test.js index ada6c3fab8..a774362c8a 100644 --- a/packages/api/test/codex-event-transform.test.js +++ b/packages/api/test/codex-event-transform.test.js @@ -995,6 +995,85 @@ test('#1272: one already-canonical Codex turn is byte-stable after finalization' assert.equal(`${body?.content}${done?.content}`, original); }); +test('#1398: an identity-annotated own signature is normalized instead of duplicated', () => { + const state = { + hadPriorTextTurn: false, + signatureIdentity: '砚砚', + canonicalSignature: '[砚砚/gpt-5.3-codex-spark🐾]', + }; + const original = ['收到,明白。已直接 @小狸花 打招呼。', '', '[砚砚/缅因猫 gpt-5.3-codex-spark🐾@cat-gmwmct57]'].join( + '\n', + ); + const body = transformCodexEvent( + { type: 'item.completed', item: { type: 'agent_message', text: original } }, + 'cat-gmwmct57', + state, + ); + transformCodexEvent({ type: 'turn.completed', usage: {} }, 'cat-gmwmct57', state); + const done = finalizeCodexStream(state, 'cat-gmwmct57'); + + assert.equal( + `${body?.content}${done?.content}`, + '收到,明白。已直接 @小狸花 打招呼。\n\n[砚砚/gpt-5.3-codex-spark🐾]', + ); +}); + +test('#1398: a display-name signature is normalized to the nickname canonical signature', () => { + const state = { + hadPriorTextTurn: false, + signatureIdentity: '小狸花', + signatureIdentityAliases: ['狸花猫'], + canonicalSignature: '[小狸花/deepseek-chat🐾]', + }; + const original = 'Hello~ 狸花猫在 🐾\n\n[狸花猫/deepseek-chat🐾]'; + const body = transformCodexEvent( + { type: 'item.completed', item: { type: 'agent_message', text: original } }, + 'cat-83srpa6h', + state, + ); + transformCodexEvent({ type: 'turn.completed', usage: {} }, 'cat-83srpa6h', state); + const done = finalizeCodexStream(state, 'cat-83srpa6h'); + + assert.equal(`${body?.content}${done?.content}`, 'Hello~ 狸花猫在 🐾\n\n[小狸花/deepseek-chat🐾]'); +}); + +test('#1398 negative control: a display-name signature for another model remains content', () => { + const state = { + hadPriorTextTurn: false, + signatureIdentity: '小狸花', + signatureIdentityAliases: ['狸花猫'], + canonicalSignature: '[小狸花/deepseek-chat🐾]', + }; + const original = '示例:\n\n[狸花猫/another-model🐾]'; + const body = transformCodexEvent( + { type: 'item.completed', item: { type: 'agent_message', text: original } }, + 'cat-83srpa6h', + state, + ); + transformCodexEvent({ type: 'turn.completed', usage: {} }, 'cat-83srpa6h', state); + const done = finalizeCodexStream(state, 'cat-83srpa6h'); + + assert.equal(`${body?.content}${done?.content}`, `${original}\n\n[小狸花/deepseek-chat🐾]`); +}); + +test('#1398 negative control: a mismatched identity annotation remains user content', () => { + const state = { + hadPriorTextTurn: false, + signatureIdentity: '砚砚', + canonicalSignature: '[砚砚/gpt-5.3-codex-spark🐾]', + }; + const sample = '引用:[砚砚/缅因猫 gpt-5.3-codex-spark🐾@cat-someone-else]'; + const body = transformCodexEvent( + { type: 'item.completed', item: { type: 'agent_message', text: sample } }, + 'cat-gmwmct57', + state, + ); + transformCodexEvent({ type: 'turn.completed', usage: {} }, 'cat-gmwmct57', state); + const done = finalizeCodexStream(state, 'cat-gmwmct57'); + + assert.equal(`${body?.content}${done?.content}`, `${sample}\n\n[砚砚/gpt-5.3-codex-spark🐾]`); +}); + test('#1272: terminal teammate and legacy signatures remain content beside one canonical signature', () => { const state = { hadPriorTextTurn: false, diff --git a/packages/api/test/codex-unix-websocket-session.test.js b/packages/api/test/codex-unix-websocket-session.test.js index a2154df7fe..67e4c030bd 100644 --- a/packages/api/test/codex-unix-websocket-session.test.js +++ b/packages/api/test/codex-unix-websocket-session.test.js @@ -7,6 +7,7 @@ import { connectCodexAppServerHost, createCodexSocketDirectory, removeCodexSocketDirectory, + spawnCodexAppServerHost, } from '../dist/domains/cats/services/agents/providers/CodexUnixWebSocketSession.js'; async function withClosingUnixWebSocket(reason, run) { @@ -58,3 +59,28 @@ test('unix websocket sanitizes provider terminal diagnostics before exposing the ); }); }); + +test('unix host sanitizes stderr before taking its tail diagnostic', async () => { + const directory = createCodexSocketDirectory(); + const socketPath = join(directory, 'missing.sock'); + const token = `sk-${'X'.repeat(40)}`; + const stderr = `${'A'.repeat(1_100)}${token}${'B'.repeat(980)}`; + try { + await assert.rejects( + () => + spawnCodexAppServerHost({ + command: process.execPath, + args: ['--input-type=module', '-e', `process.stderr.write(${JSON.stringify(stderr)}); process.exit(1);`], + socketDirectory: directory, + socketPath, + }), + (error) => { + assert.doesNotMatch(error.message, /X{8,}/); + assert.match(error.message, /\[TOKEN_REDACTED\]/); + return true; + }, + ); + } finally { + await removeCodexSocketDirectory(directory); + } +}); diff --git a/packages/api/test/collective-ingress-dispatcher.test.js b/packages/api/test/collective-ingress-dispatcher.test.js index 01a914b225..0f7bd9edac 100644 --- a/packages/api/test/collective-ingress-dispatcher.test.js +++ b/packages/api/test/collective-ingress-dispatcher.test.js @@ -75,6 +75,15 @@ function harness(events, options = {}) { if (thread) thread.createdBy = 'another_owner'; } let queueFullRemaining = options.queueFullOnce ? 1 : 0; + const persistMessage = (input) => { + const threadId = input.threadId ?? 'default'; + const key = `${input.userId}:${threadId}:${input.idempotencyKey}`; + const existing = messages.get(key); + if (existing) return { message: existing, idempotent: true }; + const stored = { ...input, id: `msg_${++messageNumber}`, threadId }; + messages.set(key, stored); + return { message: stored, idempotent: false }; + }; const connector = { readParticipationContext: async () => ({}), getProjection: async () => ({ @@ -118,42 +127,37 @@ function harness(events, options = {}) { connector, threadStore: { get: async (threadId) => threads.get(threadId) ?? null }, messageStore: { - appendIdempotent: async (input) => { - const key = `${input.userId}:${input.threadId}:${input.idempotencyKey}`; - const existing = messages.get(key); - if (existing) return { message: existing, idempotent: true }; - const stored = { ...input, id: `msg_${++messageNumber}`, threadId: input.threadId }; - messages.set(key, stored); - return { message: stored, idempotent: false }; - }, + getByIdempotencyKey: async (userId, threadId, idempotencyKey) => + messages.get(`${userId}:${threadId}:${idempotencyKey}`) ?? null, + append: async (input) => persistMessage(input).message, + appendIdempotent: async (input) => persistMessage(input), }, invocationQueue: { - enqueue: (input) => { + appendAndEnqueueDurable: async (_messageStore, messageInput, input) => { if (queueFullRemaining > 0) { queueFullRemaining -= 1; return { outcome: 'full' }; } const existing = queue.find((entry) => entry.idempotencyKey === input.idempotencyKey); - if (existing) return { outcome: 'enqueued', entry: existing, deduped: true }; + if (existing) { + return { + outcome: 'enqueued', + message: persistMessage(messageInput).message, + entry: existing, + entries: [existing], + deduped: true, + }; + } + const message = persistMessage(messageInput).message; const entry = { ...input, id: `queue_${queue.length + 1}`, - messageId: null, - mergedMessageIds: [], + messageId: message.id, status: 'queued', - createdAt: Date.now(), - autoExecute: true, - priority: 'normal', + enqueuedAt: Date.now(), }; queue.push(entry); - return { outcome: 'enqueued', entry, deduped: false }; - }, - backfillMessageId: (_threadId, _ownerId, entryId, messageId) => { - queue.find((entry) => entry.id === entryId).messageId = messageId; - }, - rollbackEnqueue: (_threadId, _ownerId, entryId) => { - const index = queue.findIndex((entry) => entry.id === entryId); - if (index >= 0) queue.splice(index, 1); + return { outcome: 'enqueued', message, entry, entries: [entry], deduped: false }; }, }, queueProcessor: { processNext: async (threadId, ownerId) => processed.push({ threadId, ownerId }) }, diff --git a/packages/api/test/collective-owner-participation.test.js b/packages/api/test/collective-owner-participation.test.js index 140eef4f97..6addfc37f1 100644 --- a/packages/api/test/collective-owner-participation.test.js +++ b/packages/api/test/collective-owner-participation.test.js @@ -11,12 +11,13 @@ import { CollectiveCurrentContext } from '../dist/domains/plugin/builtin-runtime import { CollectiveWorkAuthority } from '../dist/domains/plugin/builtin-runtime/collective-work-authority.js'; import { CollectiveWorkDispatcher } from '../dist/domains/plugin/builtin-runtime/collective-work-dispatcher.js'; import { registerCollectiveOwnerParticipationRoutes } from '../dist/routes/collective-owner-participation-routes.js'; +import { adaptMessageStore } from './helpers/message-from-fixtures.js'; import { readHeaders, writeHeaders } from './plugin-official-routes.fixture.js'; const userId = writeHeaders['x-test-session-user']; const base = '/api/plugins/collective-connector/con_aaaaaaaa'; async function harness(delayThreadWrites = false) { - const messages = new MessageStore(); + const messages = adaptMessageStore(new MessageStore()); const tasks = new TaskStore(); const threads = new ThreadStore(); let route; diff --git a/packages/api/test/collective-persistent-authority.test.js b/packages/api/test/collective-persistent-authority.test.js index 3493951d8a..4f586e1cc0 100644 --- a/packages/api/test/collective-persistent-authority.test.js +++ b/packages/api/test/collective-persistent-authority.test.js @@ -8,9 +8,9 @@ import { safeParseExtra } from '../dist/domains/cats/services/stores/redis/redis const receipt = { v: 1, sourceRef: 'message:source-A', catId: 'codex-astra', ownerAuthProvenance: 'strict' }; const trigger = { v: 1, taskId: 'work', observedRevision: 1 }; const input = { + from: { kind: 'user', userId: 'owner' }, userId: 'owner', threadId: 'private', - catId: null, content: 'Owner receipt', mentions: [], timestamp: 1, @@ -29,13 +29,43 @@ test('generic Message mutations cannot forge or replace persisted owner admissio await store.updateExtra(plain.id, { collectiveOwnerAdmissionV1: receipt, collectiveWorkInvocationV1: trigger }); assert.equal(store.getById(plain.id).extra?.collectiveOwnerAdmissionV1, undefined); assert.equal(store.getById(plain.id).extra?.collectiveWorkInvocationV1, undefined); - for (const extra of [{ collectiveOwnerAdmissionV1: receipt }, { collectiveWorkInvocationV1: trigger }]) { - assert.throws(() => store.append({ ...input, catId: 'codex-astra', extra }), /Host-owned/); + assert.throws( + () => + store.append({ + ...input, + from: { kind: 'agent', catId: 'codex-astra' }, + extra: { collectiveOwnerAdmissionV1: receipt }, + }), + /Host-owned/, + ); + assert.throws( + () => + store.append({ + ...input, + from: { kind: 'external', connectorId: 'collective' }, + source: { connector: 'collective', label: 'external' }, + extra: { collectiveOwnerAdmissionV1: receipt }, + }), + /Host-owned/, + ); + for (const from of [ + { kind: 'user', userId: 'owner' }, + { kind: 'agent', catId: 'codex-astra' }, + { kind: 'system', service: 'other' }, + ]) { assert.throws( - () => store.append({ ...input, source: { connector: 'collective', label: 'external' }, extra }), - /Host-owned/, + () => store.append({ ...input, from, extra: { collectiveWorkInvocationV1: trigger } }), + /canonical system producer/, ); } + assert.ok( + store.append({ + ...input, + timestamp: 3, + from: { kind: 'system', service: 'collective-work' }, + extra: { collectiveWorkInvocationV1: trigger }, + }), + ); const redis = new RedisMessageStore({ options: {}, eval() { @@ -43,7 +73,11 @@ test('generic Message mutations cannot forge or replace persisted owner admissio }, }); await assert.rejects( - redis.append({ ...input, catId: 'codex-astra', extra: { collectiveOwnerAdmissionV1: receipt } }), + redis.append({ + ...input, + from: { kind: 'agent', catId: 'codex-astra' }, + extra: { collectiveOwnerAdmissionV1: receipt }, + }), /Host-owned/, ); }); diff --git a/packages/api/test/collective-queue-recovery.test.js b/packages/api/test/collective-queue-recovery.test.js deleted file mode 100644 index 80a5f280ca..0000000000 --- a/packages/api/test/collective-queue-recovery.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { createInitialQueuedMessageCustody } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { buildQueueEntry } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupQueueEntry.js'; -import { authRecordFromRedisHash } from '../dist/domains/cats/services/agents/invocation/RedisAuthInvocationRecord.js'; - -const publicInput = { - userId: 'owner', - threadId: 'thread', - content: 'Public request', - targetCats: ['codex-astra'], - source: 'connector', - intent: 'execute', - ownerAuthProvenance: 'unknown', - executionScope: 'collective-participation', -}; - -test('Queue rejects public owner escalation and unowned private Work before persistence', () => { - const queue = new InvocationQueue(); - assert.throws(() => queue.enqueue({ ...publicInput, ownerAuthProvenance: 'strict' }), /scope/i); - assert.throws(() => queue.enqueue({ ...publicInput, executionScope: 'collective-work' }), /scope/i); - assert.throws(() => queue.enqueue({ ...publicInput, targetCats: ['codex-astra', 'opus'] }), /scope/i); -}); - -test('Queue restart retains singleton origin and scope; adjacent Channel requests never coalesce', () => { - const queue = new InvocationQueue(); - const a = queue.enqueue({ ...publicInput, idempotencyKey: 'public:A' }); - const b = queue.enqueue({ ...publicInput, idempotencyKey: 'public:B', content: 'B private to another public scope' }); - assert.notEqual(a.entry.id, b.entry.id); - const custody = createInitialQueuedMessageCustody(a.entry); - const message = { - id: 'message:A', - userId: 'owner', - threadId: 'thread', - catId: null, - content: 'Only A', - timestamp: 1, - queueCustody: custody, - }; - const restored = buildQueueEntry([structuredClone(message)], a.entry.id); - assert.equal(restored.executionScope, 'collective-participation'); - assert.equal(restored.ownerAuthProvenance, 'unknown'); - assert.equal(restored.source, 'connector'); - assert.equal(restored.messageId, message.id); - assert.throws( - () => buildQueueEntry([message, { ...message, id: 'message:B', content: 'B' }], a.entry.id), - /scope|singleton/i, - ); -}); - -test('persisted auth corruption cannot detach a public tool policy from its grant', () => { - const fields = { - invocationId: 'inv', - callbackToken: 'token', - userId: 'owner', - catId: 'codex-astra', - threadId: 'thread', - ownerAuthProvenance: 'strict', - toolExecutionPolicy: '{"mode":"collective_participation"}', - }; - assert.equal(authRecordFromRedisHash(fields, new Set()), null); -}); diff --git a/packages/api/test/collective-work-return.test.js b/packages/api/test/collective-work-return.test.js index 57a6e5a8c2..9ac135e76e 100644 --- a/packages/api/test/collective-work-return.test.js +++ b/packages/api/test/collective-work-return.test.js @@ -11,6 +11,7 @@ import { TaskStore } from '../dist/domains/cats/services/stores/ports/TaskStore. import { CollectiveCurrentContext } from '../dist/domains/plugin/builtin-runtime/collective-current-context.js'; import { CollectiveWorkAuthority } from '../dist/domains/plugin/builtin-runtime/collective-work-authority.js'; import { CollectiveWorkDispatcher } from '../dist/domains/plugin/builtin-runtime/collective-work-dispatcher.js'; +import { adaptMessageStore } from './helpers/message-from-fixtures.js'; catRegistry.register('codex-astra', { ...catRegistry.tryGet('codex-sol').config, @@ -20,7 +21,7 @@ catRegistry.register('codex-astra', { }); function fixture() { - const messages = new MessageStore(); + const messages = adaptMessageStore(new MessageStore()); const tasks = new TaskStore(); const operations = new Map(); const source = { @@ -144,7 +145,7 @@ async function privateAuth(f, result, revision = result.revision) { const trigger = f.messages.append({ userId: 'owner', threadId: 'private', - catId: null, + from: { kind: 'system', service: 'collective-work' }, content: 'Resume current Work', mentions: [], timestamp: Date.now(), diff --git a/packages/api/test/commands-route.test.js b/packages/api/test/commands-route.test.js index f00585b0c0..a860032f50 100644 --- a/packages/api/test/commands-route.test.js +++ b/packages/api/test/commands-route.test.js @@ -11,6 +11,7 @@ import { TaskStore } from '../dist/domains/cats/services/stores/ports/TaskStore. import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; import { CommandRegistry } from '../dist/infrastructure/commands/CommandRegistry.js'; import { commandsRoutes } from '../dist/routes/commands.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; // Mock opus service const mockOpusService = { @@ -59,12 +60,14 @@ describe('Commands Routes', () => { it('POST /api/commands/extract-tasks creates tasks', async () => { // Add some messages first - await messageStore.append({ - content: 'TODO: write tests', - userId: 'test-user', - threadId: ownThreadId, - timestamp: TEST_MESSAGE_TIMESTAMP, - }); + await messageStore.append( + canonicalTestMessageInput({ + content: 'TODO: write tests', + userId: 'test-user', + threadId: ownThreadId, + timestamp: TEST_MESSAGE_TIMESTAMP, + }), + ); const res = await app.inject({ method: 'POST', @@ -109,12 +112,14 @@ describe('Commands Routes', () => { }); it('uses X-Cat-Cafe-User header over legacy payload userId', async () => { - await messageStore.append({ - content: 'TODO: header identity should win', - userId: 'test-user', - threadId: ownThreadId, - timestamp: TEST_MESSAGE_TIMESTAMP, - }); + await messageStore.append( + canonicalTestMessageInput({ + content: 'TODO: header identity should win', + userId: 'test-user', + threadId: ownThreadId, + timestamp: TEST_MESSAGE_TIMESTAMP, + }), + ); const res = await app.inject({ method: 'POST', @@ -144,12 +149,14 @@ describe('Commands Routes', () => { }); it('returns 403 when accessing another user thread', async () => { - await messageStore.append({ - content: 'TODO: should not be visible', - userId: 'other-user', - threadId: otherThreadId, - timestamp: TEST_MESSAGE_TIMESTAMP, - }); + await messageStore.append( + canonicalTestMessageInput({ + content: 'TODO: should not be visible', + userId: 'other-user', + threadId: otherThreadId, + timestamp: TEST_MESSAGE_TIMESTAMP, + }), + ); const res = await app.inject({ method: 'POST', diff --git a/packages/api/test/concierge-a3b-route.test.js b/packages/api/test/concierge-a3b-route.test.js index 583e6996f9..b7e1d23d86 100644 --- a/packages/api/test/concierge-a3b-route.test.js +++ b/packages/api/test/concierge-a3b-route.test.js @@ -14,6 +14,7 @@ import './helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const USER_HEADER = { 'x-cat-cafe-user': 'test-user', 'content-type': 'application/json' }; @@ -478,13 +479,15 @@ describe('GET /api/concierge/peek', () => { // Seed messages in a thread — append returns StoredMessage with generated id for (let i = 0; i < 7; i++) { - const stored = messageStore.append({ - threadId: 'peek-thread', - content: `Message ${i}`, - userId: 'test-user', - catId: i % 2 === 0 ? null : 'opus', - timestamp: Date.now() + i * 1000, - }); + const stored = messageStore.append( + canonicalTestMessageInput({ + threadId: 'peek-thread', + content: `Message ${i}`, + userId: 'test-user', + catId: i % 2 === 0 ? null : 'opus', + timestamp: Date.now() + i * 1000, + }), + ); msgIds.push(stored.id); } }); @@ -509,15 +512,17 @@ describe('GET /api/concierge/peek', () => { }); it('returns a normal window around queued cat-authored speech already published to timeline', async () => { - const target = messageStore.append({ - threadId: 'peek-thread', - content: 'published source-cat seed', - userId: 'test-user', - catId: 'codex-sol', - mentions: ['opus'], - timestamp: Date.now() + 10_000, - deliveryStatus: 'queued', - }); + const target = messageStore.append( + canonicalTestMessageInput({ + threadId: 'peek-thread', + content: 'published source-cat seed', + userId: 'test-user', + catId: 'codex-sol', + mentions: ['opus'], + timestamp: Date.now() + 10_000, + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'GET', diff --git a/packages/api/test/concurrent-fault-drill.test.js b/packages/api/test/concurrent-fault-drill.test.js index 473474fb52..e6e7824ce1 100644 --- a/packages/api/test/concurrent-fault-drill.test.js +++ b/packages/api/test/concurrent-fault-drill.test.js @@ -14,6 +14,7 @@ import { DeliveryCursorStore } from '../dist/domains/cats/services/stores/ports/ import { InvocationRecordStore } from '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -78,25 +79,29 @@ describe('Concurrent fault drills - in-memory stores', () => { const threadId = 'thread-race-cursor'; const baseTs = Date.now(); - const base = messageStore.append({ - userId, - catId: null, - content: 'base', - mentions: [], - timestamp: baseTs, - threadId, - }); - - const appendPromise = Promise.resolve().then(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - return messageStore.append({ + const base = messageStore.append( + canonicalTestMessageInput({ userId, catId: null, - content: 'new-after-ack', + content: 'base', mentions: [], - timestamp: baseTs + 1, + timestamp: baseTs, threadId, - }); + }), + ); + + const appendPromise = Promise.resolve().then(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: 'new-after-ack', + mentions: [], + timestamp: baseTs + 1, + threadId, + }), + ); }); const ackPromise = Promise.resolve().then(() => deliveryCursorStore.ackCursor(userId, catId, threadId, base.id)); @@ -248,25 +253,29 @@ describe('Concurrent fault drills - Redis stores', { skip: redisIsolationSkipRea const threadId = 'thread-race-cursor'; const baseTs = Date.now(); - const base = await messageStore.append({ - userId, - catId: null, - content: 'base', - mentions: [], - timestamp: baseTs, - threadId, - }); - - const appendPromise = Promise.resolve().then(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - return messageStore.append({ + const base = await messageStore.append( + canonicalTestMessageInput({ userId, catId: null, - content: 'new-after-ack', + content: 'base', mentions: [], - timestamp: baseTs + 1, + timestamp: baseTs, threadId, - }); + }), + ); + + const appendPromise = Promise.resolve().then(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: 'new-after-ack', + mentions: [], + timestamp: baseTs + 1, + threadId, + }), + ); }); const ackPromise = Promise.resolve().then(() => deliveryCursorStore.ackCursor(userId, catId, threadId, base.id)); diff --git a/packages/api/test/conflict-router.test.js b/packages/api/test/conflict-router.test.js index 0f48a8fb85..abfc5e8bf6 100644 --- a/packages/api/test/conflict-router.test.js +++ b/packages/api/test/conflict-router.test.js @@ -62,7 +62,7 @@ describe('ConflictRouter F280 typed waits', () => { }); assert.equal(result.kind, 'notified'); assert.match(result.content, /mergeable → conflicting/); - assert.equal(messageStore.getByThread('thread_1').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 1); }); test('conflict remains state-only for a new-HEAD waiter', async () => { @@ -74,7 +74,7 @@ describe('ConflictRouter F280 typed waits', () => { mergeState: 'CONFLICTING', }); assert.equal(result.kind, 'skipped'); - assert.equal(messageStore.getByThread('thread_1').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 0); assert.equal((await taskStore.get(task.id)).automationState.conflict.mergeState, 'CONFLICTING'); }); diff --git a/packages/api/test/connector-invoke-trigger.test.js b/packages/api/test/connector-invoke-trigger.test.js index 16f963fbe9..075c555a3c 100644 --- a/packages/api/test/connector-invoke-trigger.test.js +++ b/packages/api/test/connector-invoke-trigger.test.js @@ -1,3239 +1,340 @@ // @ts-check -import assert from 'node:assert'; +import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationRecordStore } from '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { ConnectorInvokeTrigger } from '../dist/infrastructure/email/ConnectorInvokeTrigger.js'; - -const { claimActionSuccessor, continueActionSuccessorFreshRevision, recordActionSuccessorOutcome } = await import( - '../dist/domains/ball-custody/action-successor-state-machine.js' -); -const { canonicalizeActionTerminalPredicate } = await import( - '../dist/domains/ball-custody/ActionTerminalPredicateCatalog.js' -); - -// ─── Mocks ─────────────────────────────────────────────────────── +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function noopLog() { const noop = () => {}; - return /** @type {any} */ ({ - info: noop, - warn: noop, - error: noop, - debug: noop, - trace: noop, - fatal: noop, - child: () => noopLog(), - }); -} - -/** - * Mock AgentRouter that yields a single done message. - * @param {object} [opts] - * @param {Error} [opts.throwError] - If set, routeExecution throws this error - * @param {boolean} [opts.persistenceFail] - If set, simulates persistence failure - * @param {any[]} [opts.messages] - Optional exact routeExecution messages to yield - */ -function mockRouter(opts = {}) { - const calls = - /** @type {Array<{userId: string, message: string, threadId: string, userMessageId: string, targetCats: string[], intent: object, options?: object}>} */ ([]); - const ackCalls = /** @type {Array<{userId: string, threadId: string}>} */ ([]); - - return { - calls, - ackCalls, - /** @type {any} */ - router: { - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - calls.push({ userId, message, threadId, userMessageId, targetCats, intent, options }); - - if (opts.throwError) throw opts.throwError; - - if (opts.messages) { - for (const msg of opts.messages) { - yield msg; - } - return; - } - - if (opts.persistenceFail && options?.persistenceContext) { - options.persistenceContext.failed = true; - options.persistenceContext.errors.push({ catId: targetCats[0], error: 'disk full' }); - } - - yield { - type: 'text', - catId: targetCats[0], - content: 'Review noted. Working on it.', - timestamp: Date.now(), - }; - yield { - type: 'done', - catId: targetCats[0], - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 100, outputTokens: 50 } }, - }; - }, - async ackCollectedCursors(userId, threadId, _boundaries) { - ackCalls.push({ userId, threadId }); - }, - }, - }; -} - -/** - * Most unit routers below model provider-visible output only. Production emits - * a durable child `invocation_created` event immediately before that output. - * Keep that lifecycle boundary explicit in the shared harness; tests for - * pre-invocation router events opt out and yield the full sequence themselves. - * @param {any} router - */ -function withDurableChildStart(router) { - return { - ...router, - async *routeExecution(...args) { - const targetCats = args[4]; - const options = args[6]; - let childStartEmitted = false; - for await (const message of router.routeExecution(...args)) { - if (!childStartEmitted) { - childStartEmitted = true; - const startedAt = Date.now(); - yield { - type: 'system_info', - catId: targetCats[0], - content: JSON.stringify({ - type: 'invocation_created', - invocationId: `${options.parentInvocationId}-child`, - parentInvocationId: options.parentInvocationId, - executionKind: 'foreground', - startedAt, - }), - timestamp: startedAt, - }; - } - yield message; - } - }, - }; -} - -function mockSocketManager() { - const broadcasts = /** @type {Array<{msg: any, threadId: string}>} */ ([]); - const roomBroadcasts = /** @type {Array<{room: string, event: string, data: any}>} */ ([]); - const userEmits = /** @type {Array<{userId: string, event: string, data: any}>} */ ([]); - return { - broadcasts, - roomBroadcasts, - userEmits, - /** @type {any} */ - manager: { - broadcastAgentMessage(msg, threadId) { - broadcasts.push({ msg, threadId }); - }, - broadcastToRoom(room, event, data) { - roomBroadcasts.push({ room, event, data }); - }, - emitToUser(userId, event, data) { - userEmits.push({ userId, event, data }); - }, - }, - }; -} - -function mockInvocationRecordStore() { - const creates = /** @type {any[]} */ ([]); - const updates = /** @type {Array<{id: string, data: any}>} */ ([]); - let createCounter = 0; - /** @type {(() => void) | null} */ - let beforeCreate = null; - /** @type {(() => void) | null} */ - let beforeUpdate = null; - let duplicateOnCreate = false; - const records = new Map(); - - return { - creates, - updates, - /** @type {any} */ - store: { - async create(input) { - beforeCreate?.(); - creates.push(input); - if (duplicateOnCreate) { - return { outcome: 'duplicate', invocationId: 'inv-existing' }; - } - createCounter++; - const invocationId = `inv-${createCounter}`; - records.set(invocationId, { - id: invocationId, - ...input, - userMessageId: null, - status: 'queued', - }); - return { outcome: 'created', invocationId }; - }, - async update(id, data) { - beforeUpdate?.(); - updates.push({ id, data }); - const record = records.get(id); - if (!record) return null; - if (data.expectedStatus !== undefined && record.status !== data.expectedStatus) return null; - const { expectedStatus: _expectedStatus, ...patch } = data; - Object.assign(record, patch); - return record; - }, - async get(id) { - return records.get(id) ?? null; - }, - async getByIdempotencyKey(_threadId, _userId, _key) { - return records.get('inv-existing') ?? null; - }, - }, - /** Force next create to return a duplicate record in the requested lifecycle state. */ - setDuplicate(statusOrOverrides = 'succeeded') { - duplicateOnCreate = true; - const overrides = typeof statusOrOverrides === 'string' ? { status: statusOrOverrides } : statusOrOverrides; - records.set('inv-existing', { - id: 'inv-existing', - threadId: 'thread-1', - userId: 'user-1', - userMessageId: 'msg-1', - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: 'connector-msg-1', - actionLeaseCarrier: { kind: 'none' }, - status: overrides.status ?? 'succeeded', - ...(overrides.status === 'running' ? { executionStartedAt: Date.now() - 500 } : {}), - createdAt: Date.now() - 1_000, - updatedAt: Date.now() - 1_000, - ...overrides, - }); - }, - setMissingDuplicate() { - duplicateOnCreate = true; - records.delete('inv-existing'); - }, - setBeforeCreate(cb) { - beforeCreate = cb; - }, - setBeforeUpdate(cb) { - beforeUpdate = cb; - }, - setRecordStatus(id, status) { - const record = records.get(id); - if (record) record.status = status; - }, - }; -} - -function sharedQueuedRaceRecordStore() { - let record = { - id: 'inv-existing', - threadId: 'thread-1', - userId: 'user-1', - userMessageId: 'msg-1', - targetCats: ['opus'], - intent: 'execute', - status: 'queued', - idempotencyKey: 'connector-msg-1', - actionLeaseCarrier: { kind: 'none' }, - createdAt: Date.now() - 1_000, - updatedAt: Date.now() - 1_000, - }; - let queuedReads = 0; - /** @type {() => void} */ - let releaseQueuedReads; - const bothReadQueued = new Promise((resolve) => { - releaseQueuedReads = resolve; - }); - - return { - /** @type {any} */ - store: { - async create() { - return { outcome: 'duplicate', invocationId: record.id }; - }, - async get(id) { - if (id !== record.id) return null; - const snapshot = { ...record }; - if (snapshot.status === 'queued') { - queuedReads++; - if (queuedReads === 2) releaseQueuedReads(); - await bothReadQueued; - } - return snapshot; - }, - async update(id, data) { - if (id !== record.id) return null; - if (data.expectedStatus !== undefined && record.status !== data.expectedStatus) return null; - const { expectedStatus: _expectedStatus, ...patch } = data; - record = { ...record, ...patch, updatedAt: Date.now() }; - return { ...record }; - }, - async getByIdempotencyKey() { - return { ...record }; - }, - }, - getRecord() { - return { ...record }; - }, - }; + return /** @type {any} */ ({ info: noop, warn: noop, error: noop, debug: noop, trace: noop, fatal: noop }); } -function mockInvocationTracker() { - const starts = /** @type {Array<{threadId: string, catId: string}>} */ ([]); - const tryStartThreadCalls = /** @type {Array<{threadId: string, catId: string}>} */ ([]); - const completes = /** @type {Array<{threadId: string, catId: string}>} */ ([]); - const slotCompletes = /** @type {Array<{threadId: string, catId: string, controller: AbortController}>} */ ([]); - const allCompletes = /** @type {Array<{threadId: string, catIds: string[], controller: AbortController}>} */ ([]); - const cancelCalls = /** @type {Array<{threadId: string, catId: string, userId: (string|undefined)}>} */ ([]); - let aborted = false; - let cancelDenied = false; - /** @type {Map} key = "threadId:catId" or "threadId" for legacy */ - const activeSlots = new Map(); - +function socketHarness() { + const userEvents = /** @type {Array<{userId: string, event: string, payload: any}>} */ ([]); return { - starts, - tryStartThreadCalls, - completes, - slotCompletes, - allCompletes, - cancelCalls, - setAborted(val) { - aborted = val; - }, - setCancelDenied(val) { - cancelDenied = val; - }, - /** Mark a slot as having an active invocation (for queue tests) */ - setActive(threadId, userId = 'user-1') { - activeSlots.set(threadId, userId); - }, - clearActive(threadId) { - activeSlots.delete(threadId); - }, - /** @type {any} */ - tracker: { - start(threadId, catId, _userId, _targetCats) { - starts.push({ threadId, catId }); - const controller = { signal: { aborted } }; - return controller; - }, - tryStartThread(threadId, catId, _userId, _catIds) { - tryStartThreadCalls.push({ threadId, catId }); - if (activeSlots.has(threadId)) return null; - return { signal: { aborted } }; - }, - complete(threadId, catId, _controller) { - completes.push({ threadId, catId }); - }, - completeSlot(threadId, catId, controller) { - slotCompletes.push({ threadId, catId, controller }); - }, - completeAll(threadId, catIds, controller) { - allCompletes.push({ threadId, catIds: [...catIds], controller }); - }, - trackExternalSlot() { - return true; - }, - has(threadId, _catId) { - return activeSlots.has(threadId); - }, - getUserId(threadId, _catId) { - return activeSlots.get(threadId); - }, - cancel(threadId, catId, userId) { - cancelCalls.push({ threadId, catId, userId }); - if (cancelDenied) return { cancelled: false, catIds: [] }; - const owner = activeSlots.get(threadId); - if (!owner) return { cancelled: false, catIds: [] }; - if (userId && owner !== userId) return { cancelled: false, catIds: [] }; - activeSlots.delete(threadId); - return { cancelled: true, catIds: ['opus'] }; - }, - }, + userEvents, + manager: /** @type {any} */ ({ + emitToUser(userId, event, payload) { + userEvents.push({ userId, event, payload }); + }, + broadcastAgentMessage() {}, + broadcastToRoom() {}, + }), }; } -// ─── Tests ─────────────────────────────────────────────────────── - -describe('ConnectorInvokeTrigger', () => { - /** @type {ReturnType} */ - let routerMock; - /** @type {ReturnType} */ - let socketMock; - /** @type {ReturnType} */ - let recordMock; - /** @type {ReturnType} */ - let trackerMock; - +describe('ConnectorInvokeTrigger canonical Queue ingress', () => { /** @type {InvocationQueue} */ let queue; + /** @type {MessageStore} */ + let messageStore; + /** @type {ReturnType} */ + let sockets; + /** @type {string[]} */ + let drains; + /** @type {ConnectorInvokeTrigger} */ + let trigger; + /** @type {Map} */ + let waitTasks; beforeEach(() => { - routerMock = mockRouter(); - socketMock = mockSocketManager(); - recordMock = mockInvocationRecordStore(); - trackerMock = mockInvocationTracker(); queue = new InvocationQueue(); + messageStore = new MessageStore(); + sockets = socketHarness(); + drains = []; + waitTasks = new Map(); + trigger = makeTrigger(); }); - function createTrigger(overrides = {}) { - const { - router: overrideRouter = routerMock.router, - routerIncludesInvocationLifecycle = false, - ...triggerOverrides - } = overrides; + function makeTrigger(overrides = /** @type {any} */ ({})) { return new ConnectorInvokeTrigger({ - router: routerIncludesInvocationLifecycle ? overrideRouter : withDurableChildStart(overrideRouter), - socketManager: socketMock.manager, - invocationRecordStore: recordMock.store, - invocationTracker: trackerMock.tracker, + socketManager: sockets.manager, invocationQueue: queue, - log: noopLog(), - ...triggerOverrides, - }); - } - - /** Wait for background execution to complete */ - async function waitForTrigger() { - // trigger() is fire-and-forget; give microtasks time to settle - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - function waitConnectorMessage(messageId, outcomeId, generation = 3) { - return { - id: messageId, - threadId: 'thread-1', - userId: 'user-1', - catId: null, - content: `Wait outcome ${outcomeId}`, - mentions: ['opus'], - timestamp: Date.now(), - source: { - connector: 'github-wait', - label: 'GitHub Wait', - icon: 'github', - meta: { - waitContinuationCarrier: { - v: 1, - waitId: 'task-pr-7', - outcomeId, - ownerFence: { kind: 'containing_task', generation }, - }, + queueProcessor: /** @type {any} */ ({ + async requestDrain(threadId) { + drains.push(threadId); }, - }, - }; - } - - function activeManagedReviewLease(overrides = {}) { - const terminalPredicate = canonicalizeActionTerminalPredicate({ - actionFamily: 'review', - subjectRef: 'pr:owner/repo#4049', - predicate: { kind: 'review_delivered', headSha: 'a'.repeat(40) }, - }); - const lease = claimActionSuccessor(null, { - leaseId: 'lease-review-1', - tenantScope: 'user-1', - subjectRef: 'pr:owner/repo#4049', - actionFamily: 'review', - successorSlot: 'reviewer', - mode: 'single', - holderCatIds: ['opus'], - holderThreadId: 'thread-managed-event', - dispatchId: 'dispatch-review-1', - claimOrigin: 'structured_transfer', - predecessorCatId: 'codex-sol', - predecessorThreadId: 'thread-author', - issuerStandingEvidenceRef: 'message:review-request-1', - evidenceRefs: ['message:review-request-1'], - terminalPredicate, - now: 100, - }).lease; - return { - ...lease, - generation: 3, + }), + messageStore, + waitTaskStore: { get: async (taskId) => waitTasks.get(taskId) ?? null }, + log: noopLog(), ...overrides, - }; - } - - it('creates InvocationRecord and calls routeExecution', async () => { - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - // InvocationRecord created - assert.strictEqual(recordMock.creates.length, 1); - assert.strictEqual(recordMock.creates[0].threadId, 'thread-1'); - assert.deepStrictEqual(recordMock.creates[0].targetCats, ['opus']); - assert.strictEqual(recordMock.creates[0].idempotencyKey, 'connector-msg-1'); - - // routeExecution called - assert.strictEqual(routerMock.calls.length, 1); - assert.strictEqual(routerMock.calls[0].userId, 'user-1'); - assert.strictEqual(routerMock.calls[0].message, 'Review msg'); - assert.strictEqual(routerMock.calls[0].threadId, 'thread-1'); - assert.strictEqual(routerMock.calls[0].userMessageId, 'msg-1'); - assert.deepStrictEqual(routerMock.calls[0].targetCats, ['opus']); - }); - - it('copies the exact stored wait carrier into a direct Invocation and event-wait turn provenance', async () => { - const storedMessage = waitConnectorMessage('msg-wait-direct', 'wait:pr:owner/repo#7:g3:matched'); - const trigger = createTrigger({ - messageStore: { getById: async (id) => (id === storedMessage.id ? storedMessage : null) }, - }); - - await trigger.trigger( - storedMessage.threadId, - /** @type {any} */ ('opus'), - storedMessage.userId, - storedMessage.content, - storedMessage.id, - undefined, - { sourceCategory: 'review' }, - ); - await waitForTrigger(); - - const expectedCarrier = storedMessage.source.meta.waitContinuationCarrier; - assert.deepStrictEqual(recordMock.creates[0].waitContinuationCarrier, expectedCarrier); - assert.deepStrictEqual(routerMock.calls[0].options.turnCustodyWake, { - kind: 'structured', - protocol: 'event_wait', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'opus', - waitContinuationCarrier: expectedCarrier, - }); - }); - - it('keeps an action-successor wait owner fence inside event-wait custody', async () => { - const storedMessage = waitConnectorMessage('msg-wait-action-owner', 'wait:pr:owner/repo#7:g4:matched', 4); - storedMessage.source.meta.waitContinuationCarrier.ownerFence = { - kind: 'action_successor', - leaseId: 'lease-wait-4', - generation: 4, - }; - const trigger = createTrigger({ - messageStore: { getById: async (id) => (id === storedMessage.id ? storedMessage : null) }, }); + } - await trigger.trigger( - storedMessage.threadId, - /** @type {any} */ ('opus'), - storedMessage.userId, - storedMessage.content, - storedMessage.id, - undefined, - { sourceCategory: 'review' }, + function appendSource(idSuffix, options = /** @type {any} */ ({})) { + return messageStore.append( + canonicalTestMessageInput({ + threadId: options.threadId ?? 'thread-1', + userId: options.userId ?? 'user-1', + catId: null, + content: `connector-${idSuffix}`, + mentions: ['opus'], + timestamp: Date.now(), + source: options.source ?? { connector: 'github', label: 'GitHub' }, + ...(options.deliveryStatus ? { deliveryStatus: options.deliveryStatus } : {}), + }), ); - await waitForTrigger(); + } - assert.deepStrictEqual(recordMock.creates[0].actionLeaseCarrier, { kind: 'none' }); - assert.deepStrictEqual( - recordMock.creates[0].waitContinuationCarrier.ownerFence, - storedMessage.source.meta.waitContinuationCarrier.ownerFence, - ); - assert.strictEqual(routerMock.calls[0].options.turnCustodyWake.protocol, 'event_wait'); - assert.deepStrictEqual( - routerMock.calls[0].options.turnCustodyWake.waitContinuationCarrier, - storedMessage.source.meta.waitContinuationCarrier, + it('atomically commits one canonical Queue row before requesting a drain', async () => { + const source = appendSource('idle'); + assert.equal(await trigger.trigger(source.threadId, 'opus', source.userId, source.content, source.id), 'enqueued'); + + const entries = queue.list(source.threadId, source.userId); + assert.equal(entries.length, 1); + assert.equal(entries[0].payload.messageId, source.id); + assert.deepEqual(entries[0].from, { kind: 'external', connectorId: 'github' }); + assert.equal(entries[0].execution.ownerAuthProvenance, 'unknown'); + assert.deepEqual(entries[0].targets, ['opus']); + assert.equal(messageStore.getById(source.id)?.deliveryStatus, 'queued'); + assert.deepEqual(drains, [source.threadId]); + assert.ok(sockets.userEvents.some((event) => event.event === 'queue_updated')); + }); + + it('deduplicates an exact source without a second Queue carrier', async () => { + const source = appendSource('duplicate'); + const args = /** @type {const} */ ([source.threadId, 'opus', source.userId, source.content, source.id]); + assert.equal(await trigger.trigger(...args), 'enqueued'); + assert.equal(await trigger.trigger(...args), 'enqueued'); + assert.equal(queue.list(source.threadId, source.userId).length, 1); + assert.deepEqual(drains, [source.threadId, source.threadId]); + }); + + it('keeps separate connector messages as separate deterministic ledger rows', async () => { + const first = appendSource('burst-1'); + const second = appendSource('burst-2'); + const policy = { sourceCategory: /** @type {const} */ ('review'), coalesceKey: 'pr-42' }; + await trigger.trigger(first.threadId, 'opus', first.userId, first.content, first.id, undefined, policy); + await trigger.trigger(second.threadId, 'opus', second.userId, second.content, second.id, undefined, policy); + const entries = queue.list(first.threadId, first.userId); + assert.equal(entries.length, 2); + assert.deepEqual( + entries.map((entry) => entry.payload.messageId), + [first.id, second.id], ); }); - it('rejects malformed stored github-wait carriers before direct execution', async () => { - const storedMessage = waitConnectorMessage('msg-wait-malformed-direct', 'wait:pr:owner/repo#7:g4:matched', 4); - storedMessage.source.meta.waitContinuationCarrier.ownerFence.generation = 0; - const trigger = createTrigger({ - messageStore: { getById: async (id) => (id === storedMessage.id ? storedMessage : null) }, + it('preserves sender, urgency, category and Skill metadata', async () => { + const source = appendSource('metadata', { + source: { connector: 'github', label: 'GitHub', sender: { id: 'github-app', name: 'GitHub App' } }, }); - - await assert.rejects( - () => - trigger.trigger( - storedMessage.threadId, - /** @type {any} */ ('opus'), - storedMessage.userId, - storedMessage.content, - storedMessage.id, - undefined, - { sourceCategory: 'review' }, - ), - /missing a valid continuation carrier/, - ); - assert.strictEqual(recordMock.creates.length, 0); - assert.strictEqual(routerMock.calls.length, 0); - }); - - it('passes a trusted GitHub delivery-decision carrier through the direct execution path', async () => { - const headSha = 'a'.repeat(40); - const storedMessage = { - id: 'msg-billing', - threadId: 'thread-billing', - userId: 'user-1', - catId: null, - content: 'GitHub CI failed before any source step ran.', - source: { connector: 'github-ci', label: 'GitHub CI/CD' }, - mentions: ['opus'], - timestamp: 1_785_600_000_000, - extra: { - memoryCue: { - deliveryDecision: { - v: 1, - producer: 'github_ci', - producerProvenance: 'server_github_ci', - repoFullName: 'zts212653/cat-cafe', - prNumber: 3276, - headSha, - phase: 'merge_gate', - gateOutcome: 'source_evidence_complete', - externalCondition: 'billing_spending_limit_zero_step', - candidateAction: 'merge', - occurredAt: 1_785_600_000_000, - }, - }, - }, - }; - const trigger = createTrigger({ - messageStore: { - getById: async (id) => (id === storedMessage.id ? storedMessage : undefined), - }, + await trigger.trigger(source.threadId, 'opus', source.userId, source.content, source.id, undefined, { + priority: 'urgent', + sourceCategory: 'ci', + suggestedSkill: 'merge-gate', }); - - await trigger.trigger( - storedMessage.threadId, - /** @type {any} */ ('opus'), - storedMessage.userId, - storedMessage.content, - storedMessage.id, - undefined, - { sourceCategory: 'ci' }, - ); - await waitForTrigger(); - - assert.deepStrictEqual(routerMock.calls[0].options.memoryCueOpportunitySeeds, [ - { - kind: 'delivery_decision', - producer: 'github_ci', - occurredAt: 1_785_600_000_000, - payload: { - repoFullName: 'zts212653/cat-cafe', - prNumber: 3276, - headSha, - phase: 'merge_gate', - gateOutcome: 'source_evidence_complete', - externalCondition: 'billing_spending_limit_zero_step', - candidateAction: 'merge', - sourceMessageId: storedMessage.id, - }, - }, - ]); - }); - - it('passes an authoritative A2A slot claim and preserves owner auth in its durable successor', async () => { - trackerMock.tracker.trackExternalSlot = () => false; - const trigger = createTrigger(); - - trigger.trigger( - 'thread-a2a-admission', - /** @type {any} */ ('opus'), - 'user-1', - 'Managed command follow-up', - 'msg-review', - undefined, - { sourceCategory: 'scheduled', ownerAuthProvenance: 'strict' }, - ); - await waitForTrigger(); - - const options = /** @type {any} */ (routerMock.calls[0].options); - assert.ok(options.invocationController, 'connector route must expose its tracker controller to A2A admission'); - assert.equal(typeof options.trackA2ASlot, 'function'); - assert.equal(typeof options.completeA2ASlots, 'function'); - assert.equal( - options.trackA2ASlot('thread-a2a-admission', 'codex', 'user-1', options.invocationController), - false, - 'another live owner must reject inline A2A execution', - ); - - const enqueue = options.deferA2AEnqueue({ - threadId: 'thread-a2a-admission', - userId: 'user-1', - content: '@codex review complete', - source: 'agent', - ownerAuthProvenance: 'unknown', - sourceCategory: 'a2a', - targetCats: ['codex'], - callerCatId: 'opus', - messageId: 'msg-review', - a2aTriggerMessageId: 'msg-review', - autoExecute: true, - priority: 'normal', - intent: 'execute', + const entry = queue.list(source.threadId, source.userId)[0]; + assert.equal(entry.priority, 'urgent'); + assert.equal(entry.sourceCategory, 'ci'); + assert.equal(entry.execution.suggestedSkill, 'merge-gate'); + assert.deepEqual(entry.from, { + kind: 'external', + connectorId: 'github', + sender: { id: 'github-app', name: 'GitHub App' }, }); - assert.equal(enqueue.outcome, 'enqueued'); - assert.equal(queue.list('thread-a2a-admission', 'user-1')[0].messageId, 'msg-review'); - assert.equal(queue.list('thread-a2a-admission', 'user-1')[0].ownerAuthProvenance, 'strict'); }); - it('releases a terminal dynamic A2A child with the connector route controller', async () => { - let routeController; - const dynamicRouter = { - async *routeExecution(_userId, _message, threadId, _messageId, _targetCats, _intent, options) { - routeController = options.invocationController; - assert.equal(options.trackA2ASlot(threadId, 'codex', 'user-1', routeController), true); - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - options.completeA2ASlots(threadId, ['codex'], routeController); - }, - async ackCollectedCursors() {}, + it('copies the exact wait continuation carrier into the Queue ledger', async () => { + const carrier = { + v: 1, + waitId: 'task-pr-7', + outcomeId: 'wait:pr:owner/repo#7:g3:matched', + ownerFence: { kind: 'containing_task', generation: 3 }, }; - const trigger = createTrigger({ router: dynamicRouter }); - - trigger.trigger('thread-connector-child', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-child'); - await waitForTrigger(); - - const dynamicCompletions = trackerMock.allCompletes.filter((call) => call.catIds.includes('codex')); - assert.equal(dynamicCompletions.length, 1); - assert.equal(dynamicCompletions[0].threadId, 'thread-connector-child'); - assert.equal(dynamicCompletions[0].controller, routeController); - }); - - it('projects a direct managed-command wake from its persisted hold carrier', async () => { - const trigger = createTrigger({ - messageStore: { - getById: async (messageId) => - messageId === 'msg-hold-complete' - ? { - source: { - connector: 'hold-ball', - meta: { taskId: 'task-hold-complete', threadId: 'thread-1', catId: 'opus', wakeWhen: true }, - }, - } - : null, + const source = appendSource('wait', { + source: { connector: 'github-wait', label: 'GitHub Wait', meta: { waitContinuationCarrier: carrier } }, + }); + waitTasks.set(carrier.waitId, { + id: carrier.waitId, + kind: 'pr_tracking', + threadId: source.threadId, + userId: source.userId, + ownerCatId: 'opus', + automationState: { + waitOutcome: { + v: 1, + outcomeId: carrier.outcomeId, + generation: 3, + subjectRef: 'pr:owner/repo#7', + ownerFence: carrier.ownerFence, + reason: 'matched', + at: 3, + delivery: 'pending', + actor: { kind: 'system' }, + }, + }, + }); + await trigger.trigger(source.threadId, 'opus', source.userId, source.content, source.id, undefined, { + sourceCategory: 'review', + }); + assert.deepEqual(queue.list(source.threadId, source.userId)[0].execution.waitContinuationCarrier, carrier); + }); + + it('rejects a replayed github-wait source after its exact outcome was delivered', async () => { + const carrier = { + v: 1, + waitId: 'task-pr-stale', + outcomeId: 'wait:pr:owner/repo#7:g4:matched', + ownerFence: { kind: 'containing_task', generation: 4 }, + }; + const source = appendSource('stale-wait', { + source: { connector: 'github-wait', label: 'GitHub Wait', meta: { waitContinuationCarrier: carrier } }, + }); + waitTasks.set(carrier.waitId, { + id: carrier.waitId, + kind: 'pr_tracking', + threadId: source.threadId, + userId: source.userId, + ownerCatId: 'opus', + automationState: { + waitOutcome: { + v: 1, + outcomeId: carrier.outcomeId, + generation: 4, + subjectRef: 'pr:owner/repo#7', + ownerFence: carrier.ownerFence, + reason: 'matched', + at: 4, + delivery: 'delivered', + actor: { kind: 'system' }, + }, }, }); - await trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'Managed command completed.', - 'msg-hold-complete', - undefined, - { sourceCategory: 'scheduled', ownerAuthProvenance: 'strict' }, + await assert.rejects( + trigger.trigger(source.threadId, 'opus', source.userId, source.content, source.id), + /continuation is stale/, ); - await waitForTrigger(); - - assert.equal(routerMock.calls[0].options?.ownerAuthProvenance, 'strict'); - assert.deepStrictEqual(routerMock.calls[0].options?.turnCustodyWake, { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'opus', - sourceMessageId: 'msg-hold-complete', - taskId: 'task-hold-complete', - }); + assert.equal(queue.list(source.threadId, source.userId).length, 0); + assert.deepEqual(drains, []); }); - it('projects a direct A2A wake from the exact trigger handoff', async () => { - const trigger = createTrigger({ - messageStore: { - getById: async (messageId) => (messageId === 'msg-a2a' ? { catId: 'codex-sol' } : null), - }, - }); - - await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'A2A handoff.', 'msg-a2a', undefined, { - sourceCategory: 'a2a', - }); - await waitForTrigger(); - - const expected = { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'opus', - handoff: { - sourceEventId: 'route:msg-a2a:opus', - messageId: 'msg-a2a', - fromCatId: 'codex-sol', - }, + it('rejects a github-wait carrier from an obsolete task generation', async () => { + const carrier = { + v: 1, + waitId: 'task-pr-regenerated', + outcomeId: 'wait:pr:owner/repo#7:g4:matched', + ownerFence: { kind: 'containing_task', generation: 4 }, }; - assert.deepStrictEqual(routerMock.calls[0].options?.turnCustodyWake, expected); - assert.deepStrictEqual(routerMock.calls[0].options?.turnCustodyWakeForCat('opus'), expected); - }); - - it('binds exact incremental prompt exposure through QueueProcessor', async () => { - const seenCalls = []; - const exposingRouter = /** @type {any} */ ({ - async *routeExecution(userId, _message, threadId, _userMessageId, targetCats, _intent, options) { - await options.onPromptMessagesExposed({ - threadId, - userId, - catId: targetCats[0], - invocationId: options.parentInvocationId, - messageIds: ['queued-work-period-message'], - }); - yield { type: 'done', catId: targetCats[0], content: '', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }); - const trigger = createTrigger({ - router: exposingRouter, - queueProcessor: { - isThreadBusy: () => false, - async markPromptMessagesSeen(input) { - seenCalls.push(input); + const source = appendSource('old-generation', { + source: { connector: 'github-wait', label: 'GitHub Wait', meta: { waitContinuationCarrier: carrier } }, + }); + waitTasks.set(carrier.waitId, { + id: carrier.waitId, + kind: 'pr_tracking', + threadId: source.threadId, + userId: source.userId, + ownerCatId: 'opus', + automationState: { + waitOutcome: { + v: 1, + outcomeId: 'wait:pr:owner/repo#7:g5:matched', + generation: 5, + subjectRef: 'pr:owner/repo#7', + ownerFence: { kind: 'containing_task', generation: 5 }, + reason: 'matched', + at: 5, + delivery: 'pending', + actor: { kind: 'system' }, }, - async onInvocationComplete() {}, }, }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - assert.deepStrictEqual(seenCalls, [ - { - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - invocationId: 'inv-1', - messageIds: ['queued-work-period-message'], - }, - ]); + await assert.rejects( + trigger.trigger(source.threadId, 'opus', source.userId, source.content, source.id), + /continuation is stale/, + ); + assert.equal(queue.list(source.threadId, source.userId).length, 0); + assert.deepEqual(drains, []); }); - it('F254 Phase E: does not deliver a superseded draft from the direct connector path', async () => { - const staleRouter = /** @type {any} */ ({ - async *routeExecution(_userId, _message, _threadId, _userMessageId, _targetCats, _intent, options) { - yield { type: 'text', catId: 'opus', content: 'stale draft', timestamp: Date.now() }; - if (options?.persistenceContext) { - options.persistenceContext.outputCommitDecisions = { - opus: { - kind: 'superseded_positive_stale', - closureId: 'closure-1', - requiredFrontierMessageId: 'msg-newer', - }, - }; - } - yield { type: 'done', catId: 'opus', content: '', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, + it('freezes a verified managed-command action generation on the Queue row', async () => { + const lease = /** @type {any} */ ({ + leaseId: 'lease-review-1', + generation: 3, + status: 'active', + tenantScope: 'user-1', + holderThreadId: 'thread-1', + holderCatIds: ['opus'], + dispatchId: 'dispatch-review-1', + terminalPredicate: { digest: 'predicate-digest' }, }); - const deliverCalls = /** @type {Array<{content: string, catId: string}>} */ ([]); - const streamEndCalls = /** @type {string[]} */ ([]); - const trigger = createTrigger({ - router: staleRouter, - outboundHook: { - async deliver(_threadId, content, catId) { - deliverCalls.push({ content, catId }); - }, - }, - streamingHook: { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd(_threadId, content) { - streamEndCalls.push(content); + const source = appendSource('managed', { + source: { + connector: 'hold-ball', + label: '持球通知', + meta: { + taskId: 'task-managed', + threadId: 'thread-1', + catId: 'opus', + wakeWhen: true, + actionLeaseRef: { leaseId: lease.leaseId, generation: lease.generation }, }, - async cleanupPlaceholders() {}, }, }); - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-f254-stale'); - await waitForTrigger(); - - assert.deepStrictEqual(deliverCalls, [], 'known-stale draft must not leave through connector delivery'); - assert.deepStrictEqual(streamEndCalls, [''], 'streaming final must not expose known-stale content'); - }); - - it('ADR-042 delivers a published-with-unseen answer through the direct connector path', async () => { - const publishedRouter = /** @type {any} */ ({ - async *routeExecution(_userId, _message, _threadId, _userMessageId, _targetCats, _intent, options) { - yield { type: 'text', catId: 'opus', content: 'published answer', timestamp: Date.now() }; - if (options?.persistenceContext) { - options.persistenceContext.outputCommitDecisions = { - opus: { - kind: 'published_with_unseen', - messageId: 'msg-published', - lineageId: 'msg-published', - offeredSupplementId: 'f254-supplement:msg-published:1', - requiredFrontierMessageId: 'msg-newer', - }, - }; - } - yield { type: 'done', catId: 'opus', content: '', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, + const managed = makeTrigger({ actionSuccessorLeaseStore: { get: async () => lease } }); + await managed.trigger(source.threadId, 'opus', source.userId, source.content, source.id, undefined, { + sourceCategory: 'scheduled', + forceQueue: true, + ownerAuthProvenance: 'strict', }); - const deliverCalls = []; - const streamEndCalls = []; - const trigger = createTrigger({ - router: publishedRouter, - outboundHook: { - async deliver(_threadId, content, catId) { - deliverCalls.push({ content, catId }); - }, - }, - streamingHook: { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd(_threadId, content) { - streamEndCalls.push(content); - }, - async cleanupPlaceholders() {}, - }, + assert.deepEqual(queue.list(source.threadId, source.userId)[0].execution.actionSuccessorFence, { + leaseId: lease.leaseId, + generation: lease.generation, + dispatchId: lease.dispatchId, + terminalPredicateDigest: 'predicate-digest', + invocationLineageRef: `dispatch:${lease.dispatchId}`, }); - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-f254-published'); - await waitForTrigger(); - - assert.deepStrictEqual(deliverCalls, [{ content: 'published answer', catId: 'opus' }]); - assert.deepStrictEqual(streamEndCalls, ['published answer']); }); - for (const scenario of [ - { - name: 'catching-up', - decision: { - kind: 'superseded_positive_stale', - closureId: 'closure-codex', - requiredFrontierMessageId: 'msg-newer', - }, - }, - { - name: 'blocked', - decision: { - kind: 'blocked_known_closure', - closureId: 'closure-codex', - reason: 'attempt_budget_exhausted', - }, - }, - ]) { - it(`F254 Phase E: direct connector projects a non-primary ${scenario.name} output under its owner cat`, async () => { - const projectionCalls = []; - const multiCatRouter = /** @type {any} */ ({ - async *routeExecution(_userId, _message, _threadId, _userMessageId, _targetCats, _intent, options) { - yield { type: 'text', catId: 'codex', content: 'withheld codex draft', timestamp: Date.now() }; - if (options?.persistenceContext) { - options.persistenceContext.outputCommitDecisions = { codex: scenario.decision }; - } - yield { type: 'done', catId: 'codex', content: '', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }); - const trigger = createTrigger({ - router: multiCatRouter, - streamingHook: { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd() {}, - async cleanupPlaceholders() {}, - async onClosureCatchingUp(_threadId, ownerCatId) { - projectionCalls.push({ kind: 'catching_up', ownerCatId }); - }, - async onClosureBlocked(_threadId, ownerCatId) { - projectionCalls.push({ kind: 'blocked', ownerCatId }); - }, - }, - }); - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', `msg-${scenario.name}`); - await waitForTrigger(); - - assert.deepStrictEqual(projectionCalls, [ - { - kind: scenario.decision.kind === 'blocked_known_closure' ? 'blocked' : 'catching_up', - ownerCatId: 'codex', + it('rejects a stale managed-command action generation before Queue admission', async () => { + const source = appendSource('stale-managed', { + source: { + connector: 'hold-ball', + label: '持球通知', + meta: { + taskId: 'task-managed', + threadId: 'thread-1', + catId: 'opus', + wakeWhen: true, + actionLeaseRef: { leaseId: 'lease-review-1', generation: 3 }, }, - ]); - }); - } - - it('F254 Phase E: direct connector catches enqueue a runnable closure successor', async () => { - const successorRouter = /** @type {any} */ ({ - async *routeExecution(_userId, _message, threadId, _userMessageId, targetCats, _intent, options) { - options?.freshnessReinvokeEnqueue?.({ - threadId, - userId: 'user-1', - content: '[Freshness Catch Closure closure-connector] replacement prompt', - source: 'agent', - ownerAuthProvenance: 'unknown', - sourceCategory: 'freshness', - targetCats, - callerCatId: targetCats[0], - autoExecute: true, - priority: 'normal', - intent: 'execute', - idempotencyKey: 'freshness-closure:closure-connector', - freshnessClosureId: 'closure-connector', - freshnessRequiredFrontierMessageId: 'msg-newer', - freshnessContext: { - sourceNoticeIds: [], - senders: ['user'], - reason: 'stream_output_stale', - }, - }); - yield { type: 'done', catId: targetCats[0], content: '', timestamp: Date.now() }; }, - async ackCollectedCursors() {}, - }); - const trigger = createTrigger({ router: successorRouter }); - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-f254-connector-catch'); - await waitForTrigger(); - - const queued = queue.list('thread-1', 'user-1'); - assert.strictEqual(queued.length, 1, 'positive-stale connector output must schedule its replacement'); - assert.strictEqual(queued[0].freshnessClosureId, 'closure-connector'); - assert.strictEqual(queued[0].freshnessRequiredFrontierMessageId, 'msg-newer'); - assert.strictEqual(queued[0].autoExecute, true); - assert.strictEqual('freshnessContext' in queued[0], false, 'ephemeral context must not enter queue truth'); - }); - - it('F222 P1: connector direct routeExecution passes frustrationAutoIssueEligible=false', async () => { - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-f222'); - await waitForTrigger(); - - assert.strictEqual(routerMock.calls.length, 1); - assert.strictEqual( - routerMock.calls[0].options?.frustrationAutoIssueEligible, - false, - 'connector direct execution must suppress frustration detection', - ); - assert.strictEqual(routerMock.calls[0].options?.humanDispositionInvocationOrigin, 'connector'); - }); - - it('broadcasts agent messages to WebSocket room', async () => { - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - // Should expose the durable child lifecycle boundary before text + done. - const agentBroadcasts = socketMock.broadcasts.filter((b) => b.threadId === 'thread-1'); - assert.ok(agentBroadcasts.length >= 3, `Expected at least 3 broadcasts, got ${agentBroadcasts.length}`); - assert.strictEqual(agentBroadcasts[0].msg.type, 'system_info'); - assert.strictEqual(JSON.parse(agentBroadcasts[0].msg.content).type, 'invocation_created'); - assert.strictEqual(agentBroadcasts[1].msg.type, 'text'); - assert.strictEqual(agentBroadcasts[2].msg.type, 'done'); - - // Should have broadcast intent_mode - const intentBroadcast = socketMock.roomBroadcasts.find((b) => b.event === 'intent_mode'); - assert.ok(intentBroadcast, 'Should broadcast intent_mode'); - assert.strictEqual(intentBroadcast.data.mode, 'execute'); - }); - - it('updates InvocationRecord through lifecycle: queued → running → succeeded', async () => { - const trigger = createTrigger(); - await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - // Check update sequence - const updates = recordMock.updates; - assert.ok(updates.length >= 2, `Expected at least 2 updates, got ${updates.length}`); - - // First update: atomically claim queued and bind the triggering message. - assert.strictEqual(updates[0].data.userMessageId, 'msg-1'); - assert.strictEqual(updates[0].data.status, 'running'); - assert.strictEqual(updates[0].data.expectedStatus, 'queued'); - - // Last update: status succeeded - const lastUpdate = updates[updates.length - 1]; - assert.strictEqual(lastUpdate.data.status, 'succeeded'); - assert.deepStrictEqual(lastUpdate.data.successfulCatIds, ['opus']); - }); - - it('acks cursor boundaries on success', async () => { - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(routerMock.ackCalls.length, 1); - assert.strictEqual(routerMock.ackCalls[0].userId, 'user-1'); - assert.strictEqual(routerMock.ackCalls[0].threadId, 'thread-1'); - }); - - it('starts and completes InvocationTracker', async () => { - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(trackerMock.tryStartThreadCalls.length, 1); - assert.strictEqual(trackerMock.completes.length, 1); - assert.strictEqual(trackerMock.completes[0].threadId, 'thread-1'); - }); - - it('does not rerun duplicate invocations already known to have succeeded', async () => { - recordMock.setDuplicate(); - const trigger = createTrigger(); - const outcome = await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(outcome, 'dispatched'); - // Should not call routeExecution - assert.strictEqual(routerMock.calls.length, 0); - // Should not start tracker - assert.strictEqual(trackerMock.starts.length, 0); - }); - - for (const status of ['queued', 'failed']) { - it(`replays the same durable InvocationRecord when a duplicate is ${status}`, async () => { - recordMock.setDuplicate({ status, ...(status === 'failed' ? { error: 'process_restart' } : {}) }); - const trigger = createTrigger(); - - assert.equal( - await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - 'dispatched', - ); - await waitForTrigger(); - - assert.equal(routerMock.calls.length, 1, `${status} carrier must be redriven`); - const claim = recordMock.updates.find( - (update) => update.id === 'inv-existing' && update.data.status === 'running', - ); - assert.deepEqual(claim?.data, { - userMessageId: 'msg-1', - status: 'running', - expectedStatus: status, - ...(status === 'failed' ? { error: '' } : {}), - }); }); - } - - it('allows only one worker to recover the same queued duplicate', async () => { - const sharedRecordMock = sharedQueuedRaceRecordStore(); - const trackerA = mockInvocationTracker(); - const trackerB = mockInvocationTracker(); - const triggerA = createTrigger({ - invocationRecordStore: sharedRecordMock.store, - invocationTracker: trackerA.tracker, + const stale = makeTrigger({ + actionSuccessorLeaseStore: { get: async () => ({ leaseId: 'lease-review-1', generation: 4 }) }, }); - const triggerB = createTrigger({ - invocationRecordStore: sharedRecordMock.store, - invocationTracker: trackerB.tracker, - }); - - const outcomes = await Promise.allSettled([ - triggerA.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - triggerB.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - ]); - await waitForTrigger(); - - assert.ok( - outcomes.every( - (outcome) => - (outcome.status === 'fulfilled' && outcome.value === 'dispatched') || - (outcome.status === 'rejected' && /running without execution-start receipt/.test(String(outcome.reason))), - ), - 'the CAS loser may acknowledge only after observing the winner start; otherwise it must fail closed', + await assert.rejects( + stale.trigger(source.threadId, 'opus', source.userId, source.content, source.id), + /generation no longer matches/, ); - assert.ok(outcomes.some((outcome) => outcome.status === 'fulfilled' && outcome.value === 'dispatched')); - assert.strictEqual(routerMock.calls.length, 1, 'only the worker that atomically claims queued may execute'); - assert.strictEqual(sharedRecordMock.getRecord().status, 'succeeded'); - assert.strictEqual(trackerA.completes.length, 1); - assert.strictEqual(trackerB.completes.length, 1); - }); - - it('rejects a running duplicate until the claimed executor has a durable start receipt', async () => { - const sharedStore = new InvocationRecordStore(); - const trackerA = mockInvocationTracker(); - const trackerB = mockInvocationTracker(); - let routeCalls = 0; - /** @type {() => void} */ - let signalEntered; - const entered = new Promise((resolve) => { - signalEntered = resolve; - }); - /** @type {() => void} */ - let releaseRoute; - const routeReleased = new Promise((resolve) => { - releaseRoute = resolve; - }); - const blockingRouter = /** @type {any} */ ({ - async *routeExecution(_userId, _message, _threadId, _messageId, targetCats) { - routeCalls++; - signalEntered(); - await routeReleased; - yield { type: 'text', catId: targetCats[0], content: 'started', timestamp: Date.now() }; - yield { type: 'done', catId: targetCats[0], content: '', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }); - const triggerA = createTrigger({ - router: blockingRouter, - invocationRecordStore: sharedStore, - invocationTracker: trackerA.tracker, - }); - const triggerB = createTrigger({ - router: blockingRouter, - invocationRecordStore: sharedStore, - invocationTracker: trackerB.tracker, - }); - - const firstOutcome = triggerA.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await entered; - - const claimed = sharedStore.getByIdempotencyKey('thread-1', 'user-1', 'connector-msg-1'); - assert.strictEqual(claimed?.status, 'running'); - assert.strictEqual(claimed?.executionStartedAt, undefined); - - try { - await assert.rejects( - triggerB.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /running without execution-start receipt/, - ); - assert.strictEqual(routeCalls, 1, 'duplicate worker must not execute before start receipt exists'); - } finally { - releaseRoute(); - } - - assert.strictEqual(await firstOutcome, 'dispatched'); - await waitForTrigger(); - const completed = sharedStore.getByIdempotencyKey('thread-1', 'user-1', 'connector-msg-1'); - assert.strictEqual(completed?.status, 'succeeded'); - assert.strictEqual(typeof completed?.executionStartedAt, 'number'); - assert.strictEqual(routeCalls, 1); + assert.equal(queue.list(source.threadId, source.userId).length, 0); }); - it('does not acknowledge a failed replay from the previous attempt receipt', async () => { - const sharedStore = new InvocationRecordStore(); - const { invocationId } = await sharedStore.create({ - threadId: 'thread-1', - userId: 'user-1', - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: 'connector-msg-1', - actionLeaseCarrier: { kind: 'none' }, - }); - sharedStore.update(invocationId, { status: 'running', expectedStatus: 'queued' }); - sharedStore.update(invocationId, { - executionStartedAt: Date.now() - 1_000, - expectedStatus: 'running', - }); - sharedStore.update(invocationId, { - status: 'failed', - error: 'process_restart', - expectedStatus: 'running', - }); - - const trackerA = mockInvocationTracker(); - const trackerB = mockInvocationTracker(); - let routeCalls = 0; - /** @type {() => void} */ - let signalEntered; - const entered = new Promise((resolve) => { - signalEntered = resolve; - }); - /** @type {() => void} */ - let releaseChildCreation; - const childCreationReleased = new Promise((resolve) => { - releaseChildCreation = resolve; - }); - const blockingRouter = /** @type {any} */ ({ - async *routeExecution(_userId, _message, _threadId, _messageId, targetCats, _intent, options) { - routeCalls++; - signalEntered(); - await childCreationReleased; - yield { - type: 'system_info', - catId: targetCats[0], - content: JSON.stringify({ - type: 'invocation_created', - invocationId: 'child-retry-1', - parentInvocationId: options.parentInvocationId, - executionKind: 'foreground', - startedAt: Date.now(), - }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: targetCats[0], content: '', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }); - const triggerA = createTrigger({ - router: blockingRouter, - routerIncludesInvocationLifecycle: true, - invocationRecordStore: sharedStore, - invocationTracker: trackerA.tracker, - }); - const triggerB = createTrigger({ - router: blockingRouter, - routerIncludesInvocationLifecycle: true, - invocationRecordStore: sharedStore, - invocationTracker: trackerB.tracker, - }); - - const firstOutcome = triggerA.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await entered; - - try { - const claimed = sharedStore.get(invocationId); - assert.strictEqual(claimed?.status, 'running'); - assert.strictEqual( - claimed?.executionStartedAt, - undefined, - 'a new failed-record attempt must not inherit the previous attempt receipt', - ); - await assert.rejects( - triggerB.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /running without execution-start receipt/, - ); - assert.strictEqual(routeCalls, 1, 'the duplicate must not execute or acknowledge before the new receipt'); - } finally { - releaseChildCreation(); - await firstOutcome; - await waitForTrigger(); - } - - const completed = sharedStore.get(invocationId); - assert.strictEqual(completed?.status, 'succeeded'); - assert.strictEqual(typeof completed?.executionStartedAt, 'number'); - assert.strictEqual(routeCalls, 1); + it('fails closed when source owner or thread does not match the trigger', async () => { + const source = appendSource('mismatch'); + await assert.rejects( + trigger.trigger('thread-other', 'opus', source.userId, source.content, source.id), + /does not match/, + ); + assert.equal(queue.list('thread-other', source.userId).length, 0); }); - for (const [label, buildPreInvocationMessages] of [ - [ - 'incremental degradation', - (_parentInvocationId, catId) => [ - { type: 'system_info', catId, content: 'incremental context was truncated', timestamp: Date.now() }, - ], - ], - [ - 'context briefing', - (_parentInvocationId, catId) => [ - { - type: 'system_info', - catId, - content: JSON.stringify({ type: 'context_briefing', messageId: 'briefing-1' }), - timestamp: Date.now(), - }, - ], - ], - [ - 'malformed or foreign invocation_created', - (parentInvocationId, catId) => [ - { - type: 'system_info', - catId, - content: JSON.stringify({ type: 'invocation_created', invocationId: '', parentInvocationId }), - timestamp: Date.now(), - }, - { - type: 'system_info', - catId, - content: JSON.stringify({ - type: 'invocation_created', - invocationId: 'child-from-another-parent', - parentInvocationId: 'another-parent', - startedAt: Date.now(), - }), - timestamp: Date.now(), - }, - ], - ], - ]) { - it(`does not acknowledge ${label} as durable child execution`, async () => { - const sharedStore = new InvocationRecordStore(); - const trackerA = mockInvocationTracker(); - const trackerB = mockInvocationTracker(); - let routeCalls = 0; - /** @type {() => void} */ - let signalPreInvocationConsumed; - const preInvocationConsumed = new Promise((resolve) => { - signalPreInvocationConsumed = resolve; - }); - /** @type {() => void} */ - let releaseChildCreation; - const childCreationReleased = new Promise((resolve) => { - releaseChildCreation = resolve; - }); - const blockingRouter = /** @type {any} */ ({ - async *routeExecution(_userId, _message, _threadId, _messageId, targetCats, _intent, options) { - routeCalls++; - for (const preInvocationMessage of buildPreInvocationMessages(options.parentInvocationId, targetCats[0])) { - yield preInvocationMessage; - } - signalPreInvocationConsumed(); - await childCreationReleased; - yield { - type: 'system_info', - catId: targetCats[0], - content: JSON.stringify({ - type: 'invocation_created', - invocationId: 'child-invocation-1', - parentInvocationId: options.parentInvocationId, - executionKind: 'foreground', - startedAt: Date.now(), - }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: targetCats[0], content: '', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }); - const triggerA = createTrigger({ - router: blockingRouter, - routerIncludesInvocationLifecycle: true, - invocationRecordStore: sharedStore, - invocationTracker: trackerA.tracker, - }); - const triggerB = createTrigger({ - router: blockingRouter, - routerIncludesInvocationLifecycle: true, - invocationRecordStore: sharedStore, - invocationTracker: trackerB.tracker, - }); - - const firstOutcome = triggerA.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await preInvocationConsumed; - - try { - const claimed = sharedStore.getByIdempotencyKey('thread-1', 'user-1', 'connector-msg-1'); - assert.strictEqual( - claimed?.executionStartedAt, - undefined, - `${label} must not create an execution-start receipt`, - ); - await assert.rejects( - triggerB.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /running without execution-start receipt/, - ); - assert.strictEqual(routeCalls, 1, 'duplicate worker must not execute before invocation_created'); - } finally { - releaseChildCreation(); - } - - assert.strictEqual(await firstOutcome, 'dispatched'); - await waitForTrigger(); - const completed = sharedStore.getByIdempotencyKey('thread-1', 'user-1', 'connector-msg-1'); - assert.strictEqual(completed?.status, 'succeeded'); - assert.strictEqual(typeof completed?.executionStartedAt, 'number'); - assert.strictEqual(routeCalls, 1); - }); - } - - for (const statusAfterConflict of ['failed', 'canceled', 'missing']) { - it(`fails closed when a queued claim loser re-reads ${statusAfterConflict}`, async () => { - let reads = 0; - const queuedRecord = { - id: 'inv-existing', - threadId: 'thread-1', - userId: 'user-1', - userMessageId: 'msg-1', - targetCats: ['opus'], - intent: 'execute', - status: 'queued', - idempotencyKey: 'connector-msg-1', - actionLeaseCarrier: { kind: 'none' }, - createdAt: Date.now() - 1_000, - updatedAt: Date.now() - 1_000, - }; - const conflictingStore = /** @type {any} */ ({ - async create() { - return { outcome: 'duplicate', invocationId: queuedRecord.id }; - }, - async get() { - reads++; - if (reads === 1) return { ...queuedRecord }; - return statusAfterConflict === 'missing' ? null : { ...queuedRecord, status: statusAfterConflict }; - }, - async update() { - return null; + it('keeps committed Queue work when drain scheduling fails', async () => { + const source = appendSource('drain-failure'); + const failing = makeTrigger({ + queueProcessor: { + async requestDrain() { + throw new Error('drain unavailable'); }, - }); - const trigger = createTrigger({ invocationRecordStore: conflictingStore }); - - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - new RegExp(`status: ${statusAfterConflict}`), - ); - - assert.strictEqual(routerMock.calls.length, 0); - assert.strictEqual(trackerMock.completes.length, 1); - }); - } - - it('acknowledges a running duplicate without starting a second execution', async () => { - recordMock.setDuplicate('running'); - const trigger = createTrigger(); - - const outcome = await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(outcome, 'dispatched'); - assert.strictEqual(routerMock.calls.length, 0); - assert.strictEqual(trackerMock.completes.length, 1); - }); - - it('rejects a canceled duplicate instead of claiming a fresh dispatch', async () => { - recordMock.setDuplicate('canceled'); - const trigger = createTrigger(); - - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /existing invocation inv-existing is canceled/, - ); - - assert.strictEqual(routerMock.calls.length, 0); - assert.strictEqual(trackerMock.completes.length, 1); - }); - - it('rejects a duplicate whose durable record cannot be loaded', async () => { - recordMock.setMissingDuplicate(); - const trigger = createTrigger(); - - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /existing invocation inv-existing is missing/, - ); - - assert.strictEqual(routerMock.calls.length, 0); - assert.strictEqual(trackerMock.completes.length, 1); - }); - - it('cancels when thread is being deleted (aborted signal)', async () => { - trackerMock.setAborted(true); - const trigger = createTrigger(); - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /canceled before execution started/, - ); - await waitForTrigger(); - - // Should not call routeExecution - assert.strictEqual(routerMock.calls.length, 0); - - // Should update status to canceled - const cancelUpdate = recordMock.updates.find((u) => u.data.status === 'canceled'); - assert.ok(cancelUpdate, 'Should set status to canceled'); - }); - - it('late-binds cancel-all suppression when direct admission is aborted before invocation ID binding', async () => { - const controller = new AbortController(); - trackerMock.tracker.tryStartThread = () => controller; - const suppressCalls = /** @type {Array<{threadId: string, catId: string, executionIds: readonly string[]}>} */ ([]); - const bindCalls = /** @type {Array<{threadId: string, catId: string, executionId: string}>} */ ([]); - const completionCalls = /** @type {Array<{status: string, invocationId: string | undefined}>} */ ([]); - const mockQueueProcessor = /** @type {any} */ ({ - isAutoResumeSuppressed: () => false, - isThreadBusy: () => false, - suppressAutoResume(threadId, catId, executionIds = []) { - suppressCalls.push({ threadId, catId, executionIds }); - }, - bindAutoResumeSuppressionExecution(threadId, catId, executionId) { - bindCalls.push({ threadId, catId, executionId }); - }, - async onInvocationComplete(_threadId, _catId, status, invocationId) { - completionCalls.push({ status, invocationId }); - }, - }); - recordMock.setBeforeCreate(() => { - controller.abort('cancel_all'); - // cancelAll/force-reset can only arm a slot fence here because the - // durable InvocationRecord identity does not exist until create returns. - mockQueueProcessor.suppressAutoResume('thread-1', 'opus'); - }); - - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-pre-bind-cancel'), - /canceled before execution started/, - ); - await waitForTrigger(); - - assert.deepStrictEqual( - suppressCalls, - [{ threadId: 'thread-1', catId: 'opus', executionIds: [] }], - 'the reset action must arm one anonymous pre-bind fence', - ); - assert.deepStrictEqual( - bindCalls, - [{ threadId: 'thread-1', catId: 'opus', executionId: 'inv-1' }], - 'durable admission must bind the canceled direct invocation without renewing the fence', - ); - assert.deepStrictEqual(completionCalls, [{ status: 'canceled', invocationId: 'inv-1' }]); - assert.strictEqual(routerMock.calls.length, 0, 'pre-bind cancel-all must never reach routeExecution'); - }); - - it('rejects when routeExecution fails before durable execution starts', async () => { - routerMock = mockRouter({ throwError: new Error('CLI crashed') }); - const trigger = createTrigger({ router: routerMock.router }); - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /CLI crashed/, - ); - await waitForTrigger(); - - // Should update status to failed - const failUpdate = recordMock.updates.find((u) => u.data.status === 'failed'); - assert.ok(failUpdate, 'Should set status to failed'); - assert.ok(failUpdate.data.error.includes('CLI crashed')); - - // Should broadcast error to WebSocket - const errorBroadcast = socketMock.broadcasts.find((b) => b.msg.type === 'error'); - assert.ok(errorBroadcast, 'Should broadcast error'); - - // Should still complete tracker (finally block) - assert.strictEqual(trackerMock.completes.length, 1); - }); - - it('marks failed on persistence failure', async () => { - routerMock = mockRouter({ persistenceFail: true }); - const trigger = createTrigger({ router: routerMock.router }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - const failUpdate = recordMock.updates.find((u) => u.data.status === 'failed'); - assert.ok(failUpdate, 'Should set status to failed on persistence failure'); - assert.ok(failUpdate.data.error.includes('persistence failed')); - - // Should NOT ack cursors - assert.strictEqual(routerMock.ackCalls.length, 0); - }); - - // ── 砚砚 R1 P1: pre-try errors must not leak unhandledRejection ── - - it('R1-P1 regression: create() throws → no unhandledRejection, no tracker leak', async () => { - // Override create to throw - recordMock.store.create = async () => { - throw new Error('create boom'); - }; - - const trigger = createTrigger(); - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /create boom/, - ); - await waitForTrigger(); - - // start() not called (F185: tryStartThread used instead) - assert.strictEqual(trackerMock.starts.length, 0); - // F185: tryStartThread WAS called (controller pre-acquired in trigger) - assert.strictEqual(trackerMock.tryStartThreadCalls.length, 1, 'controller was pre-acquired'); - // R1-P1 FIX: controller must be released even when create() throws - assert.strictEqual(trackerMock.completes.length, 1, 'pre-acquired controller must be released on create failure'); - // Should NOT call routeExecution - assert.strictEqual(routerMock.calls.length, 0); - // No unhandledRejection = test process survives - }); - - it('PR #1181 P1: dispatched is returned only after the invocation record is durable', async () => { - recordMock.store.create = async () => { - throw new Error('record store unavailable'); - }; - - const trigger = createTrigger(); - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-durable-admission'), - /record store unavailable/, - ); - - assert.strictEqual(routerMock.calls.length, 0, 'execution must not start without a durable invocation record'); - assert.strictEqual(trackerMock.completes.length, 1, 'failed admission must release the pre-acquired controller'); - }); - - it('R1-P1 regression: durable claim throws → tracker completes without execution', async () => { - let updateCallCount = 0; - recordMock.store.update = async (id, data) => { - updateCallCount++; - // First update is the queued -> running claim with userMessageId binding. - if (updateCallCount === 1 && data.userMessageId) { - throw new Error('claim boom'); - } - recordMock.updates.push({ id, data }); - }; - - const trigger = createTrigger(); - await assert.rejects( - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), - /claim boom/, - ); - await waitForTrigger(); - - // Tracker must have been started AND completed (no leak) - assert.strictEqual(trackerMock.tryStartThreadCalls.length, 1, 'tracker should start via tryStartThread'); - assert.strictEqual(trackerMock.completes.length, 1, 'tracker must complete even on backfill error'); - - // Should NOT call routeExecution (error happened before) - assert.strictEqual(routerMock.calls.length, 0); - }); - - // ── F39 Phase C: Queue mode tests ── - - // ── ISSUE-9: Multi-cat A2A outbound delivery ── - - it('delivers outbound messages per-cat for multi-cat A2A responses (ISSUE-9)', async () => { - // Mock router that yields messages from TWO cats (A2A chain: opus → codex) - const multiCatRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - yield { type: 'text', catId: 'opus', content: 'Hello from opus!', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 100, outputTokens: 50 } }, - }; - yield { type: 'text', catId: 'codex', content: 'Hello from codex!', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'codex', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 80, outputTokens: 40 } }, - }; }, - async ackCollectedCursors() {}, - }); - - const deliverCalls = /** @type {Array<{threadId: string, content: string, catId: string}>} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }, - }; - - const trigger = createTrigger({ router: multiCatRouter, outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Hello', 'msg-1'); - await waitForTrigger(); - - // Should deliver TWICE — one per cat, not merged into one - assert.strictEqual(deliverCalls.length, 2, 'Should deliver once per cat in A2A chain'); - assert.strictEqual(deliverCalls[0].catId, 'opus'); - assert.strictEqual(deliverCalls[0].content, 'Hello from opus!'); - assert.strictEqual(deliverCalls[1].catId, 'codex'); - assert.strictEqual(deliverCalls[1].content, 'Hello from codex!'); - }); - - it('BUG-5: multi-turn delivers per-turn for all connectors (no merge needed)', async () => { - const multiCatRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - yield { type: 'text', catId: 'opus', content: 'Hello from opus!', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 100, outputTokens: 50 } }, - }; - yield { type: 'text', catId: 'codex', content: 'Hello from codex!', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'codex', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 80, outputTokens: 40 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - const deliverCalls = /** @type {Array<{threadId: string, content: string, catId: string}>} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }, - }; - - const trigger = createTrigger({ router: multiCatRouter, outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Hello', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(deliverCalls.length, 2, 'Multi-turn delivers per-turn'); - assert.strictEqual(deliverCalls[0].catId, 'opus'); - assert.strictEqual(deliverCalls[0].content, 'Hello from opus!'); - assert.strictEqual(deliverCalls[1].catId, 'codex'); - assert.strictEqual(deliverCalls[1].content, 'Hello from codex!'); - }); - - it('single-cat outbound delivery still works with per-cat logic', async () => { - const deliverCalls = /** @type {Array<{threadId: string, content: string, catId: string}>} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }, - }; - - const trigger = createTrigger({ outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(deliverCalls.length, 1, 'Single cat should deliver once'); - assert.strictEqual(deliverCalls[0].catId, 'opus'); - assert.strictEqual(deliverCalls[0].content, 'Review noted. Working on it.'); - }); - - it('R1-P1: delivers richBlocks-only reply (no text) via outbound hook', async () => { - // Router yields only richBlocks with no text content - const richOnlyRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - // Simulate richBlocks being stashed (no text message, just done with richBlocks) - if (options?.persistenceContext) { - options.persistenceContext.richBlocks = [{ type: 'code', language: 'js', content: 'console.log("hi")' }]; - } - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 10, outputTokens: 5 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - const deliverCalls = /** @type {Array<{threadId: string, content: string, catId: string, richBlocks: any}>} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId, richBlocks) => { - deliverCalls.push({ threadId, content, catId, richBlocks }); - }, - }; - - const trigger = createTrigger({ router: richOnlyRouter, outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Show code', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(deliverCalls.length, 1, 'Should deliver richBlocks-only reply'); - assert.ok(deliverCalls[0].richBlocks?.length > 0, 'Should include richBlocks'); - }); - - it('R1-P2: multi-cat outbound delivery preserves order (sequential await)', async () => { - const multiCatRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - yield { type: 'text', catId: 'opus', content: 'First', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 1, outputTokens: 1 } }, - }; - yield { type: 'text', catId: 'codex', content: 'Second', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'codex', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 1, outputTokens: 1 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - // Simulate slow network: first deliver takes longer than second - const deliverOrder = /** @type {string[]} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId) => { - if (catId === 'opus') { - await new Promise((r) => setTimeout(r, 20)); // slow - } - deliverOrder.push(catId); - }, - }; - - const trigger = createTrigger({ router: multiCatRouter, outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Hello', 'msg-1'); - await new Promise((r) => setTimeout(r, 200)); // wait for async - - assert.deepStrictEqual(deliverOrder, ['opus', 'codex'], 'Should deliver in cat order, not race order'); - }); - - it('cloud-P1: delivers fallback message for silent invocation (#873)', async () => { - // Router yields only 'done' — no text, no richBlocks. - // #873 fix: silent path now delivers a fallback so IM users aren't left hanging. - const silentRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 0, outputTokens: 0 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - const deliverCalls = /** @type {any[]} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }, - }; - - const trigger = createTrigger({ router: silentRouter, outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(deliverCalls.length, 1, 'Should deliver fallback for silent invocation'); - assert.strictEqual(deliverCalls[0].catId, 'opus'); - assert.ok(deliverCalls[0].content, 'Fallback content should be non-empty'); - }); - - it('cloud-P1: hanging deliver does not block tracker cleanup', async () => { - const hangingRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - yield { type: 'text', catId: 'opus', content: 'Hello', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 1, outputTokens: 1 } }, - }; - yield { type: 'text', catId: 'codex', content: 'World', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'codex', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 1, outputTokens: 1 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - const outboundHook = { - deliver: async () => { - // Simulate hanging forever - await new Promise(() => {}); - }, - }; - - // Use short timeout (100ms) to keep test fast - const trigger = createTrigger({ router: hangingRouter, outboundHook, deliverTimeoutMs: 100 }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - // Wait longer than timeout (100ms * 2 cats + buffer) - await new Promise((r) => setTimeout(r, 500)); - - // Tracker must have completed despite hanging deliver - assert.strictEqual(trackerMock.completes.length, 1, 'Tracker must complete even if deliver hangs'); - }); - - it('cloud-R4-P2: late-success delivery triggers deferred placeholder cleanup', async () => { - /** @type {() => void} */ - let resolveDeliver = () => {}; - const deliverPromise = new Promise((r) => { - resolveDeliver = r; - }); - const outboundHook = { - deliver: async () => { - await deliverPromise; - }, - }; - let cleanupCalled = false; - const streamingHook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd() {}, - cleanupPlaceholders: async () => { - cleanupCalled = true; - }, - }; - - const trigger = createTrigger({ - outboundHook, - streamingHook, - deliverTimeoutMs: 50, - }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - - await new Promise((r) => setTimeout(r, 200)); - assert.strictEqual(cleanupCalled, false, 'cleanup must NOT run immediately after timeout'); - - resolveDeliver(); - await new Promise((r) => setTimeout(r, 100)); - assert.strictEqual(cleanupCalled, true, 'cleanup must run after late-success delivery'); - }); - - it('cloud-R5-P1: late-failure delivery must NOT trigger cleanup (preserve placeholder as fallback)', async () => { - // R5-P1 design: on delivery failure, placeholder is preserved so next retry/invocation can - // consume it. Calling cleanupPlaceholders on failure would incorrectly finalize the card. - /** @type {(err: Error) => void} */ - let rejectDeliver = () => {}; - const deliverPromise = new Promise((_, rej) => { - rejectDeliver = rej; - }); - const outboundHook = { - deliver: async () => { - await deliverPromise; - }, - }; - let cleanupCalled = false; - const streamingHook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd() {}, - cleanupPlaceholders: async () => { - cleanupCalled = true; - }, - }; - - const trigger = createTrigger({ - outboundHook, - streamingHook, - deliverTimeoutMs: 50, - }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - - await new Promise((r) => setTimeout(r, 200)); - assert.strictEqual(cleanupCalled, false, 'cleanup must NOT run before inflight promises settle'); - - rejectDeliver(new Error('connector down')); - await new Promise((r) => setTimeout(r, 100)); - assert.strictEqual( - cleanupCalled, - false, - 'cleanup must NOT run after hard delivery failure (R5-P1: preserve placeholder)', - ); - }); - - it('R7: silent fallback late-success triggers deferred placeholder cleanup', async () => { - // Silent invocation (only done, no text) → deliver times out → deliver later succeeds - // → cleanupPlaceholders must be called on late-success (R7 fix). - const silentRouter = /** @type {any} */ ({ - async *routeExecution(_u, _m, _t, _mid, targetCats, _i, _o) { - yield { - type: 'done', - catId: targetCats[0], - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 0, outputTokens: 0 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - /** @type {() => void} */ - let resolveDeliver = () => {}; - const deliverPromise = new Promise((r) => { - resolveDeliver = r; - }); - const outboundHook = { - deliver: async () => { - await deliverPromise; - }, - }; - let cleanupCalled = false; - const streamingHook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd() {}, - cleanupPlaceholders: async () => { - cleanupCalled = true; - }, - }; - - const trigger = createTrigger({ - router: silentRouter, - outboundHook, - streamingHook, - deliverTimeoutMs: 50, - }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - - // Wait for timeout to fire - await new Promise((r) => setTimeout(r, 200)); - assert.strictEqual(cleanupCalled, false, 'cleanup must NOT run immediately after silent timeout'); - - // Late-success: deliver finally resolves - resolveDeliver(); - await new Promise((r) => setTimeout(r, 100)); - assert.strictEqual(cleanupCalled, true, 'cleanup must run after silent late-success delivery (R7)'); - }); - - it('R7: silent fallback late-failure must NOT trigger cleanup (preserve placeholder)', async () => { - // Silent invocation → deliver times out → deliver later rejects - // → cleanupPlaceholders must NOT be called (thinking card stays as fallback UX). - const silentRouter = /** @type {any} */ ({ - async *routeExecution(_u, _m, _t, _mid, targetCats, _i, _o) { - yield { - type: 'done', - catId: targetCats[0], - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 0, outputTokens: 0 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - /** @type {(err: Error) => void} */ - let rejectDeliver = () => {}; - const deliverPromise = new Promise((_, rej) => { - rejectDeliver = rej; - }); - const outboundHook = { - deliver: async () => { - await deliverPromise; - }, - }; - let cleanupCalled = false; - const streamingHook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd() {}, - cleanupPlaceholders: async () => { - cleanupCalled = true; - }, - }; - - const trigger = createTrigger({ - router: silentRouter, - outboundHook, - streamingHook, - deliverTimeoutMs: 50, - }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - - await new Promise((r) => setTimeout(r, 200)); - assert.strictEqual(cleanupCalled, false, 'cleanup must NOT run after silent timeout'); - - // Late-failure: deliver rejects - rejectDeliver(new Error('connector down')); - await new Promise((r) => setTimeout(r, 100)); - assert.strictEqual( - cleanupCalled, - false, - 'cleanup must NOT run after silent hard failure (R7: preserve placeholder)', - ); - }); - - it('cloud-P1-4: A→B→A ping-pong delivers 3 separate turns (not merged by catId)', async () => { - const pingPongRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - // Turn 1: opus responds - yield { type: 'text', catId: 'opus', content: 'Opus turn 1', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 10, outputTokens: 5 } }, - }; - // Turn 2: codex responds (A2A) - yield { type: 'text', catId: 'codex', content: 'Codex review', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'codex', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 20, outputTokens: 10 } }, - }; - // Turn 3: opus responds again (A2A back) - yield { type: 'text', catId: 'opus', content: 'Opus turn 3', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 15, outputTokens: 8 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - const deliverCalls = /** @type {Array<{catId: string, content: string}>} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId) => { - deliverCalls.push({ catId, content }); - }, - }; - - const trigger = createTrigger({ router: pingPongRouter, outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - // Should deliver 3 separate turns, not 2 (with opus merged) - assert.strictEqual(deliverCalls.length, 3, 'Should deliver 3 turns for A→B→A'); - assert.strictEqual(deliverCalls[0].catId, 'opus'); - assert.strictEqual(deliverCalls[0].content, 'Opus turn 1'); - assert.strictEqual(deliverCalls[1].catId, 'codex'); - assert.strictEqual(deliverCalls[1].content, 'Codex review'); - assert.strictEqual(deliverCalls[2].catId, 'opus'); - assert.strictEqual(deliverCalls[2].content, 'Opus turn 3'); - }); - - it('cloud-P1-5: richBlocks-only re-entry after silent cat gets own turn', async () => { - // A→B(silent)→A(richBlocks-only): opus text+done, codex silent done, opus richBlocks+done - const reentryRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - // Turn 1: opus responds with text - yield { type: 'text', catId: 'opus', content: 'Opus first', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 10, outputTokens: 5 } }, - }; - // Turn 2: codex is silent (only done, no text/richBlocks) - yield { - type: 'done', - catId: 'codex', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 5, outputTokens: 0 } }, - }; - // Turn 3: opus re-enters with richBlocks only (no text) - if (options?.persistenceContext) { - options.persistenceContext.richBlocks = [{ type: 'code', language: 'js', content: 'return 42;' }]; - } - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 8, outputTokens: 3 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - const deliverCalls = /** @type {Array<{catId: string, content: string, richBlocks: any}>} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId, richBlocks) => { - deliverCalls.push({ catId, content, richBlocks }); - }, - }; - - const trigger = createTrigger({ router: reentryRouter, outboundHook }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - // Should deliver 2 turns: opus text + opus richBlocks (separate, not merged) - assert.strictEqual(deliverCalls.length, 2, 'Should deliver 2 separate turns'); - assert.strictEqual(deliverCalls[0].catId, 'opus'); - assert.strictEqual(deliverCalls[0].content, 'Opus first'); - assert.strictEqual(deliverCalls[1].catId, 'opus'); - assert.ok(deliverCalls[1].richBlocks?.length > 0, 'Second turn should have richBlocks'); - }); - - it('cloud-P1-3: silent first cat → second cat reply uses actual speaker catId', async () => { - // opus is silent (no text, no richBlocks), codex responds - const silentFirstRouter = /** @type {any} */ ({ - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { - // opus: silent — only done, no text - yield { - type: 'done', - catId: 'opus', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 10, outputTokens: 0 } }, - }; - // codex: responds with text - yield { type: 'text', catId: 'codex', content: 'I can help!', timestamp: Date.now() }; - yield { - type: 'done', - catId: 'codex', - content: '', - timestamp: Date.now(), - metadata: { usage: { inputTokens: 50, outputTokens: 20 } }, - }; - }, - async ackCollectedCursors() {}, - }); - - const deliverCalls = /** @type {Array<{catId: string, content: string}>} */ ([]); - const outboundHook = { - deliver: async (threadId, content, catId) => { - deliverCalls.push({ catId, content }); - }, - }; - - const trigger = createTrigger({ router: silentFirstRouter, outboundHook }); - // Trigger as opus, but opus is silent — codex is the actual speaker - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(deliverCalls.length, 1, 'Should deliver only codex reply'); - assert.strictEqual(deliverCalls[0].catId, 'codex', 'Should use actual speaker catId, not trigger catId'); - assert.strictEqual(deliverCalls[0].content, 'I can help!'); - }); - - describe('queue mode (active invocation running)', () => { - it('enqueues connector message when another cat is running', async () => { - trackerMock.setActive('thread-1'); - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - // Should NOT call routeExecution (queued instead) - assert.strictEqual(routerMock.calls.length, 0); - // Should NOT start tracker (no direct execution) - assert.strictEqual(trackerMock.starts.length, 0); - // Should NOT create InvocationRecord (no direct execution) - assert.strictEqual(recordMock.creates.length, 0); - - // Queue should have the entry - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1); - assert.strictEqual(entries[0].content, 'Review msg'); - assert.strictEqual(entries[0].source, 'connector'); - assert.strictEqual(entries[0].messageId, 'msg-1'); - assert.deepStrictEqual(entries[0].targetCats, ['opus']); - }); - - it('projects the exact stored wait carrier into Queue while the thread is busy', async () => { - trackerMock.setActive('thread-1'); - const storedMessage = waitConnectorMessage('msg-wait-queued', 'wait:pr:owner/repo#7:g3:matched'); - const trigger = createTrigger({ - messageStore: { getById: async (id) => (id === storedMessage.id ? storedMessage : null) }, - }); - - await trigger.trigger( - storedMessage.threadId, - /** @type {any} */ ('opus'), - storedMessage.userId, - storedMessage.content, - storedMessage.id, - undefined, - { sourceCategory: 'review' }, - ); - await waitForTrigger(); - - assert.deepStrictEqual( - queue.list('thread-1', 'user-1')[0].waitContinuationCarrier, - storedMessage.source.meta.waitContinuationCarrier, - ); - assert.strictEqual(recordMock.creates.length, 0); - }); - - it('rejects malformed stored github-wait carriers before Queue admission', async () => { - trackerMock.setActive('thread-1'); - const storedMessage = waitConnectorMessage('msg-wait-malformed-queued', 'wait:pr:owner/repo#7:g4:matched', 4); - delete storedMessage.source.meta.waitContinuationCarrier.ownerFence; - const trigger = createTrigger({ - messageStore: { getById: async (id) => (id === storedMessage.id ? storedMessage : null) }, - }); - - await assert.rejects( - () => - trigger.trigger( - storedMessage.threadId, - /** @type {any} */ ('opus'), - storedMessage.userId, - storedMessage.content, - storedMessage.id, - undefined, - { sourceCategory: 'review' }, - ), - /missing a valid continuation carrier/, - ); - assert.strictEqual(queue.list('thread-1', 'user-1').length, 0); - assert.strictEqual(recordMock.creates.length, 0); - assert.strictEqual(routerMock.calls.length, 0); - }); - - it('urgent connector enqueues with priority instead of preempting (F175)', async () => { - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger(); - trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'Urgent review msg', - 'msg-urgent-1', - undefined, - { - priority: 'urgent', - reason: 'github_review', - }, - ); - await waitForTrigger(); - - assert.strictEqual(trackerMock.cancelCalls.length, 0, 'F175: no preemption — active signal must not be aborted'); - assert.strictEqual(routerMock.calls.length, 0, 'Should not execute directly — goes through queue'); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1, 'Should enqueue urgent message'); - assert.strictEqual(entries[0].priority, 'urgent', 'Entry should carry urgent priority'); - assert.strictEqual(entries[0].messageId, 'msg-urgent-1'); - }); - - it('urgent connector passes sourceCategory to queue entry (F175)', async () => { - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger(); - trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'Review with category', - 'msg-cat-1', - undefined, - { - priority: 'urgent', - reason: 'github_review', - sourceCategory: 'review', - }, - ); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1); - assert.strictEqual(entries[0].sourceCategory, 'review', 'sourceCategory should be preserved on queue entry'); - assert.strictEqual(entries[0].priority, 'urgent'); - }); - - it('urgent connector with owner mismatch still enqueues without cancel (F175)', async () => { - trackerMock.setActive('thread-1', 'owner-user'); - const trigger = createTrigger(); - trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-2', - 'Urgent review msg', - 'msg-urgent-2', - undefined, - { - priority: 'urgent', - reason: 'github_review', - }, - ); - await waitForTrigger(); - - assert.strictEqual(trackerMock.cancelCalls.length, 0, 'F175: no cancel calls for any urgent trigger'); - assert.strictEqual(routerMock.calls.length, 0, 'Should not execute directly'); - - const entries = queue.list('thread-1', 'user-2'); - assert.strictEqual(entries.length, 1, 'Should enqueue urgent connector'); - assert.strictEqual(entries[0].priority, 'urgent'); - }); - - it('normal priority connector enqueues with priority normal (F175)', async () => { - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Normal msg', 'msg-normal-1'); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1); - assert.strictEqual(entries[0].priority, 'normal', 'Default priority should be normal'); - }); - - it('emits queue_updated after enqueue', async () => { - trackerMock.setActive('thread-1'); - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - const queueUpdate = socketMock.userEmits.find((e) => e.event === 'queue_updated'); - assert.ok(queueUpdate, 'Should emit queue_updated'); - assert.strictEqual(queueUpdate.userId, 'user-1'); - assert.strictEqual(queueUpdate.data.threadId, 'thread-1'); - assert.strictEqual(queueUpdate.data.action, 'enqueued'); - assert.ok(Array.isArray(queueUpdate.data.queue)); - }); - - it('does NOT merge consecutive connector messages (F134: each may be from a different group sender)', async () => { - trackerMock.setActive('thread-1'); - const trigger = createTrigger(); - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'First review', 'msg-1'); - await waitForTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Second review', 'msg-2'); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 2, 'Connector messages must not merge'); - assert.ok(entries[0].content.includes('First review')); - assert.ok(entries[1].content.includes('Second review')); - }); - - it('coalesces queued connector messages with the same policy coalesceKey and preserves first content', async () => { - trackerMock.setActive('thread-1'); - const trigger = createTrigger(); - const policy = { - priority: 'normal', - reason: 'github_review_feedback', - sourceCategory: 'review', - coalesceKey: 'pr:owner/repo#42:review-feedback', - }; - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'First review', 'msg-1', undefined, policy); - await waitForTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Second review', 'msg-2', undefined, policy); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1, 'same PR review feedback should keep one queued invocation'); - assert.strictEqual(entries[0].content, 'First review', 'coalescing keeps first queued content as canonical'); - assert.strictEqual(entries[0].messageId, 'msg-1'); - assert.deepStrictEqual(entries[0].mergedMessageIds, ['msg-2']); - }); - - it('does not coalesce different wait generations under the same adapter key', async () => { - trackerMock.setActive('thread-1'); - const first = waitConnectorMessage('msg-wait-g3', 'wait:pr:owner/repo#7:g3:matched', 3); - const second = waitConnectorMessage('msg-wait-g4', 'wait:pr:owner/repo#7:g4:matched', 4); - const messages = new Map([ - [first.id, first], - [second.id, second], - ]); - const trigger = createTrigger({ messageStore: { getById: async (id) => messages.get(id) ?? null } }); - const policy = { - priority: 'normal', - sourceCategory: 'review', - coalesceKey: 'pr:owner/repo#7:wait:opus', - }; - - await trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - first.content, - first.id, - undefined, - policy, - ); - await waitForTrigger(); - await trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - second.content, - second.id, - undefined, - policy, - ); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 2); - assert.deepStrictEqual( - entries.map((entry) => entry.waitContinuationCarrier?.ownerFence.generation), - [3, 4], - ); - }); - - it('upgrades coalesced review feedback when a later event is urgent', async () => { - trackerMock.setActive('thread-1'); - const trigger = createTrigger(); - const coalesceKey = 'pr:owner/repo#42:review-feedback'; - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Commented review', 'msg-1', undefined, { - priority: 'normal', - reason: 'github_review_feedback', - sourceCategory: 'review', - coalesceKey, - }); - await waitForTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Changes requested', 'msg-2', undefined, { - priority: 'urgent', - reason: 'github_review_feedback', - sourceCategory: 'review', - suggestedSkill: 'receive-review', - coalesceKey, - }); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1, 'same PR review feedback should keep one queued invocation'); - assert.strictEqual(entries[0].content, 'Commented review', 'coalescing still keeps first content canonical'); - assert.strictEqual(entries[0].priority, 'urgent', 'later CHANGES_REQUESTED should upgrade queue priority'); - assert.strictEqual(entries[0].suggestedSkill, 'receive-review'); - assert.strictEqual(entries[0].messageId, 'msg-1'); - assert.deepStrictEqual(entries[0].mergedMessageIds, ['msg-2']); - }); - - it('queues follow-up review feedback when the previous coalesced wake-up is already processing', async () => { - trackerMock.setActive('thread-1'); - const trigger = createTrigger(); - const coalesceKey = 'pr:owner/repo#42:review-feedback'; - const policy = { - priority: 'normal', - reason: 'github_review_feedback', - sourceCategory: 'review', - suggestedSkill: 'receive-review', - coalesceKey, - }; - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'First review', 'msg-1', undefined, policy); - await waitForTrigger(); - const first = queue.markProcessing('thread-1', 'user-1'); - assert.ok(first, 'first coalesced wake-up should be processing'); - - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Second review', 'msg-2', undefined, policy); - await waitForTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Third review', 'msg-3', undefined, policy); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - const processingEntries = entries.filter((entry) => entry.status === 'processing'); - const queuedEntries = entries.filter((entry) => entry.status === 'queued'); - - assert.strictEqual(processingEntries.length, 1, 'the in-flight wake-up remains processing'); - assert.strictEqual(queuedEntries.length, 1, 'follow-up feedback gets a fresh queued wake-up'); - assert.strictEqual(queuedEntries[0].content, 'Second review'); - assert.strictEqual(queuedEntries[0].messageId, 'msg-2'); - assert.deepStrictEqual(queuedEntries[0].mergedMessageIds, ['msg-3']); - }); - - it('connector messages bypass MAX_QUEUE_DEPTH (F175)', async () => { - trackerMock.setActive('thread-1'); - const trigger = createTrigger(); - - // Enqueue 7 connector messages — all should succeed (no depth limit for connector) - for (let i = 0; i < 7; i++) { - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', `msg ${i}`, `msg-${i}`); - await waitForTrigger(); - } - - const fullWarning = socketMock.userEmits.find((e) => e.event === 'queue_full_warning'); - assert.strictEqual(fullWarning, undefined, 'No queue_full_warning for connector source'); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 7, 'All 7 connector messages should be enqueued'); - }); - - it('P1 fix: direct execution calls queueProcessor.onInvocationComplete on success', async () => { - // Codex cloud review P1: connector direct execution doesn't notify QueueProcessor, - // so queued follow-ups stall forever. This test verifies the fix. - const qpCalls = /** @type {Array<{threadId: string, catId: string, status: string}>} */ ([]); - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete(threadId, catId, status) { - qpCalls.push({ threadId, catId, status }); - }, - }); - - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - // Must have notified QueueProcessor with 'succeeded' - assert.strictEqual(qpCalls.length, 1, 'Should call onInvocationComplete once'); - assert.strictEqual(qpCalls[0].threadId, 'thread-1'); - assert.strictEqual(qpCalls[0].status, 'succeeded'); - }); - - it('F254 D1.2b: connector success reports only cats with done evidence to queueProcessor', async () => { - const qpCalls = - /** @type {Array<{threadId: string, catId: string, status: string, completedCatIds?: string[]}>} */ ([]); - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete(threadId, catId, status, _invocationId, completedCatIds) { - qpCalls.push({ threadId, catId, status, completedCatIds }); - }, - }); - - routerMock = mockRouter({ - messages: [ - { - type: 'text', - catId: 'opus', - content: 'started but no terminal done event', - timestamp: Date.now(), - }, - ], - }); - const trigger = createTrigger({ router: routerMock.router, queueProcessor: mockQueueProcessor }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-no-done'); - await waitForTrigger(); - - assert.strictEqual(qpCalls.length, 1, 'Should call onInvocationComplete once'); - assert.strictEqual(qpCalls[0].status, 'succeeded'); - assert.deepStrictEqual( - qpCalls[0].completedCatIds, - [], - 'connector path must not close queued_handled without a successful done event', - ); - const succeededUpdate = recordMock.updates.find((update) => update.data.status === 'succeeded'); - assert.ok(succeededUpdate, 'expected durable succeeded update'); - assert.deepStrictEqual( - succeededUpdate.data.successfulCatIds, - [], - 'the persistent witness must match the exact empty done-evidence set', - ); - }); - - it('F254 D1.2b: connector error-coded done is not successful handled evidence', async () => { - const qpCalls = - /** @type {Array<{threadId: string, catId: string, status: string, completedCatIds?: string[]}>} */ ([]); - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete(threadId, catId, status, _invocationId, completedCatIds) { - qpCalls.push({ threadId, catId, status, completedCatIds }); - }, - }); - - routerMock = mockRouter({ - messages: [ - { - type: 'done', - catId: 'opus', - timestamp: Date.now(), - errorCode: 'connector_failed', - }, - ], - }); - const trigger = createTrigger({ router: routerMock.router, queueProcessor: mockQueueProcessor }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-error-done'); - await waitForTrigger(); - - assert.strictEqual(qpCalls.length, 1, 'Should call onInvocationComplete once'); - assert.strictEqual(qpCalls[0].status, 'succeeded'); - assert.deepStrictEqual( - qpCalls[0].completedCatIds, - [], - 'connector path must not close queued_handled on error-coded done events', - ); - }); - - it('P1 fix: direct execution calls queueProcessor.onInvocationComplete on failure', async () => { - const qpCalls = /** @type {Array<{threadId: string, catId: string, status: string}>} */ ([]); - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete(threadId, catId, status) { - qpCalls.push({ threadId, catId, status }); - }, - }); - - routerMock = mockRouter({ throwError: new Error('boom') }); - const trigger = createTrigger({ router: routerMock.router, queueProcessor: mockQueueProcessor }); - await assert.rejects(trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'), /boom/); - await waitForTrigger(); - - assert.strictEqual(qpCalls.length, 1, 'Should call onInvocationComplete once'); - assert.strictEqual(qpCalls[0].threadId, 'thread-1'); - assert.strictEqual(qpCalls[0].status, 'failed'); - }); - - it('executes directly when no active invocation', async () => { - // trackerMock.setActive NOT called → has() returns false - const trigger = createTrigger(); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - // Should call routeExecution (direct execution) - assert.strictEqual(routerMock.calls.length, 1); - // Queue should be empty - assert.strictEqual(queue.list('thread-1', 'user-1').length, 0); - }); - }); - - describe('F140 Phase C: suggestedSkill through queue path (F175)', () => { - it('suggestedSkill is preserved on queue entry when urgent enqueues (F175)', async () => { - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger(); - const policy = { priority: 'urgent', reason: 'github_ci_failure', suggestedSkill: 'merge-gate' }; - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'CI failed', 'msg-skill', undefined, policy); - await waitForTrigger(); - - assert.strictEqual(routerMock.calls.length, 0, 'F175: urgent does not execute directly'); - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1, 'Should enqueue urgent message'); - assert.strictEqual(entries[0].priority, 'urgent'); - }); - - it('connector retry with same messageId does not create duplicate queue entry', async () => { - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger(); - const policy = { priority: 'urgent', reason: 'webhook_retry' }; - - // First trigger — should enqueue - const r1 = await trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'CI failed', - 'msg-retry-1', - undefined, - policy, - ); - assert.strictEqual(r1, 'enqueued'); - assert.strictEqual(queue.list('thread-1', 'user-1').length, 1); - - // Second trigger with same messageId — should be deduped - const r2 = await trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'CI failed', - 'msg-retry-1', - undefined, - policy, - ); - assert.strictEqual(r2, 'enqueued', 'Duplicate should return enqueued (idempotent)'); - assert.strictEqual(queue.list('thread-1', 'user-1').length, 1, 'Queue should still have exactly 1 entry'); - - // Different messageId — should enqueue normally - const r3 = await trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'New event', - 'msg-different', - undefined, - policy, - ); - assert.strictEqual(r3, 'enqueued'); - assert.strictEqual(queue.list('thread-1', 'user-1').length, 2, 'Different messageId should enqueue'); - }); - - it('suggestedSkill passes through to direct execution when no active invocation', async () => { - const trigger = createTrigger(); - const policy = { priority: 'urgent', reason: 'github_ci_failure', suggestedSkill: 'merge-gate' }; - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'CI failed', 'msg-urgent', undefined, policy); - await waitForTrigger(); - - assert.strictEqual(routerMock.calls.length, 1, 'Should execute directly when idle'); - const intent = routerMock.calls[0].intent; - assert.ok(intent, 'intent should exist'); - assert.ok(Array.isArray(intent.promptTags), 'promptTags should be an array'); - assert.ok( - intent.promptTags.includes('skill:merge-gate'), - `promptTags should include 'skill:merge-gate' but got: ${JSON.stringify(intent.promptTags)}`, - ); - }); - - it('queued path preserves suggestedSkill in QueueEntry (#564 regression)', async () => { - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger(); - const policy = { priority: 'urgent', reason: 'github_ci_failure', suggestedSkill: 'receive-review' }; - trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'Review changes requested', - 'msg-queue-skill', - undefined, - policy, - ); - await waitForTrigger(); - - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1, 'Should enqueue'); - assert.strictEqual( - entries[0].suggestedSkill, - 'receive-review', - 'suggestedSkill must be preserved in queue entry', - ); - }); - }); - - // ── F185: thread-level busy gate (AC-1/AC-2) ── - - describe('F185: thread-level busy gate', () => { - it('managed review wake restores the exact action lease carrier for typed CHANGES_REQUESTED', async () => { - const messageStore = new MessageStore(); - const activeLease = activeManagedReviewLease(); - const source = messageStore.append({ - userId: 'scheduler', - catId: null, - content: '[定时任务] review command completed', - mentions: [], - timestamp: 2_100, - threadId: 'thread-managed-event', - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { - taskId: 'task-managed-review', - threadId: 'thread-managed-event', - catId: 'opus', - wakeWhen: true, - actionLeaseRef: { leaseId: 'lease-review-1', generation: 3 }, - }, - }, - }); - const trigger = createTrigger({ - messageStore, - actionSuccessorLeaseStore: { - async get(leaseId) { - return leaseId === 'lease-review-1' ? activeLease : null; - }, - }, - }); - - assert.equal( - await trigger.trigger( - 'thread-managed-event', - /** @type {any} */ ('opus'), - 'user-1', - source.content, - source.id, - undefined, - { sourceCategory: 'scheduled' }, - ), - 'dispatched', - ); - await waitForTrigger(); - - assert.deepEqual(recordMock.creates[0].actionLeaseCarrier, { - kind: 'action_successor', - leaseId: 'lease-review-1', - generation: 3, - }); - - const changesRequested = recordActionSuccessorOutcome(activeLease, { - generation: 3, - catId: 'opus', - outcome: 'succeeded', - evidenceRef: 'github:review:message-verdict-1', - now: 200, - }); - const repairedPredicate = canonicalizeActionTerminalPredicate({ - actionFamily: 'review', - subjectRef: 'pr:owner/repo#4049', - predicate: { kind: 'review_delivered', headSha: 'b'.repeat(40) }, - }); - const reentry = continueActionSuccessorFreshRevision(changesRequested, { - successorLeaseId: 'lease-review-2', - expectedGeneration: 3, - terminalPredicate: repairedPredicate, - holderCatIds: ['opus'], - holderThreadId: 'thread-managed-event', - claimOrigin: 'structured_transfer', - predecessorCatId: 'codex-sol', - predecessorThreadId: 'thread-author', - dispatchId: 'dispatch-review-2', - issuerStandingEvidenceRef: 'message:review-request-2', - evidenceRef: `community:pr:owner/repo#4049:head:${'b'.repeat(40)}`, - now: 300, - }); - - assert.equal(changesRequested.status, 'completed'); - assert.equal(reentry.outcome, 'continued'); - assert.equal(reentry.lease.status, 'active'); - assert.equal(reentry.lease.terminalPredicate.freshnessKey, `head:${'b'.repeat(40)}`); - }); - - it('managed review wake fails closed when its frozen generation is stale', async () => { - const messageStore = new MessageStore(); - const source = messageStore.append({ - userId: 'scheduler', - catId: null, - content: '[定时任务] stale review command completed', - mentions: [], - timestamp: 2_100, - threadId: 'thread-managed-event', - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { - taskId: 'task-managed-review-stale', - threadId: 'thread-managed-event', - catId: 'opus', - wakeWhen: true, - actionLeaseRef: { leaseId: 'lease-review-1', generation: 3 }, - }, - }, - }); - const trigger = createTrigger({ - messageStore, - actionSuccessorLeaseStore: { - async get() { - return activeManagedReviewLease({ generation: 4 }); - }, - }, - }); - - await assert.rejects( - trigger.trigger( - 'thread-managed-event', - /** @type {any} */ ('opus'), - 'user-1', - source.content, - source.id, - undefined, - { sourceCategory: 'scheduled' }, - ), - /generation/i, - ); - assert.equal(recordMock.creates.length, 0); - assert.equal(queue.list('thread-managed-event', 'user-1').length, 0); - }); - - it('managed event forceQueue creates one F254/F264 carrier even while idle', async () => { - const messageStore = new MessageStore(); - const source = messageStore.append({ - userId: 'scheduler', - catId: null, - content: '[定时任务] managed command completed', - mentions: [], - timestamp: 2_100, - threadId: 'thread-managed-event', - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { - taskId: 'task-managed-event', - threadId: 'thread-managed-event', - catId: 'opus', - wakeWhen: true, - actionLeaseRef: { leaseId: 'lease-review-1', generation: 3 }, - }, - }, - }); - const autoExecuteCalls = []; - const queueProcessor = /** @type {any} */ ({ - async tryAutoExecute(threadId) { - autoExecuteCalls.push(threadId); - }, - }); - const trigger = createTrigger({ - messageStore, - queueProcessor, - actionSuccessorLeaseStore: { - async get(leaseId) { - return leaseId === 'lease-review-1' ? activeManagedReviewLease() : null; - }, - }, - }); - const args = [ - 'thread-managed-event', - /** @type {any} */ ('opus'), - 'user-1', - source.content, - source.id, - undefined, - { sourceCategory: 'scheduled', forceQueue: true, ownerAuthProvenance: 'strict' }, - ]; - - assert.deepEqual(await Promise.all([trigger.trigger(...args), trigger.trigger(...args)]), [ - 'enqueued', - 'enqueued', - ]); - - const entries = queue.list('thread-managed-event', 'user-1'); - assert.equal(entries.length, 1, 'concurrent replay must reuse the exact action generation carrier'); - assert.equal(entries[0].messageId, source.id); - assert.equal(entries[0].sourceCategory, 'scheduled'); - assert.equal(entries[0].ownerAuthProvenance, 'strict'); - assert.equal(entries[0].idempotencyKey, 'action:lease-review-1:3:opus'); - assert.deepEqual(entries[0].actionSuccessorFence, { - leaseId: 'lease-review-1', - generation: 3, - dispatchId: 'dispatch-review-1', - terminalPredicateDigest: activeManagedReviewLease().terminalPredicate.digest, - invocationLineageRef: 'dispatch:dispatch-review-1', - }); - assert.equal(messageStore.getById(source.id).queueCustody.entryId, entries[0].id); - assert.equal(messageStore.getById(source.id).queueCustody.ownerAuthProvenance, 'strict'); - assert.equal(routerMock.calls.length, 0, 'forceQueue never forks into direct admission'); - assert.equal(recordMock.creates.length, 0); - assert.deepEqual(autoExecuteCalls, ['thread-managed-event', 'thread-managed-event']); - }); - - it('never upgrades a legacy source custody from unknown to strict', async () => { - const legacySource = { - id: 'msg-managed-legacy-custody', - userId: 'scheduler', - catId: null, - content: '[定时任务] legacy managed command completed', - mentions: [], - timestamp: 2_200, - threadId: 'thread-managed-legacy', - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { - taskId: 'task-managed-legacy', - threadId: 'thread-managed-legacy', - catId: 'opus', - wakeWhen: true, - }, - }, - queueCustody: { - version: 1, - entryId: 'legacy-entry', - revision: 1, - ownerUserId: 'user-1', - intent: 'execute', - status: 'queued', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - notifiedByCatIds: [], - failedByCatIds: [], - handledByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - createdAt: 2_200, - updatedAt: 2_200, - }, - }; - const trigger = createTrigger({ - messageStore: { - getById: async (messageId) => (messageId === legacySource.id ? legacySource : null), - }, - queueCustodyCoordinator: { - async transferEntryCustody() {}, - }, - }); - - await trigger.trigger( - legacySource.threadId, - /** @type {any} */ ('opus'), - 'user-1', - legacySource.content, - legacySource.id, - undefined, - { sourceCategory: 'scheduled', forceQueue: true, ownerAuthProvenance: 'strict' }, - ); - - const [entry] = queue.list(legacySource.threadId, 'user-1'); - assert.equal(entry.ownerAuthProvenance, 'unknown'); - }); - - it('force-reset suppression queues a late connector wake before direct admission', async () => { - let tryStartCalls = 0; - trackerMock.tracker.tryStartThread = () => { - tryStartCalls++; - return new AbortController(); - }; - const mockQueueProcessor = /** @type {any} */ ({ - isAutoResumeSuppressed: () => true, - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete() {}, - }); - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - - const outcome = await trigger.trigger( - 'thread-1', - /** @type {any} */ ('opus'), - 'user-1', - 'Managed command completed after reset.', - 'msg-late-managed-command', - undefined, - { sourceCategory: 'scheduled' }, - ); - - assert.strictEqual(outcome, 'enqueued'); - assert.strictEqual(tryStartCalls, 0, 'suppressed wake must not reach direct tracker admission'); - assert.strictEqual(routerMock.calls.length, 0, 'suppressed wake must not dispatch'); - assert.strictEqual(queue.list('thread-1', 'user-1').length, 1, 'late wake remains recoverable in Queue'); - }); - - it('AC-1: enqueues when queueProcessor.isThreadBusy returns true', async () => { - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => true, - isCatBusy: () => false, - async onInvocationComplete() {}, - }); - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - const outcome = await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(outcome, 'enqueued', 'should enqueue when thread is busy'); - assert.strictEqual(routerMock.calls.length, 0, 'should NOT dispatch'); - assert.strictEqual(queue.list('thread-1', 'user-1').length, 1); - }); - - it('AC-2: enqueues when tryStartThread returns null (thread busy in tracker)', async () => { - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete() {}, - }); - // Mock tryStartThread returning null (thread already has active invocation) - trackerMock.tracker.tryStartThread = (_threadId, _catId) => null; - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - const outcome = await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(outcome, 'enqueued', 'should enqueue when tryStartThread fails'); - assert.strictEqual(routerMock.calls.length, 0, 'should NOT dispatch'); - }); - - it('AC-2: dispatches when tryStartThread returns controller', async () => { - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete() {}, - }); - const acquiredController = new AbortController(); - trackerMock.tracker.tryStartThread = (_threadId, _catId) => acquiredController; - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - const outcome = await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); - await waitForTrigger(); - - assert.strictEqual(outcome, 'dispatched', 'should dispatch when tryStartThread succeeds'); - assert.strictEqual(routerMock.calls.length, 1, 'should call routeExecution'); - }); - - it('AC-10: connector + different cat busy in thread → enqueues (thread-level gate)', async () => { - // Scenario: opus is running, connector targets codex — should still queue (thread-level) - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger(); - const outcome = await trigger.trigger( - 'thread-1', - /** @type {any} */ ('codex'), - 'user-1', - 'CI notification', - 'msg-ci', - ); - - assert.strictEqual(outcome, 'enqueued', 'connector must queue when ANY cat is busy in thread'); - assert.strictEqual(routerMock.calls.length, 0, 'should NOT dispatch concurrently'); - }); - - it('AC-3: reuses tryStartThread controller (no separate start() call)', async () => { - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete() {}, - }); - const acquiredController = new AbortController(); - trackerMock.tracker.tryStartThread = (_threadId, _catId) => acquiredController; - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - // start() should NOT have been called (tryStartThread already registered the slot) - assert.strictEqual(trackerMock.starts.length, 0, 'should NOT call start() when tryStartThread was used'); - // complete() should still be called for cleanup - assert.strictEqual(trackerMock.completes.length, 1, 'should call complete()'); - }); - - it('R2-P1-B: duplicate invocation does not notify queueProcessor with failed status', async () => { - const onCompleteCalls = /** @type {Array<{threadId: string, catId: string, status: string}>} */ ([]); - const mockQueueProcessor = /** @type {any} */ ({ - isThreadBusy: () => false, - isCatBusy: () => false, - async onInvocationComplete(threadId, catId, status) { - onCompleteCalls.push({ threadId, catId, status }); - }, - }); - recordMock.setDuplicate(); - const trigger = createTrigger({ queueProcessor: mockQueueProcessor }); - await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'msg', 'msg-1'); - await waitForTrigger(); - - // Duplicate should NOT cause onInvocationComplete('failed') - const failedCalls = onCompleteCalls.filter((c) => c.status === 'failed'); - assert.strictEqual(failedCalls.length, 0, 'duplicate must not trigger failed onInvocationComplete'); - }); - - it('R2-P1-A: queue full emits system_info via broadcastAgentMessage', async () => { - const mockFullQueue = /** @type {any} */ ({ - hasEntryWithMessageId: () => false, - enqueue: () => ({ outcome: 'full' }), - size: () => 5, - list: () => [], - hasQueuedUserMessagesForThread: () => false, - hasActiveOrQueuedAgentForCat: () => false, - }); - trackerMock.setActive('thread-1', 'user-1'); - const trigger = createTrigger({ invocationQueue: mockFullQueue }); - const outcome = await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'overflow', 'mid-full'); - - assert.strictEqual(outcome, 'full'); - const systemInfoMsgs = socketMock.broadcasts.filter( - (b) => b.msg.type === 'system_info' && b.threadId === 'thread-1', - ); - assert.ok(systemInfoMsgs.length > 0, 'queue full must emit system_info to thread'); - const parsed = JSON.parse(systemInfoMsgs[0].msg.content); - assert.strictEqual(parsed.reason, 'queue_full', 'system_info reason must be queue_full'); }); + assert.equal(await failing.trigger(source.threadId, 'opus', source.userId, source.content, source.id), 'enqueued'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(queue.list(source.threadId, source.userId).length, 1); }); }); diff --git a/packages/api/test/connector-router.test.js b/packages/api/test/connector-router.test.js index 65e459896d..b84b1c9329 100644 --- a/packages/api/test/connector-router.test.js +++ b/packages/api/test/connector-router.test.js @@ -73,7 +73,7 @@ function mockThreadStore() { }; } -function mockTrigger(outcome = 'dispatched') { +function mockTrigger(outcome = 'enqueued') { const calls = []; return { calls, @@ -195,10 +195,15 @@ describe('ConnectorRouter', () => { it('posts message to message store with ConnectorSource', async () => { await router.route('feishu', 'chat-123', 'Hello', 'ext-1'); assert.equal(messageStore.messages.length, 1); + assert.equal(messageStore.messages[0].deliveryStatus, 'queued'); assert.equal(messageStore.messages[0].source.connector, 'feishu'); assert.equal(messageStore.messages[0].source.label, '飞书'); assert.equal(typeof messageStore.messages[0].source.icon, 'string'); assert.equal(messageStore.messages[0].source.icon, '/images/connectors/feishu.png'); + assert.deepEqual(messageStore.messages[0].from, { + kind: 'external', + connectorId: 'feishu', + }); }); it('triggers cat invocation', async () => { @@ -295,29 +300,12 @@ describe('ConnectorRouter', () => { assert.ok(thread.projectPath.length > 1, 'projectPath should be a real filesystem path'); }); - it('broadcasts connector message to websocket', async () => { - await router.route('feishu', 'chat-123', 'Hello', 'ext-1'); - assert.ok(socketManager.broadcasts.length > 0); - assert.equal(socketManager.broadcasts[0].event, 'connector_message'); - }); - - it('emits nested message protocol (threadId + message.{id,type,content,source,timestamp})', async () => { + it('keeps a triggered connector source out of History until Queue admission exposes it', async () => { await router.route('feishu', 'chat-123', 'Hi from IM', 'ext-proto-1'); const bc = socketManager.broadcasts.find((b) => b.event === 'connector_message'); - assert.ok(bc, 'should have a connector_message broadcast'); - const { data } = bc; - // Must have nested message — frontend guard: if (!data?.message?.id) return; - assert.ok(data.threadId, 'data.threadId must exist'); - assert.ok(data.message, 'data.message must exist (nested protocol)'); - assert.ok(data.message.id, 'data.message.id must exist'); - assert.equal(data.message.type, 'connector'); - assert.equal(data.message.content, 'Hi from IM'); - assert.ok(data.message.source, 'data.message.source must exist'); - assert.equal(data.message.source.connector, 'feishu'); - assert.equal(typeof data.message.timestamp, 'number'); - // Must NOT have flat legacy fields - assert.equal(data.messageId, undefined, 'legacy messageId must not exist'); - assert.equal(data.connectorId, undefined, 'legacy connectorId must not exist'); + assert.equal(bc, undefined, 'producer must not project queued input into History before admission'); + assert.equal(messageStore.messages[0].deliveryStatus, 'queued'); + assert.equal(trigger.calls[0].messageId, messageStore.messages[0].id); }); describe('command interception', () => { diff --git a/packages/api/test/context-assembler.test.js b/packages/api/test/context-assembler.test.js index ee521a5111..3c688dca38 100644 --- a/packages/api/test/context-assembler.test.js +++ b/packages/api/test/context-assembler.test.js @@ -163,6 +163,58 @@ describe('assembleContext', () => { assert.ok(!result.contextText.includes('co-creator] Error:'), 'system error must not appear as co-creator'); }); + test('includes delivered owner-bound managed hold facts for the shared user/cat view', async () => { + const { assembleContext } = await import('../dist/domains/cats/services/context/ContextAssembler.js'); + const hold = mockMsg({ + from: { kind: 'system', service: 'hold-ball' }, + userId: 'user-1', + content: '持球中:等待 CI 完成;完成后复核结果', + timestamp: 2_000, + source: { + connector: 'hold-ball', + label: '等待状态', + icon: '🏓', + meta: { + managedHold: true, + phase: 'waiting', + taskId: 'hold-ball-1', + threadId: 'thread-1', + catId: 'codex', + }, + }, + }); + + const result = assembleContext([hold]); + assert.equal(result.messageCount, 1); + assert.match(result.contextText, /等待 CI 完成/); + assert.match(result.contextText, /等待状态/); + }); + + test('keeps managed-hold wake sources out of cat context until Queue admission', async () => { + const { assembleContext } = await import('../dist/domains/cats/services/context/ContextAssembler.js'); + const wake = mockMsg({ + from: { kind: 'system', service: 'managed-command-wake' }, + userId: 'user-1', + content: '门禁已结束,请检查结果', + deliveryStatus: 'queued', + source: { + connector: 'hold-ball', + label: '持球唤醒', + icon: '🏓', + meta: { + managedHold: true, + phase: 'wake', + taskId: 'hold-ball-2', + threadId: 'thread-1', + catId: 'codex', + }, + }, + }); + + assert.equal(assembleContext([wake]).messageCount, 0); + assert.match(assembleContext([{ ...wake, deliveryStatus: 'delivered' }]).contextText, /门禁已结束/); + }); + test('default token ceiling does not reintroduce a message-count cap', async () => { const { assembleContext } = await import('../dist/domains/cats/services/context/ContextAssembler.js'); const msgs = Array.from({ length: 25 }, (_, i) => mockMsg({ content: `m${i}`, timestamp: i * 1000 })); diff --git a/packages/api/test/coordination-terminal-dispatch-retirement.test.js b/packages/api/test/coordination-terminal-dispatch-retirement.test.js deleted file mode 100644 index ee7cdf5bb6..0000000000 --- a/packages/api/test/coordination-terminal-dispatch-retirement.test.js +++ /dev/null @@ -1,272 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { createCatId } from '@cat-cafe/shared'; -import { buildHandedEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; -import { TurnCustodyProjectionService } from '../dist/domains/ball-custody/TurnCustodyProjectionService.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialCrossThreadQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { - createA2ADispositionAuth as auth, - createA2ADispositionWake as dispatchWake, - createA2ADispositionHarness as harness, -} from './helpers/a2a-dispatch-disposition-harness.js'; - -const COORDINATION_ID = 'coord-terminal-retirement'; - -function bindActiveCoordination(h) { - h.source.extra = { - crossPost: { - sourceThreadId: 'thread-origin', - sourceInvocationId: 'origin-invocation', - }, - coordination: { - id: COORDINATION_ID, - phase: 'active', - hop: 1, - subjectRef: 'task:terminal-retirement', - }, - targetCats: ['codex-sol'], - }; -} - -function appendTerminal(h, overrides = {}) { - return h.messageStore.append({ - userId: 'user-1', - catId: createCatId('codex-sol'), - content: '@fable5 terminal result', - mentions: [createCatId('fable5')], - timestamp: 1_750, - threadId: 'thread-origin', - origin: 'callback', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-1', - sourceInvocationId: 'inv-1', - }, - coordination: { - id: COORDINATION_ID, - phase: 'terminal', - hop: 2, - subjectRef: 'task:terminal-retirement', - }, - causal: { - kind: 'invocation_reply', - triggerMessageId: h.source.id, - }, - stream: { - invocationId: 'inv-1', - turnInvocationId: 'inv-1', - }, - targetCats: ['fable5'], - ...overrides, - }, - }); -} - -describe('coordination terminal → ordinary A2A dispatch retirement', () => { - test('one consumed terminal retires the exact dispatch fence and both completion paths replay', async () => { - const h = await harness(); - bindActiveCoordination(h); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: h.projectionStore, - ballCustodyEventLog: h.eventLog, - }); - const opened = await gate.open(dispatchWake(h)); - const terminal = appendTerminal(h); - const queue = new InvocationQueue(); - const enqueued = queue.enqueue({ - threadId: terminal.threadId, - userId: terminal.userId, - ownerAuthProvenance: 'unknown', - content: terminal.content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['fable5'], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex-sol', - a2aParentInvocationId: 'inv-1', - a2aTriggerMessageId: terminal.id, - }); - assert.equal(enqueued.outcome, 'enqueued'); - queue.backfillMessageId(terminal.threadId, terminal.userId, enqueued.entry.id, terminal.id); - const queued = queue.getEntrySnapshot(terminal.threadId, terminal.userId, enqueued.entry.id); - assert.equal( - h.messageStore.initializeQueueCustody( - terminal.id, - createInitialCrossThreadQueuedMessageCustody(terminal.id, [queued]), - ).kind, - 'initialized', - ); - const seenAt = queued.createdAt + 10; - assert.equal(queue.markProcessingById(terminal.threadId, queued.id), true); - assert.equal( - queue.markQueuedAwakened(terminal.threadId, terminal.userId, queued.id, 'fable5', 'terminal-child', seenAt - 1), - true, - ); - queue.markProcessingSeen(terminal.threadId, terminal.userId, queued.id, ['fable5'], 'terminal-child', seenAt); - const processing = queue.getEntrySnapshot(terminal.threadId, terminal.userId, queued.id); - const custody = new QueuedMessageCustodyCoordinator({ messageStore: h.messageStore, now: () => seenAt + 1 }); - await custody.persistEntry(processing); - - assert.equal((await gate.close(opened)).shouldBlock, true); - assert.equal((await h.service.completeFromCoordinationTerminal(terminal.id)).outcome, 'applied'); - const settled = await custody.commitSuccessfulTargets(processing, ['fable5'], 'terminal-child', seenAt + 20, { - fable5: { - invocationId: 'terminal-child', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'terminal-child' }, - handledAt: seenAt + 20, - consumption: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - }, - }); - assert.equal(settled.perMessage[0].fullyConsumed, true); - assert.equal(h.messageStore.getById(terminal.id).deliveryStatus, 'delivered'); - assert.equal(h.messageStore.getById(terminal.id).queueCustody.status, 'terminal'); - assert.deepEqual(h.messageStore.getById(terminal.id).queueCustody.targetOutcomeByCatId.fable5, { - invocationId: 'terminal-child', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'terminal-child' }, - handledAt: seenAt + 20, - consumption: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - }); - assert.deepEqual(await gate.close(opened), { - state: 'covered_active', - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'dispatch_dispositioned', - dispatchDisposition: 'completed', - dispatchDispositionEventId: `dispatch-disposition:inv-1:${h.source.id}`, - dispatchDispositionAt: 2_000, - evidenceRefs: [`dispatch:ball:thread:thread-1`, `route:${h.source.id}:codex-sol`], - }); - - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'replayed'); - h.setLatest(false); - assert.equal((await h.service.completeFromCoordinationTerminal(terminal.id)).outcome, 'replayed'); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.dispatch_dispositioned') - .length, - 1, - ); - }); - - test('missing terminal identity fails closed without producing a disposition', async () => { - const h = await harness(); - bindActiveCoordination(h); - const terminal = appendTerminal(h, { causal: undefined }); - - await assert.rejects( - () => h.service.completeFromCoordinationTerminal(terminal.id), - /a2a_dispatch_coordination_terminal_identity_missing/, - ); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.dispatch_dispositioned'), - false, - ); - }); - - test('a superseded source with a successor handoff remains replaced and writes no disposition', async () => { - const h = await harness(); - bindActiveCoordination(h); - const terminal = appendTerminal(h); - const successor = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('opus'), - content: '@codex-sol continue the successor coordination', - mentions: [createCatId('codex-sol')], - timestamp: 1_900, - threadId: 'thread-1', - replyTo: h.source.id, - extra: { - coordination: { - id: 'coord-successor', - phase: 'active', - hop: 1, - subjectRef: 'task:successor', - }, - }, - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: successor.id, - at: 1_900, - }), - ); - h.setLatest(false); - - await assert.rejects( - () => h.service.completeFromCoordinationTerminal(terminal.id), - (error) => { - assert.equal(error.code, 'a2a_dispatch_disposition_replaced'); - assert.equal(error.replacement.sourceMessageId, successor.id); - return true; - }, - ); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.dispatch_dispositioned'), - false, - ); - }); - - test('both-missing coordination subjects fail closed without producing a disposition', async () => { - const h = await harness(); - bindActiveCoordination(h); - delete h.source.extra.coordination.subjectRef; - const terminal = appendTerminal(h, { - coordination: { id: COORDINATION_ID, phase: 'terminal', hop: 2 }, - }); - - await assert.rejects( - () => h.service.completeFromCoordinationTerminal(terminal.id), - /a2a_dispatch_coordination_terminal_mismatch/, - ); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.dispatch_dispositioned'), - false, - ); - }); - - test('foreign or mismatched coordination terminals fail closed', async () => { - const cases = [ - (h) => appendTerminal(h, { coordination: { id: 'coord-foreign', phase: 'terminal', hop: 2 } }), - (h) => appendTerminal(h, { coordination: { id: COORDINATION_ID, phase: 'terminal', hop: 9 } }), - (h) => { - const terminal = appendTerminal(h); - terminal.threadId = 'thread-foreign'; - return terminal; - }, - (h) => appendTerminal(h, { stream: { invocationId: 'inv-foreign', turnInvocationId: 'inv-foreign' } }), - ]; - - for (const createTerminal of cases) { - const h = await harness(); - bindActiveCoordination(h); - const terminal = createTerminal(h); - await assert.rejects( - () => h.service.completeFromCoordinationTerminal(terminal.id), - /a2a_dispatch_coordination_terminal_mismatch/, - ); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.dispatch_dispositioned'), - false, - ); - } - }); -}); diff --git a/packages/api/test/cross-thread-action-successor.test.js b/packages/api/test/cross-thread-action-successor.test.js index 48284f2616..c241a9bef3 100644 --- a/packages/api/test/cross-thread-action-successor.test.js +++ b/packages/api/test/cross-thread-action-successor.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; import Fastify from 'fastify'; +import { adaptInvocationQueue, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { return { @@ -104,7 +105,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); registry = new InvocationRegistry(); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); messageStore = new MessageStore(); threadStore = new ThreadStore(); source = await threadStore.create('user-1', 'Source'); @@ -149,7 +150,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { router: createMockRouter(), invocationRecordStore: createMockInvocationRecordStore(), invocationQueue, - queueProcessor: { async tryAutoExecute() {} }, + queueProcessor: { async requestDrain() {} }, actionSuccessorAdmissionService: actionService, }); }); @@ -220,7 +221,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { assert.equal(actionService.calls[0].dispatchId, 'post:review-2915'); const entries = invocationQueue.list(source.id, 'user-1'); assert.equal(entries.length, 1); - assert.deepEqual(entries[0].actionSuccessorFence, { + assert.deepEqual(entries[0].execution.actionSuccessorFence, { leaseId: 'lease-review-1', generation: 1, dispatchId: 'post:review-2915', @@ -309,7 +310,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { const entries = invocationQueue.list(target.id, 'user-1'); assert.equal(entries.length, 1); - assert.deepEqual(entries[0].actionSuccessorFence, { + assert.deepEqual(entries[0].execution.actionSuccessorFence, { leaseId: 'lease-review-1', generation: 1, dispatchId: 'cross-post:review-2868', @@ -352,7 +353,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { outcome: 'returned', }); const [entry] = invocationQueue.list(target.id, 'user-1'); - assert.deepEqual(entry.actionSuccessorFence, { + assert.deepEqual(entry.execution.actionSuccessorFence, { leaseId: 'lease-review-1', generation: 2, dispatchId: 'cross-post:return-review-2868', @@ -360,7 +361,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { assert.equal(actionService.unavailable.length, 0); assert.deepEqual(actionService.returnedDelivered, [ { - fence: entry.actionSuccessorFence, + fence: entry.execution.actionSuccessorFence, evidenceRef: 'queue:cross-post:return-review-2868:return_enqueued', now: actionService.returnedDelivered[0].now, }, @@ -400,7 +401,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { assert.equal(actionService.unavailable.length, 0); assert.deepEqual(actionService.returnedDelivered, [ { - fence: entry.actionSuccessorFence, + fence: entry.execution.actionSuccessorFence, evidenceRef: 'queue:cross-post:return-replay-review-2868:return_enqueued', now: actionService.returnedDelivered[0].now, }, @@ -409,15 +410,18 @@ describe('F167 Phase S: cross-thread action successor admission', () => { test('replayed custody return stays pending when queue depth prevents delivery', async () => { for (let i = 0; i < 10; i++) { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: target.id, - userId: 'user-1', - content: `fill-return-replay-${i}`, - source: 'agent', - targetCats: [`cat-${i}`], - intent: 'execute', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + kind: 'private_input', + ownerAuthProvenance: 'unknown', + threadId: target.id, + userId: 'user-1', + content: `fill-return-replay-${i}`, + source: 'agent', + targetCats: [`cat-${i}`], + intent: 'execute', + }), + ); } actionService.admit = async (input) => { actionService.calls.push(input); @@ -496,7 +500,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { const [entry] = invocationQueue.list(target.id, 'user-1'); assert.deepEqual(actionService.returnedDelivered, [ { - fence: entry.actionSuccessorFence, + fence: entry.execution.actionSuccessorFence, evidenceRef: 'queue:cross-post:return-same-client-2868:return_enqueued', now: actionService.returnedDelivered[0].now, }, @@ -529,18 +533,21 @@ describe('F167 Phase S: cross-thread action successor admission', () => { }); test('action-scoped work queues behind unrelated same-cat work instead of coalescing into it', async () => { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: target.id, - userId: 'user-1', - content: 'Unrelated earlier handoff', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + kind: 'private_input', + ownerAuthProvenance: 'unknown', + threadId: target.id, + userId: 'user-1', + content: 'Unrelated earlier handoff', + source: 'agent', + sourceCategory: 'a2a', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + callerCatId: 'opus', + }), + ); const response = await post({ targetCats: ['codex'], @@ -557,8 +564,8 @@ describe('F167 Phase S: cross-thread action successor admission', () => { assert.equal(response.statusCode, 200); const entries = invocationQueue.list(target.id, 'user-1'); assert.equal(entries.length, 2); - assert.equal(entries[0].actionSuccessorFence, undefined); - assert.equal(entries[1].actionSuccessorFence.leaseId, 'lease-review-1'); + assert.equal(entries[0].execution.actionSuccessorFence, undefined); + assert.equal(entries[1].execution.actionSuccessorFence.leaseId, 'lease-review-1'); }); test('releases a newly claimed action lease when exact-message dedupe reuses an existing message', async () => { @@ -589,70 +596,6 @@ describe('F167 Phase S: cross-thread action successor admission', () => { assert.match(actionService.unavailable[0].evidenceRef, /exact_duplicate$/); }); - test('queued duplicate recovery preserves the replayed return fence and confirms delivery', async () => { - threadStore.addParticipants(target.id, ['codex']); - const first = await post({ - targetCats: ['codex'], - clientMessageId: 'recover-review-2868', - action: { - subjectRef: 'subject:task:task-2868', - actionFamily: 'implement', - successorSlot: 'implementer', - mode: 'single', - terminalPredicate: { kind: 'task_done' }, - }, - }); - assert.equal(first.statusCode, 200); - const [storedCarrier] = messageStore.getByThreadIncludingQueued(target.id, 20, 'user-1'); - assert.equal(storedCarrier.extra.callbackDedup.coordinationKey, 'action-active-root'); - const [lostEntry] = invocationQueue.list(target.id, 'user-1'); - assert.ok(lostEntry); - invocationQueue.remove(target.id, 'user-1', lostEntry.id); - - actionService.admit = async (input) => { - actionService.calls.push(input); - return { - admit: false, - outcome: 'replayed', - lease: activeLease(['codex'], { generation: 2, dispatchId: input.dispatchId }), - }; - }; - auth = await registry.create('user-1', 'opus', source.id); - const replay = await post({ - targetCats: ['codex'], - clientMessageId: 'recover-review-2868', - action: { - subjectRef: 'subject:task:task-2868', - actionFamily: 'implement', - successorSlot: 'implementer', - mode: 'single', - terminalPredicate: { kind: 'task_done' }, - returnToPredecessor: { - leaseId: 'lease-review-1', - expectedGeneration: 1, - groundingEvidenceRef: 'grounding:mismatch', - }, - }, - }); - - assert.equal(replay.statusCode, 200); - assert.equal(replay.json().status, 'duplicate'); - const [recovered] = invocationQueue.list(target.id, 'user-1'); - assert.deepEqual(recovered.actionSuccessorFence, { - leaseId: 'lease-review-1', - generation: 2, - dispatchId: 'cross-post:recover-review-2868', - }); - assert.equal(actionService.unavailable.length, 0); - assert.deepEqual(actionService.returnedDelivered, [ - { - fence: recovered.actionSuccessorFence, - evidenceRef: 'queue:cross-post:recover-review-2868:return_enqueued', - now: actionService.returnedDelivered[0].now, - }, - ]); - }); - test('action carrier rejects ambiguous identity and preserves explicit parallel intent', async () => { const missingKey = await post({ targetCats: ['codex'], @@ -682,8 +625,9 @@ describe('F167 Phase S: cross-thread action successor admission', () => { assert.equal(parallel.statusCode, 200); assert.deepEqual(actionService.calls.at(-1).holderCatIds, ['codex', 'gpt52']); const entries = invocationQueue.list(target.id, 'user-1'); - assert.equal(entries.length, 2); - assert.ok(entries.every((entry) => entry.actionSuccessorFence?.leaseId === 'lease-review-1')); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0].targets, ['codex', 'gpt52']); + assert.equal(entries[0].execution.actionSuccessorFence?.leaseId, 'lease-review-1'); }); test('agent-key action fails closed instead of posting or enqueueing an unfenced successor', async () => { @@ -697,7 +641,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { const agentApp = Fastify(); const agentRegistry = new InvocationRegistry(); - const agentQueue = new InvocationQueue(); + const agentQueue = adaptInvocationQueue(new InvocationQueue()); const agentMessages = new MessageStore(); const agentThreads = new ThreadStore(); const agentThread = await agentThreads.create('user-1', 'Agent-key target'); @@ -716,7 +660,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => { router: createMockRouter(), invocationRecordStore: createMockInvocationRecordStore(), invocationQueue: agentQueue, - queueProcessor: { async tryAutoExecute() {} }, + queueProcessor: { async requestDrain() {} }, actionSuccessorAdmissionService: agentAdmission, }); diff --git a/packages/api/test/cross-thread-coordination-chain.test.js b/packages/api/test/cross-thread-coordination-chain.test.js index c0b2daccba..07321690e6 100644 --- a/packages/api/test/cross-thread-coordination-chain.test.js +++ b/packages/api/test/cross-thread-coordination-chain.test.js @@ -1,9 +1,9 @@ /** * F167 Phase R — cross-thread coordination identity + terminal ACK guard. * - * Regression lineage: Claim -> active reply -> terminal Release -> ACK. - * The ACK is persisted for visibility but must not enqueue an ACK-of-ACK - * invocation. A genuinely new active collaboration remains routable. + * Regression lineage: Claim -> active reply -> terminal Release. Cross-thread + * posts remain fail-closed without routing credentials; either text or + * structured targets after terminal start genuinely new active work. */ import assert from 'node:assert/strict'; @@ -15,6 +15,7 @@ function createMockSocketManager() { return { broadcastAgentMessage() {}, broadcastToRoom() {}, + emitToUser() {}, }; } @@ -64,6 +65,7 @@ describe('F167 Phase R: cross-thread coordination chain', () => { const { InMemoryDispatchProposalStore } = await import( '../dist/domains/approval-hub/stores/ports/IDispatchProposalStore.js' ); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); registry = new InvocationRegistry(); @@ -71,6 +73,7 @@ describe('F167 Phase R: cross-thread coordination chain', () => { threadStore = new ThreadStore(); invocationRecordStore = createMockInvocationRecordStore(); dispatchProposalStore = new InMemoryDispatchProposalStore(); + const invocationQueue = new InvocationQueue(); app = Fastify(); await app.register(callbacksRoutes, { registry, @@ -80,6 +83,8 @@ describe('F167 Phase R: cross-thread coordination chain', () => { router: createMockRouter(), invocationRecordStore, dispatchProposalStore, + invocationQueue, + queueProcessor: { requestDrain() {} }, }); }); @@ -94,7 +99,7 @@ describe('F167 Phase R: cross-thread coordination chain', () => { payload: { threadId, content, - targetCats: [targetCat], + ...(targetCat ? { targetCats: [targetCat] } : {}), clientMessageId, ...(effectClass ? { effectClass } : {}), ...(coordination ? { coordination } : {}), @@ -106,7 +111,7 @@ describe('F167 Phase R: cross-thread coordination chain', () => { return messageStore.getByThread(threadId, 20, 'user-1').find((message) => message.content === content); } - test('Claim -> Release -> ACK closes without ACK-of-ACK spawn, while explicit new active work remains routable', async () => { + test('Claim -> Release closes while every explicit routing form starts fresh active work', async () => { const source = await threadStore.create('user-1', 'Source'); const target = await threadStore.create('user-1', 'Target'); await threadStore.addParticipants(source.id, ['opus']); @@ -161,100 +166,59 @@ describe('F167 Phase R: cross-thread coordination chain', () => { assert.equal(release.extra.coordination.hop, 2); const targetAckAuth = await registry.create('user-1', 'codex', target.id, undefined, release.id); - const recordsBeforeAck = invocationRecordStore.getRecords().length; - const ackResponse = await post({ + const messagesBeforeUnroutedPost = messageStore.getByThread(source.id, 20, 'user-1').length; + const unroutedResponse = await post({ auth: targetAckAuth, threadId: source.id, - content: '@opus\nRelease received', - targetCat: 'opus', + content: 'Release received', clientMessageId: 'ack', }); - assert.equal(ackResponse.statusCode, 200); - const ackBody = ackResponse.json(); - assert.equal(ackBody.status, 'terminal_ack_recorded'); - assert.deepEqual( - ackBody.routing_warnings.find((warning) => warning.kind === 'suppressed_by_terminal_ack'), - { kind: 'suppressed_by_terminal_ack', droppedMentions: ['opus'] }, - ); - assert.equal(invocationRecordStore.getRecords().length, recordsBeforeAck, 'ACK must not enqueue ACK-of-ACK'); - const ack = findMessage(source.id, '@opus\nRelease received'); - assert.deepEqual(ack.mentions, []); - assert.equal(ack.extra.coordination.id, coordinationId); - assert.equal(ack.extra.coordination.phase, 'ack'); - assert.equal(ack.extra.coordination.hop, 3); - - const ackRetry = await post({ - auth: targetAckAuth, - threadId: source.id, - content: '@opus\nRelease received', - targetCat: 'opus', - }); - assert.equal(ackRetry.statusCode, 200); - assert.equal(ackRetry.json().status, 'duplicate'); - assert.equal( - messageStore - .getByThread(source.id, 20, 'user-1') - .filter((message) => message.content === '@opus\nRelease received').length, - 1, - 'terminal ACK retry must not append a second record when only an informational suppression warning exists', - ); - - const recordsBeforeMismatchedTerminalAck = invocationRecordStore.getRecords().length; - const messagesBeforeMismatchedTerminalAck = messageStore.getByThread(source.id, 20, 'user-1').length; - const mismatchedTerminalAckResponse = await post({ - auth: targetAckAuth, - threadId: source.id, - content: 'Release received with stale caller id', - targetCat: 'opus', - coordination: { phase: 'terminal', id: 'caller-supplied-other-chain' }, - clientMessageId: 'ack-mismatched-terminal-id', - }); - assert.equal(mismatchedTerminalAckResponse.statusCode, 409); - assert.deepEqual(mismatchedTerminalAckResponse.json(), { - kind: 'coordination_id_conflict', - message: 'Explicit terminal coordination id conflicts with the incoming coordination lineage.', - incomingCoordinationId: coordinationId, - explicitCoordinationId: 'caller-supplied-other-chain', - }); - assert.equal(invocationRecordStore.getRecords().length, recordsBeforeMismatchedTerminalAck); + assert.equal(unroutedResponse.statusCode, 400); + assert.equal(unroutedResponse.json().kind, 'cross_post_no_routing'); assert.equal( messageStore.getByThread(source.id, 20, 'user-1').length, - messagesBeforeMismatchedTerminalAck, - 'a rejected conflict must not persist a message', + messagesBeforeUnroutedPost, + 'an unrouted cross-thread courtesy message must fail before persistence', ); - assert.equal(findMessage(source.id, 'Release received with stale caller id'), undefined); - const mismatchedTerminalAckRetry = await post({ + const structuredTargetResponse = await post({ auth: targetAckAuth, threadId: source.id, - content: 'Release received with stale caller id', + content: 'New structured work discovered after release', targetCat: 'opus', - coordination: { phase: 'terminal', id: 'caller-supplied-other-chain' }, - clientMessageId: 'ack-mismatched-terminal-id', + clientMessageId: 'new-structured-work-after-release', }); - assert.equal(mismatchedTerminalAckRetry.statusCode, 409); - assert.deepEqual(mismatchedTerminalAckRetry.json(), mismatchedTerminalAckResponse.json()); - assert.equal(invocationRecordStore.getRecords().length, recordsBeforeMismatchedTerminalAck); - assert.equal(messageStore.getByThread(source.id, 20, 'user-1').length, messagesBeforeMismatchedTerminalAck); + assert.equal(structuredTargetResponse.statusCode, 200); + assert.equal(structuredTargetResponse.json().status, 'ok'); + assert.deepEqual( + structuredTargetResponse.json().routed, + ['opus'], + 'structured targetCats after terminal must enqueue a new active hop', + ); + const structuredTarget = findMessage(source.id, 'New structured work discovered after release'); + assert.deepEqual(structuredTarget.mentions, ['opus']); + assert.equal(structuredTarget.extra.coordination.phase, 'active'); + assert.notEqual(structuredTarget.extra.coordination.id, coordinationId); - const mismatchedTerminalSubjectResponse = await post({ + const explicitMentionResponse = await post({ auth: targetAckAuth, threadId: source.id, - content: 'Release received with a foreign subject', + content: '@opus\nNew work discovered after release', targetCat: 'opus', - coordination: { phase: 'terminal', id: coordinationId, subjectRef: 'subject:other-work' }, - clientMessageId: 'ack-mismatched-terminal-subject', + coordination: { phase: 'terminal', id: coordinationId }, + clientMessageId: 'new-work-after-release', }); - assert.equal(mismatchedTerminalSubjectResponse.statusCode, 409); - assert.deepEqual(mismatchedTerminalSubjectResponse.json(), { - kind: 'coordination_subject_conflict', - message: 'Explicit terminal coordination subject conflicts with the incoming coordination lineage.', - coordinationId, - incomingSubjectRef: 'subject:review-cycle', - explicitSubjectRef: 'subject:other-work', - }); - assert.equal(invocationRecordStore.getRecords().length, recordsBeforeMismatchedTerminalAck); - assert.equal(messageStore.getByThread(source.id, 20, 'user-1').length, messagesBeforeMismatchedTerminalAck); + assert.equal(explicitMentionResponse.statusCode, 200); + assert.equal(explicitMentionResponse.json().status, 'ok'); + assert.deepEqual( + explicitMentionResponse.json().routed, + ['opus'], + 'an explicit line-start mention after terminal must enqueue a new active hop', + ); + const explicitMention = findMessage(source.id, '@opus\nNew work discovered after release'); + assert.deepEqual(explicitMention.mentions, ['opus']); + assert.equal(explicitMention.extra.coordination.phase, 'active'); + assert.notEqual(explicitMention.extra.coordination.id, coordinationId); const recordsBeforeRestart = invocationRecordStore.getRecords().length; const restartResponse = await post({ @@ -267,7 +231,11 @@ describe('F167 Phase R: cross-thread coordination chain', () => { }); assert.equal(restartResponse.statusCode, 200); assert.equal(restartResponse.json().status, 'ok'); - assert.equal(invocationRecordStore.getRecords().length, recordsBeforeRestart + 1); + assert.equal( + invocationRecordStore.getRecords().length, + recordsBeforeRestart, + 'callback admission must not mint an InvocationRecord before QueueProcessor reserves the carrier', + ); const restart = findMessage(source.id, 'New substantive coordination'); assert.notEqual(restart.extra.coordination.id, coordinationId); assert.equal(restart.extra.coordination.phase, 'active'); @@ -317,8 +285,7 @@ describe('F167 Phase R: cross-thread coordination chain', () => { const ackResponse = await post({ auth: ackAuth, threadId: thread.id, - content: '@codex\n收到,无 open items。', - targetCat: 'codex', + content: '收到,无 open items。', clientMessageId: 'same-thread-review-ack', }); assert.equal(ackResponse.statusCode, 200); @@ -328,7 +295,7 @@ describe('F167 Phase R: cross-thread coordination chain', () => { recordsBeforeAck, 'terminal ACK must not wake the reviewer', ); - const ack = findMessage(thread.id, '@codex\n收到,无 open items。'); + const ack = findMessage(thread.id, '收到,无 open items。'); assert.deepEqual(ack.mentions, []); assert.equal(ack.extra.crossPost, undefined); assert.equal(ack.extra.coordination.phase, 'ack'); diff --git a/packages/api/test/cross-thread-coordination-ingress.test.js b/packages/api/test/cross-thread-coordination-ingress.test.js index fe47820d88..13fb938bdb 100644 --- a/packages/api/test/cross-thread-coordination-ingress.test.js +++ b/packages/api/test/cross-thread-coordination-ingress.test.js @@ -51,6 +51,7 @@ describe('F167 Phase R: callback ingress boundaries', () => { let messageStore; let threadStore; let invocationRecordStore; + let invocationQueue; let dispatchProposalStore; let app; @@ -60,6 +61,7 @@ describe('F167 Phase R: callback ingress boundaries', () => { ); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { InMemoryDispatchProposalStore } = await import( '../dist/domains/approval-hub/stores/ports/IDispatchProposalStore.js' ); @@ -69,6 +71,7 @@ describe('F167 Phase R: callback ingress boundaries', () => { messageStore = new MessageStore(); threadStore = new ThreadStore(); invocationRecordStore = createMockInvocationRecordStore(); + invocationQueue = new InvocationQueue(); dispatchProposalStore = new InMemoryDispatchProposalStore(); app = Fastify(); await app.register(callbacksRoutes, { @@ -79,6 +82,8 @@ describe('F167 Phase R: callback ingress boundaries', () => { router: createMockRouter(), invocationRecordStore, dispatchProposalStore, + invocationQueue, + queueProcessor: { async requestDrain() {} }, }); }); @@ -114,6 +119,7 @@ describe('F167 Phase R: callback ingress boundaries', () => { for (const phase of ['active', 'terminal']) { const content = `Minted ${phase} root`; const recordsBefore = invocationRecordStore.getRecords().length; + const queuedBefore = invocationQueue.list(target.id, 'user-1').length; const first = await post({ auth, @@ -138,7 +144,12 @@ describe('F167 Phase R: callback ingress boundaries', () => { messageStore.getByThread(target.id, 20, 'user-1').filter((message) => message.content === content).length, 1, ); - assert.equal(invocationRecordStore.getRecords().length, recordsBefore + 1); + assert.equal( + invocationRecordStore.getRecords().length, + recordsBefore, + 'durable admission does not manufacture an invocation before the Queue drain owns it', + ); + assert.equal(invocationQueue.list(target.id, 'user-1').length, queuedBefore + 1); } }); diff --git a/packages/api/test/cross-thread-review-affinity.test.js b/packages/api/test/cross-thread-review-affinity.test.js index 2b335d27f8..ac0ff86eb7 100644 --- a/packages/api/test/cross-thread-review-affinity.test.js +++ b/packages/api/test/cross-thread-review-affinity.test.js @@ -271,6 +271,49 @@ describe('cross-thread review action affinity', () => { assert.equal(result.contentDedupCoordinationKey, 'minted-active-root'); }); + it('treats an explicit line-start mention after terminal as a new active hop', () => { + const result = resolveCrossThreadCoordination({ + incoming: { + ...boundIncoming, + coordination: { ...boundIncoming.coordination, phase: 'terminal' }, + }, + targetThreadId: 'thread-ancestor', + hasExplicitRoutingIntent: true, + mintId: () => 'coord-mentioned-after-terminal', + }); + + assert.deepEqual(result.coordination, { + id: 'coord-mentioned-after-terminal', + phase: 'active', + hop: 0, + }); + assert.equal(result.suppressRouting, false); + assert.equal(result.contentDedupCoordinationKey, 'minted-active-root'); + }); + + it('routes explicit terminal replies with authored targets as fresh active work', () => { + for (const explicit of [{ phase: 'terminal' }, { phase: 'terminal', id: 'coord-task-ancestor' }]) { + const result = resolveCrossThreadCoordination({ + explicit, + incoming: { + ...boundIncoming, + coordination: { ...boundIncoming.coordination, phase: 'terminal' }, + }, + targetThreadId: 'thread-ancestor', + hasExplicitRoutingIntent: true, + mintId: () => 'coord-targeted-after-terminal', + }); + + assert.deepEqual(result.coordination, { + id: 'coord-targeted-after-terminal', + phase: 'active', + hop: 0, + }); + assert.equal(result.suppressRouting, false); + assert.equal(result.contentDedupCoordinationKey, 'minted-active-root'); + } + }); + it('allows an explicit or minted standalone terminal only when no incoming id exists', () => { const explicitTerminal = resolveCrossThreadCoordination({ explicit: { phase: 'terminal', id: 'coord-explicit', subjectRef: 'subject:task:done' }, diff --git a/packages/api/test/cursor-ack-on-abort.test.js b/packages/api/test/cursor-ack-on-abort.test.js index f85c332f6a..f35718af61 100644 --- a/packages/api/test/cursor-ack-on-abort.test.js +++ b/packages/api/test/cursor-ack-on-abort.test.js @@ -1,5 +1,6 @@ /** - * F148 fix: Multi-cat invocation abort must still ack cursors for completed cats. + * F148 fix: a target attempt from one source Queue Entry must still ack cursor progress + * collected before the terminal event. * * Root cause: QueueProcessor step 8 (abort check) returns 'canceled' before * step 9 (ackCollectedCursors), losing cursor progress for cats that already @@ -8,13 +9,14 @@ import assert from 'node:assert/strict'; import { describe, it, mock } from 'node:test'; +import { adaptInvocationQueue, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); function stubDeps(overrides = {}) { return { - queue: new InvocationQueue(), + queue: adaptInvocationQueue(new InvocationQueue()), invocationTracker: { start: mock.fn(() => new AbortController()), startAll: mock.fn(() => new AbortController()), @@ -30,6 +32,8 @@ function stubDeps(overrides = {}) { update: mock.fn(async () => {}), }, router: { + resolveExplicitTargets: mock.fn(async (requestedCatIds) => [...requestedCatIds]), + resolveConversationTargetsAtAdmission: mock.fn(async (requestedCatIds) => [...requestedCatIds]), routeExecution: mock.fn(async function* () { yield { type: 'done', catId: 'opus', timestamp: Date.now() }; }), @@ -54,20 +58,23 @@ function stubDeps(overrides = {}) { } function enqueueEntry(queue, overrides = {}) { - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: '@gemini @opus hello', - source: 'user', - targetCats: ['gemini', 'opus'], - intent: 'execute', - ...overrides, - }); + const result = queue.enqueue( + canonicalTestQueueInput({ + kind: 'private_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: '@gemini @opus hello', + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + ...overrides, + }), + ); return result.entry; } -describe('F148 fix: cursor ack on abort (multi-cat)', () => { +describe('F148 fix: cursor ack on abort', () => { it('acks collected cursors when aborted after partial completion', async () => { const abortController = new AbortController(); @@ -80,12 +87,12 @@ describe('F148 fix: cursor ack on abort (multi-cat)', () => { has: mock.fn(() => false), }, router: { + resolveExplicitTargets: mock.fn(async (requestedCatIds) => [...requestedCatIds]), + resolveConversationTargetsAtAdmission: mock.fn(async (requestedCatIds) => [...requestedCatIds]), routeExecution: mock.fn(async function* (_u, _m, _t, _mid, _cats, _intent, opts) { - // Gemini completes — cursor boundary collected - opts.cursorBoundaries.set('gemini', 'boundary-gemini-001'); - yield { type: 'text', catId: 'gemini', content: 'hi', timestamp: Date.now() }; - yield { type: 'done', catId: 'gemini', timestamp: Date.now() }; - // Opus starts — abort fires mid-execution (no cursor collected for opus) + // Cursor progress is collected before the invocation is aborted. + opts.cursorBoundaries.set('opus', 'boundary-opus-001'); + yield { type: 'text', catId: 'opus', content: 'hi', timestamp: Date.now() }; abortController.abort('preempted'); yield { type: 'text', catId: 'opus', content: 'partial', timestamp: Date.now() }; yield { type: 'done', catId: 'opus', timestamp: Date.now() }; @@ -94,8 +101,7 @@ describe('F148 fix: cursor ack on abort (multi-cat)', () => { }, }); - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); + enqueueEntry(deps.queue); const processor = new QueueProcessor(deps); await processor.onInvocationComplete('t1', 'gemini', 'succeeded'); @@ -114,26 +120,24 @@ describe('F148 fix: cursor ack on abort (multi-cat)', () => { assert.equal(ackCalls[0].arguments[1], 't1'); const boundaries = ackCalls[0].arguments[2]; assert.ok(boundaries instanceof Map, 'boundaries should be a Map'); - assert.ok(boundaries.has('gemini'), 'gemini cursor boundary must be preserved'); + assert.ok(boundaries.has('opus'), 'opus cursor boundary must be preserved'); }); it('acks collected cursors when routeExecution throws after partial completion', async () => { const deps = stubDeps({ router: { + resolveExplicitTargets: mock.fn(async (requestedCatIds) => [...requestedCatIds]), + resolveConversationTargetsAtAdmission: mock.fn(async (requestedCatIds) => [...requestedCatIds]), routeExecution: mock.fn(async function* (_u, _m, _t, _mid, _cats, _intent, opts) { - // Gemini completes — cursor boundary collected - opts.cursorBoundaries.set('gemini', 'boundary-gemini-002'); - yield { type: 'text', catId: 'gemini', content: 'done', timestamp: Date.now() }; - yield { type: 'done', catId: 'gemini', timestamp: Date.now() }; - // Opus crashes + opts.cursorBoundaries.set('opus', 'boundary-opus-002'); + yield { type: 'text', catId: 'opus', content: 'done', timestamp: Date.now() }; throw new Error('ACP process crashed'); }), ackCollectedCursors: mock.fn(async () => {}), }, }); - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-2'); + enqueueEntry(deps.queue); const processor = new QueueProcessor(deps); await processor.onInvocationComplete('t1', 'gemini', 'succeeded'); @@ -149,7 +153,7 @@ describe('F148 fix: cursor ack on abort (multi-cat)', () => { const ackCalls = deps.router.ackCollectedCursors.mock.calls; assert.equal(ackCalls.length, 1, 'ackCollectedCursors must be called even on exception'); const boundaries = ackCalls[0].arguments[2]; - assert.ok(boundaries.has('gemini'), 'gemini cursor boundary must be preserved'); + assert.ok(boundaries.has('opus'), 'opus cursor boundary must be preserved'); }); it('skips cursor ack when no boundaries collected before abort', async () => { @@ -164,6 +168,8 @@ describe('F148 fix: cursor ack on abort (multi-cat)', () => { has: mock.fn(() => false), }, router: { + resolveExplicitTargets: mock.fn(async (requestedCatIds) => [...requestedCatIds]), + resolveConversationTargetsAtAdmission: mock.fn(async (requestedCatIds) => [...requestedCatIds]), routeExecution: mock.fn(async function* () { // Abort fires immediately — no cursor boundaries collected abortController.abort('preempted'); @@ -173,8 +179,7 @@ describe('F148 fix: cursor ack on abort (multi-cat)', () => { }, }); - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-3'); + enqueueEntry(deps.queue, { targetCats: ['opus'] }); const processor = new QueueProcessor(deps); await processor.onInvocationComplete('t1', 'opus', 'succeeded'); diff --git a/packages/api/test/cursor-deferred-ack.test.js b/packages/api/test/cursor-deferred-ack.test.js deleted file mode 100644 index 7c4abf1b2f..0000000000 --- a/packages/api/test/cursor-deferred-ack.test.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * ADR-008 S3: Cursor advancement deferred to invocation succeeded - * - * Tests that cursor ack is deferred via cursorBoundaries map (S3 design), - * and only committed via ackCollectedCursors after the invocation succeeds. - */ - -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import Fastify from 'fastify'; -import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { InvocationRecordStore } from '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { invocationsRoutes } from '../dist/routes/invocations.js'; - -function createMockSocketManager() { - return { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - }; -} - -/** - * Creates a router where ackCollectedCursors tracks calls. - */ -function createTrackingRouter(options = {}) { - const { shouldThrow } = options; - const ackCalls = []; - return { - routeExecution: async function* (_userId, _msg, _threadId, _userMsgId, _cats, _intent, _opts) { - if (shouldThrow) throw new Error('Boom'); - yield { type: 'text', catId: 'opus', content: 'ok', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute', explicit: false, promptTags: [] }, - }), - async ackCollectedCursors(userId, threadId, boundaries) { - ackCalls.push({ userId, threadId, boundaries: new Map(boundaries) }); - }, - getAckCalls() { - return ackCalls; - }, - }; -} - -async function setupScenario(router, status = 'failed') { - const invocationRecordStore = new InvocationRecordStore(); - const messageStore = new MessageStore(); - const invocationTracker = new InvocationTracker(); - const socketManager = createMockSocketManager(); - - const storedMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: '@布偶猫 cursor test', - mentions: ['opus'], - timestamp: Date.now(), - threadId: 'thread-c', - }); - - const createResult = invocationRecordStore.create({ - threadId: 'thread-c', - userId: 'user-1', - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: `key-cursor-${Date.now()}`, - actionLeaseCarrier: { kind: 'none' }, - }); - // Transition through proper lifecycle: queued → running → target status - invocationRecordStore.update(createResult.invocationId, { - userMessageId: storedMsg.id, - status: 'running', - }); - invocationRecordStore.update(createResult.invocationId, { - status, - ...(status === 'failed' ? { error: 'prev error' } : {}), - }); - - const app = Fastify(); - await app.register(invocationsRoutes, { - invocationRecordStore, - messageStore, - socketManager, - router, - invocationTracker, - }); - await app.ready(); - - return { app, invocationRecordStore, invocationId: createResult.invocationId }; -} - -describe('ADR-008 S3: cursor deferred ack', () => { - it('ackCollectedCursors is called after successful retry', async () => { - const router = createTrackingRouter(); - const { app, invocationRecordStore, invocationId } = await setupScenario(router); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - assert.equal(res.statusCode, 202); - - // Wait for background execution - await new Promise((r) => setTimeout(r, 150)); - - const record = invocationRecordStore.get(invocationId); - assert.equal(record.status, 'succeeded'); - - const ackCalls = router.getAckCalls(); - assert.equal(ackCalls.length, 1, 'ackCollectedCursors should be called exactly once'); - assert.equal(ackCalls[0].userId, 'user-1'); - assert.equal(ackCalls[0].threadId, 'thread-c'); - }); - - it('ackCollectedCursors is NOT called when retry execution fails', async () => { - const router = createTrackingRouter({ shouldThrow: true }); - const { app, invocationRecordStore, invocationId } = await setupScenario(router); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - assert.equal(res.statusCode, 202); - - // Wait for background execution to fail - await new Promise((r) => setTimeout(r, 150)); - - const record = invocationRecordStore.get(invocationId); - assert.equal(record.status, 'failed'); - - const ackCalls = router.getAckCalls(); - assert.equal(ackCalls.length, 0, 'ackCollectedCursors should NOT be called on failure'); - }); - - it('cursorBoundaries map is passed to routeExecution', async () => { - let capturedOpts; - const captureRouter = { - routeExecution: async function* (_u, _m, _t, _mid, _cats, _intent, opts) { - capturedOpts = opts; - yield { type: 'text', catId: 'opus', content: 'cap', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute', explicit: false, promptTags: [] }, - }), - async ackCollectedCursors() {}, - }; - - const { app, invocationId } = await setupScenario(captureRouter); - - await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - await new Promise((r) => setTimeout(r, 150)); - - assert.ok(capturedOpts, 'routeExecution should have been called'); - assert.ok(capturedOpts.cursorBoundaries instanceof Map, 'cursorBoundaries should be a Map instance'); - }); -}); diff --git a/packages/api/test/cursor-order-extended.test.js b/packages/api/test/cursor-order-extended.test.js index a915bce176..3da0456696 100644 --- a/packages/api/test/cursor-order-extended.test.js +++ b/packages/api/test/cursor-order-extended.test.js @@ -13,6 +13,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { parseCursor, cursorFor } = await import('../dist/domains/cats/services/stores/cursor.js'); @@ -26,30 +27,36 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const threadId = `red10-${Date.now()}`; // Append 3 messages — timestamps don't matter, seq must be strictly monotonic - const m1 = store.append({ - userId: 'u1', - catId: null, - content: 'a', - mentions: [], - timestamp: Date.now(), - threadId, - }); - const m2 = store.append({ - userId: 'u1', - catId: null, - content: 'b', - mentions: [], - timestamp: Date.now(), - threadId, - }); - const m3 = store.append({ - userId: 'u1', - catId: null, - content: 'c', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const m1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'a', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); + const m2 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'b', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); + const m3 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'c', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const page = store.getByThreadAfter(threadId); assert.equal(page.length, 3); @@ -70,14 +77,16 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red12-${Date.now()}`; - store.append({ - userId: 'u1', - catId: null, - content: 'stable', - mentions: [], - timestamp: Date.now(), - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'stable', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const page1 = store.getByThreadAfter(threadId); const token1 = cursorFor(page1[0]); @@ -99,25 +108,29 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red15-${Date.now()}`; - const direct = store.append({ - userId: 'u1', - catId: null, - content: 'direct', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const direct = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'direct', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); // Hidden queued work (system/scheduler) — NOT timeline-published - const queued = store.append({ - userId: 'scheduler', - catId: 'system', - content: 'hidden-queued', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const queued = store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: 'hidden-queued', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Verify via getByThreadAfter (which injects visibilitySeq) const page = store.getByThreadAfter(threadId); @@ -157,23 +170,27 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const threadId = `red23a-${Date.now()}`; const baseTs = Date.now() - 10000; - const c = store.append({ - userId: 'u1', - catId: null, - content: 'C-direct', - mentions: [], - timestamp: baseTs + 100, - threadId, - }); - const q = store.append({ - userId: 'u1', - catId: 'opus', - content: 'Q-queued', - mentions: [], - timestamp: baseTs + 50, - threadId, - deliveryStatus: 'queued', - }); + const c = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'C-direct', + mentions: [], + timestamp: baseTs + 100, + threadId, + }), + ); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'Q-queued', + mentions: [], + timestamp: baseTs + 50, + threadId, + deliveryStatus: 'queued', + }), + ); store.markDelivered(q.id, baseTs + 200); const latest = store.getLatestVisibleCursor(threadId); @@ -195,14 +212,16 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red23b-${Date.now()}`; - store.append({ - userId: 'u1', - catId: null, - content: 'msg', - mentions: [], - timestamp: Date.now(), - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'msg', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const latest = store.getLatestVisibleCursor(threadId); assert.ok(latest); @@ -217,23 +236,27 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red23c-${Date.now()}`; - const c = store.append({ - userId: 'u1', - catId: null, - content: 'C', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - const q = store.append({ - userId: 'u1', - catId: 'opus', - content: 'Q', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - deliveryStatus: 'queued', - }); + const c = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'C', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'Q', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + deliveryStatus: 'queued', + }), + ); store.markDelivered(q.id, Date.now()); // Q.id < C.id (lex order) but Q's visibilitySeq > C's visibilitySeq @@ -255,23 +278,27 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red13-${Date.now()}`; - const direct = store.append({ - userId: 'u1', - catId: null, - content: 'visible', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - const q = store.append({ - userId: 'u1', - catId: 'opus', - content: 'will-cancel', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const direct = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'visible', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'will-cancel', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); store.markCanceled(q.id); const page = store.getByThreadAfter(threadId); @@ -279,6 +306,43 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { assert.equal(page[0].content, 'visible'); }); + it('keeps a canceled lifecycle response visible as the member terminal surface', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const threadId = `canceled-response-${Date.now()}`; + + const response = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'codex', + content: '', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + lifecycle: { + kind: 'response', + orderKey: '1:inv-canceled', + invocationId: 'inv-canceled', + targetId: 'codex', + inputEntryIds: ['entry-1'], + inputMessageIds: ['source-1'], + status: 'canceled', + startedAt: 1, + completedAt: 2, + }, + }), + ); + store.markCanceled(response.id); + + const page = store.getByThreadAfter(threadId); + assert.deepEqual( + page.map((message) => message.id), + [response.id], + 'the response bubble must survive cancellation while canceled queued source work stays hidden', + ); + }); + // ---- RED #19: Queued lifecycle ---- // Timeline-published cat speech: seq assigned at append, preserved at delivery. // Not visible in getByThreadAfter while queued (display filter), visible after delivery. @@ -287,15 +351,17 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red19-${Date.now()}`; - const q = store.append({ - userId: 'u1', - catId: 'opus', - content: 'queued-msg', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'queued-msg', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Before delivery: not visible const before = store.getByThreadAfter(threadId); @@ -324,15 +390,17 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `deliver-preserve-${Date.now()}`; - const q = store.append({ - userId: 'u1', - catId: 'codex-sol', - content: 'timeline-published cat speech', - mentions: ['opus'], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'codex-sol', + content: 'timeline-published cat speech', + mentions: ['opus'], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); // Q gets visibilitySeq at append (timeline-published) const qCursorBefore = cursorFor(q); @@ -340,14 +408,16 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const qSeqBefore = parseCursor(qCursorBefore).seq; // Append later ordinary B — gets a higher seq - const b = store.append({ - userId: 'u1', - catId: null, - content: 'ordinary B', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const b = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'ordinary B', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const bSeq = parseCursor(cursorFor(b)).seq; assert.ok(bSeq > qSeqBefore, 'B seq must be > Q seq (B appended later)'); @@ -375,15 +445,17 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `hidden-queued-${Date.now()}`; - const hidden = store.append({ - userId: 'scheduler', - catId: 'system', - content: 'hidden system queued work', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const hidden = store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: 'hidden system queued work', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Hidden queued: NO visibilitySeq at append assert.equal(hidden.visibilitySeq, undefined, 'Hidden queued must not get visibilitySeq'); @@ -407,9 +479,36 @@ describe('Cursor Order — Extended RED tests (§8.8)', () => { const threadId = `pruned-v1-${Date.now()}`; // Create 3 messages - store.append({ userId: 'u1', catId: null, content: 'M1', mentions: [], timestamp: Date.now() - 2000, threadId }); - store.append({ userId: 'u1', catId: null, content: 'M2', mentions: [], timestamp: Date.now() - 1000, threadId }); - store.append({ userId: 'u1', catId: null, content: 'M3', mentions: [], timestamp: Date.now(), threadId }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'M1', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'M2', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'M3', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); // Use a fake cursor ID that doesn't exist (simulates pruned message) const fakeCursor = 'zzz-pruned-cursor-id'; diff --git a/packages/api/test/cursor-order-mention-parity.test.js b/packages/api/test/cursor-order-mention-parity.test.js index 6030123cff..048d5f84cf 100644 --- a/packages/api/test/cursor-order-mention-parity.test.js +++ b/packages/api/test/cursor-order-mention-parity.test.js @@ -13,6 +13,7 @@ import assert from 'node:assert/strict'; import { after, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -82,36 +83,42 @@ describe('#1269 R12: Redis mention publication boundary parity', () => { const threadId = `r12-mention-${Date.now()}`; // C: direct mention — visible immediately - const c = await store.append({ - userId: 'u1', - catId: null, - content: '@opus direct mention', - mentions: ['opus'], - timestamp: Date.now() - 3000, - threadId, - }); + const c = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@opus direct mention', + mentions: ['opus'], + timestamp: Date.now() - 3000, + threadId, + }), + ); // Q: queued real-cat speech mention — timeline-published at append - const q = await store.append({ - userId: 'u1', - catId: 'opus', - content: '@opus cat speech mention', - mentions: ['opus'], - timestamp: Date.now() - 2000, - threadId, - deliveryStatus: 'queued', - }); + const q = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: '@opus cat speech mention', + mentions: ['opus'], + timestamp: Date.now() - 2000, + threadId, + deliveryStatus: 'queued', + }), + ); // H: hidden scheduler work — NOT timeline-published until delivery - const h = await store.append({ - userId: 'scheduler', - catId: null, - content: '@opus hidden scheduler work', - mentions: ['opus'], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + const h = await store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: '@opus hidden scheduler work', + mentions: ['opus'], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); // Before H delivery: C and Q visible, H invisible const mentions1 = await store.getMentionsFor('opus', 20, undefined, threadId); @@ -140,25 +147,29 @@ describe('#1269 R12: Redis mention publication boundary parity', () => { const threadId = `r12-cursor-${Date.now()}`; // C: direct mention - const c = await store.append({ - userId: 'u1', - catId: null, - content: '@opus direct', - mentions: ['opus'], - timestamp: Date.now() - 2000, - threadId, - }); + const c = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@opus direct', + mentions: ['opus'], + timestamp: Date.now() - 2000, + threadId, + }), + ); // Q: queued real-cat speech (timeline-published) - await store.append({ - userId: 'u1', - catId: 'opus', - content: '@opus cat speech', - mentions: ['opus'], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: '@opus cat speech', + mentions: ['opus'], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); // Get C's cursor const fullPage = await store.getMentionsFor('opus', 20, undefined, threadId); @@ -181,36 +192,42 @@ describe('#1269 R12: Redis mention publication boundary parity', () => { const threadId = `r12-recent-${Date.now()}`; // C: direct mention - await store.append({ - userId: 'u1', - catId: null, - content: '@opus direct recent', - mentions: ['opus'], - timestamp: Date.now() - 3000, - threadId, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@opus direct recent', + mentions: ['opus'], + timestamp: Date.now() - 3000, + threadId, + }), + ); // Q: queued real-cat speech - await store.append({ - userId: 'u1', - catId: 'opus', - content: '@opus cat speech recent', - mentions: ['opus'], - timestamp: Date.now() - 2000, - threadId, - deliveryStatus: 'queued', - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: '@opus cat speech recent', + mentions: ['opus'], + timestamp: Date.now() - 2000, + threadId, + deliveryStatus: 'queued', + }), + ); // H: hidden scheduler work - const h = await store.append({ - userId: 'scheduler', - catId: null, - content: '@opus hidden recent', - mentions: ['opus'], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + const h = await store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: '@opus hidden recent', + mentions: ['opus'], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); // Before delivery: C and Q visible, H invisible const recent1 = await store.getRecentMentionsFor('opus', 20, undefined, threadId); @@ -239,26 +256,30 @@ describe('#1269 R12: Redis mention publication boundary parity', () => { const threadId = `r12-legacy-${Date.now()}`; // Q: queued real-cat speech — timeline-published - await store.append({ - userId: 'u1', - catId: 'opus', - content: '@terra legacy cat speech', - mentions: ['terra'], - timestamp: Date.now() - 2000, - threadId, - deliveryStatus: 'queued', - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: '@terra legacy cat speech', + mentions: ['terra'], + timestamp: Date.now() - 2000, + threadId, + deliveryStatus: 'queued', + }), + ); // H: hidden scheduler work - const h = await store.append({ - userId: 'scheduler', - catId: null, - content: '@terra legacy hidden', - mentions: ['terra'], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + const h = await store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: '@terra legacy hidden', + mentions: ['terra'], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); // Legacy path (no threadId) — Q visible, H invisible const legacy1 = await store.getMentionsFor('terra', 20); diff --git a/packages/api/test/cursor-order-mentions.test.js b/packages/api/test/cursor-order-mentions.test.js index df83f62b07..e52262c914 100644 --- a/packages/api/test/cursor-order-mentions.test.js +++ b/packages/api/test/cursor-order-mentions.test.js @@ -11,6 +11,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { cursorFor } = await import('../dist/domains/cats/services/stores/cursor.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -23,28 +24,32 @@ describe('Cursor Order — RED #22: Late mention exactly-once', () => { const baseTs = Date.now() - 10000; // C: direct mention (visible immediately) - const c = store.append({ - userId: 'u1', - catId: null, - content: '@opus direct mention', - mentions: ['opus'], - timestamp: baseTs + 200, - threadId, - }); + const c = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@opus direct mention', + mentions: ['opus'], + timestamp: baseTs + 200, + threadId, + }), + ); // Q: genuinely hidden queued work (catId: null, scheduler-originated) — NOT // timeline-published, so invisible before delivery. Q.id < C.id because // of the earlier timestamp. After markDelivered, Q becomes visible and // must appear exactly once in the next mention page. - const q = store.append({ - userId: 'scheduler', - catId: null, - content: '@opus queued mention', - mentions: ['opus'], - timestamp: baseTs + 50, - threadId, - deliveryStatus: 'queued', - }); + const q = store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: '@opus queued mention', + mentions: ['opus'], + timestamp: baseTs + 50, + threadId, + deliveryStatus: 'queued', + }), + ); // Before delivery: only C visible in mentions const mentions1 = store.getMentionsFor('opus', 20, undefined, threadId); @@ -72,37 +77,43 @@ describe('Cursor Order — RED #22: Late mention exactly-once', () => { const baseTs = Date.now() - 50000; // First mention (will be acked) - const _anchor = store.append({ - userId: 'u1', - catId: null, - content: '@opus anchor', - mentions: ['opus'], - timestamp: baseTs, - threadId, - }); + const _anchor = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@opus anchor', + mentions: ['opus'], + timestamp: baseTs, + threadId, + }), + ); const anchorCursor = cursorFor(store.getByThreadAfter(threadId)[0]); // 25 non-mention messages (would eat a page-then-filter window) for (let i = 0; i < 25; i++) { - store.append({ - userId: 'u1', - catId: null, - content: `filler-${i}`, - mentions: [], - timestamp: baseTs + 100 + i * 10, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: `filler-${i}`, + mentions: [], + timestamp: baseTs + 100 + i * 10, + threadId, + }), + ); } // 1 mention at the end - const lateMention = store.append({ - userId: 'u1', - catId: null, - content: '@opus late mention', - mentions: ['opus'], - timestamp: baseTs + 500, - threadId, - }); + const lateMention = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@opus late mention', + mentions: ['opus'], + timestamp: baseTs + 500, + threadId, + }), + ); // getMentionsFor with limit=20, cursor after anchor const mentions = store.getMentionsFor('opus', 20, undefined, threadId, anchorCursor); diff --git a/packages/api/test/cursor-order-r8-regression.test.js b/packages/api/test/cursor-order-r8-regression.test.js index c623db5c51..ad06d1fcf2 100644 --- a/packages/api/test/cursor-order-r8-regression.test.js +++ b/packages/api/test/cursor-order-r8-regression.test.js @@ -16,6 +16,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -27,25 +28,29 @@ describe('#1269 R8 P1-1: isTimelinePublished in forward scans', () => { const threadId = `r8-p1-1-after-${Date.now()}`; // Direct message visible at append - store.append({ - userId: 'u1', - catId: null, - content: 'C-direct', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'C-direct', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); // Timeline-published cat speech: queued but catId is real cat → visible at append - store.append({ - userId: 'u1', - catId: 'opus', - content: 'Q-cat-speech', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'Q-cat-speech', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); // Full scan should include both messages const page = store.getByThreadAfter(threadId, undefined, undefined, 'u1', { @@ -60,25 +65,29 @@ describe('#1269 R8 P1-1: isTimelinePublished in forward scans', () => { const store = new MessageStore(); const threadId = `r8-p1-1-default-${Date.now()}`; - store.append({ - userId: 'u1', - catId: null, - content: 'C-direct', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'C-direct', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); // Timeline-published cat speech — queued, real cat - store.append({ - userId: 'u1', - catId: 'opus', - content: 'Q-cat-speech', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'Q-cat-speech', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); // Default read (no options) — should NOT include queued cat speech const page = store.getByThreadAfter(threadId); @@ -91,25 +100,29 @@ describe('#1269 R8 P1-1: isTimelinePublished in forward scans', () => { const store = new MessageStore(); const threadId = `r8-p1-1-hidden-${Date.now()}`; - store.append({ - userId: 'u1', - catId: null, - content: 'C-direct', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'C-direct', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); // Hidden queued work: catId=null, not timeline-published - store.append({ - userId: 'scheduler', - catId: null, - content: 'Q-hidden', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: 'Q-hidden', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); const page = store.getByThreadAfter(threadId); const contents = page.map((m) => m.content); @@ -128,15 +141,17 @@ describe('#1269: isTimelinePublished in mention queries', () => { const threadId = `r8-mention-queued-${Date.now()}`; // Cat speech mentioning 'terra' — queued but timeline-published → visible - store.append({ - userId: 'u1', - catId: 'opus', - content: 'Hey @terra check this', - mentions: ['terra'], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'Hey @terra check this', + mentions: ['terra'], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); const mentions = store.getMentionsFor('terra', 10, undefined, threadId); assert.equal(mentions.length, 1, 'Timeline-published cat speech mention should be found'); @@ -148,15 +163,17 @@ describe('#1269: isTimelinePublished in mention queries', () => { const threadId = `r8-mention-hidden-${Date.now()}`; // Hidden queued work mentioning 'terra' — not timeline-published - store.append({ - userId: 'scheduler', - catId: null, - content: 'Hidden mention @terra', - mentions: ['terra'], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: 'Hidden mention @terra', + mentions: ['terra'], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); const mentions = store.getMentionsFor('terra', 10, undefined, threadId); assert.equal(mentions.length, 0, 'Hidden queued mention should NOT be found'); @@ -166,15 +183,17 @@ describe('#1269: isTimelinePublished in mention queries', () => { const store = new MessageStore(); const threadId = `r8-recent-queued-${Date.now()}`; - store.append({ - userId: 'u1', - catId: 'opus', - content: 'Recent @terra', - mentions: ['terra'], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'Recent @terra', + mentions: ['terra'], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); const recent = store.getRecentMentionsFor('terra', 10, undefined, threadId); assert.equal(recent.length, 1, 'Timeline-published recent mention should be found'); @@ -184,48 +203,51 @@ describe('#1269: isTimelinePublished in mention queries', () => { const store = new MessageStore(); const threadId = `r8-recent-hidden-${Date.now()}`; - store.append({ - userId: 'scheduler', - catId: null, - content: 'Hidden recent @terra', - mentions: ['terra'], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: 'Hidden recent @terra', + mentions: ['terra'], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); const recent = store.getRecentMentionsFor('terra', 10, undefined, threadId); assert.equal(recent.length, 0, 'Hidden queued recent mention should NOT be found'); }); }); -// ---- P1-2: cancel clears custody fields (Memory parity) ---- +// ---- P1-2: cancellation never creates a Queue mirror in History ---- -describe('#1269 R8 P1-2: cancel clears queueCustody (Memory parity)', () => { - it('markCanceled removes queueCustody from message', async () => { +describe('#1269 R8 P1-2: cancel keeps Message and Queue storage independent', () => { + it('markCanceled terminalizes delivery without creating Queue state on Message', async () => { const store = new MessageStore(); const threadId = `r8-p1-2-cancel-${Date.now()}`; - // Append with queueCustody already set (simulates initialized custody) - const q = store.append({ - userId: 'u1', - catId: null, - content: 'work-to-cancel', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'work-to-cancel', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Cancel const result = store.markCanceled(q.id); assert.ok(result, 'markCanceled should return a result'); assert.equal(result.deliveryStatus, 'canceled', 'Status should be canceled'); - assert.equal(result.queueCustody, undefined, 'queueCustody should be cleared after cancel'); + assert.equal(result.queueCustody, undefined, 'History must not mirror Queue state'); // Verify via getById too const after = await store.getById(q.id); assert.equal(after?.deliveryStatus, 'canceled'); - assert.equal(after?.queueCustody, undefined); + assert.equal(after?.queueCustody, undefined, 'History must not mirror Queue state'); }); }); diff --git a/packages/api/test/cursor-order-remaining.test.js b/packages/api/test/cursor-order-remaining.test.js index 0067350b41..c17449a8b7 100644 --- a/packages/api/test/cursor-order-remaining.test.js +++ b/packages/api/test/cursor-order-remaining.test.js @@ -7,6 +7,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { parseCursor, cursorFor } = await import('../dist/domains/cats/services/stores/cursor.js'); const { MessageStore, generateSortableId } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -22,15 +23,26 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const baseTs = Date.now() - 10000; // Simulate legacy messages (direct, no queuing) - const l1 = store.append({ userId: 'u1', catId: null, content: 'L1', mentions: [], timestamp: baseTs, threadId }); - const l2 = store.append({ - userId: 'u1', - catId: null, - content: 'L2', - mentions: [], - timestamp: baseTs + 100, - threadId, - }); + const l1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'L1', + mentions: [], + timestamp: baseTs, + threadId, + }), + ); + const l2 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'L2', + mentions: [], + timestamp: baseTs + 100, + threadId, + }), + ); // Read the legacy region const legacyPage = store.getByThreadAfter(threadId); @@ -39,14 +51,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { assert.equal(legacyPage[1].id, l2.id); // Append new messages — must have seqs strictly above legacy - const _n1 = store.append({ - userId: 'u1', - catId: null, - content: 'N1', - mentions: [], - timestamp: baseTs + 5000, - threadId, - }); + const _n1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'N1', + mentions: [], + timestamp: baseTs + 5000, + threadId, + }), + ); const fullPage = store.getByThreadAfter(threadId); assert.equal(fullPage.length, 3); @@ -65,22 +79,26 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red11-${Date.now()}`; - const _m1 = store.append({ - userId: 'u1', - catId: null, - content: 'A-will-delete', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); - const m2 = store.append({ - userId: 'u1', - catId: null, - content: 'B-live', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); + const _m1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'A-will-delete', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); + const m2 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'B-live', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); // Record B's seq const page1 = store.getByThreadAfter(threadId); @@ -91,14 +109,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const highestSeq = page1Full[page1Full.length - 1].visibilitySeq; // Now append another message — its seq must be > highestSeq - const m3 = store.append({ - userId: 'u1', - catId: null, - content: 'C-after-delete', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const m3 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'C-after-delete', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const page2 = store.getByThreadAfter(threadId); const m3Item = page2.find((m) => m.id === m3.id); @@ -120,14 +140,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red16-${Date.now()}`; - const m = store.append({ - userId: 'u1', - catId: null, - content: 'legacy-msg', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const m = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'legacy-msg', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); // From after-path: carries visibilitySeq → v2 cursor const page = store.getByThreadAfter(threadId); @@ -159,7 +181,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const msgs = []; for (let i = 0; i < 100; i++) { msgs.push( - store.append({ userId: 'u1', catId: null, content: `m${i}`, mentions: [], timestamp: Date.now(), threadId }), + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: `m${i}`, + mentions: [], + timestamp: Date.now(), + threadId, + }), + ), ); } @@ -182,27 +213,31 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red20-${Date.now()}`; - const _m1 = store.append({ - userId: 'u1', - catId: null, - content: 'will-survive', - mentions: [], - timestamp: Date.now() + 100000, - threadId, - }); + const _m1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'will-survive', + mentions: [], + timestamp: Date.now() + 100000, + threadId, + }), + ); const page1 = store.getByThreadAfter(threadId); const prevHwm = page1[0].visibilitySeq; // Append with earlier timestamp (simulates clock rollback) - const m2 = store.append({ - userId: 'u1', - catId: null, - content: 'after-rollback', - mentions: [], - timestamp: Date.now() - 100000, - threadId, - }); + const m2 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'after-rollback', + mentions: [], + timestamp: Date.now() - 100000, + threadId, + }), + ); const page2 = store.getByThreadAfter(threadId); const newSeq = page2.find((m) => m.id === m2.id)?.visibilitySeq; @@ -216,14 +251,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const store = new MessageStore(); const threadId = `red21-${Date.now()}`; - const _m1 = store.append({ - userId: 'u1', - catId: null, - content: 'msg1', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const _m1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'msg1', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const page1 = store.getByThreadAfter(threadId); const firstSeq = page1[0].visibilitySeq; @@ -231,14 +268,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { store.deleteByThread(threadId); // New append — seq must continue above the old hwm - const _m2 = store.append({ - userId: 'u1', - catId: null, - content: 'after-delete', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const _m2 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'after-delete', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const page2 = store.getByThreadAfter(threadId); assert.equal(page2.length, 1); assert.ok(page2[0].visibilitySeq > firstSeq, 'Seq must continue above old hwm after thread clear'); @@ -277,14 +316,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const threadId = `red25-${Date.now()}`; for (let i = 0; i < 500; i++) { - store.append({ - userId: 'u1', - catId: null, - content: `msg-${i}`, - mentions: [], - timestamp: Date.now() - (500 - i) * 10, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: `msg-${i}`, + mentions: [], + timestamp: Date.now() - (500 - i) * 10, + threadId, + }), + ); } // Paginate in chunks of 100 @@ -309,14 +350,16 @@ describe('Cursor Order — Remaining RED tests (§8.8)', () => { const msgs = []; for (let i = 0; i < 5; i++) { msgs.push( - store.append({ - userId: 'u1', - catId: null, - content: `legacy-${i}`, - mentions: [], - timestamp: Date.now() - (5 - i) * 1000, - threadId, - }), + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: `legacy-${i}`, + mentions: [], + timestamp: Date.now() - (5 - i) * 1000, + threadId, + }), + ), ); } diff --git a/packages/api/test/cursor-order-route-regression.test.js b/packages/api/test/cursor-order-route-regression.test.js index 57808679f7..c5b2481ad4 100644 --- a/packages/api/test/cursor-order-route-regression.test.js +++ b/packages/api/test/cursor-order-route-regression.test.js @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; /** * Cross-format-aware in-memory read state store. @@ -95,53 +96,33 @@ function createCrossFormatAwareStore() { } function appendTerminalManagedHold(messageStore, threadId, { ownerUserId, hiddenTrigger = false, suffix }) { - const custody = { - version: 1, - entryId: `entry-${suffix}`, - revision: 1, - ownerUserId, - intent: 'managed command wake', - status: 'queued', - allTargetCats: ['opus5'], - pendingTargetCats: ['opus5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 2000, - updatedAt: 2000, - }; - const message = messageStore.append({ - userId: 'scheduler', - catId: null, - content: `managed command ${suffix}`, - mentions: [], - timestamp: 2000, - threadId, - deliveryStatus: 'queued', - queueCustody: custody, - ...(hiddenTrigger ? { extra: { scheduler: { hiddenTrigger: true } } } : {}), - source: { - connector: 'hold-ball', - label: '持球结果', - icon: '🏓', - meta: { taskId: `task-${suffix}`, threadId, catId: 'opus5', wakeWhen: true }, - }, - }); - messageStore.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: { - ...custody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - failedByCatIds: ['opus5'], - updatedAt: 2100, - }, - deliveredAt: 2100, - }); + const message = messageStore.append( + canonicalTestMessageInput({ + userId: ownerUserId ?? 'scheduler', + from: { kind: 'system', service: 'hold-ball' }, + catId: null, + content: `managed command ${suffix}`, + mentions: [], + timestamp: 2000, + threadId, + deliveryStatus: 'queued', + ...(hiddenTrigger ? { extra: { scheduler: { hiddenTrigger: true } } } : {}), + source: { + connector: 'hold-ball', + label: '持球结果', + icon: '🏓', + meta: { + managedHold: true, + phase: 'wake', + taskId: `task-${suffix}`, + threadId, + catId: 'opus5', + wakeWhen: true, + }, + }, + }), + ); + messageStore.markDelivered(message.id, 2100); return message; } @@ -179,22 +160,26 @@ describe('#1200 R14 route: POST /read/latest cross-format', () => { process.env.VISIBILITY_CURSOR_V2 = 'on'; const thread = threadStore.create('alice', 'Thread A'); - const msgA = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'early message', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - const msgC = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'latest message', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgA = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'early message', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + const msgC = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'latest message', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); // Seed stored cursor at msgA's raw ID (v1) readStateStore._seed('alice', thread.id, msgA.id); @@ -259,22 +244,26 @@ describe('#1200 R14 route: POST /read/latest cross-format', () => { it('stored v1 B, B tombstoned, latest=A (earlier) → no regression', async () => { const thread = threadStore.create('alice', 'Thread B'); - const msgA = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'message A (earlier, live)', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - const msgB = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'message B (later, will be tombstoned)', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgA = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'message A (earlier, live)', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + const msgB = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'message B (later, will be tombstoned)', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); // Seed stored cursor at B's raw ID (v1) readStateStore._seed('alice', thread.id, msgB.id); @@ -302,22 +291,26 @@ describe('#1200 R14 route: POST /read/latest cross-format', () => { process.env.VISIBILITY_CURSOR_V2 = 'on'; try { const thread = threadStore.create('alice', 'Thread canonical monotonicity'); - const msgA = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'message A (earlier, live)', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - const msgB = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'message B (later, will be tombstoned)', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgA = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'message A (earlier, live)', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + const msgB = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'message B (later, will be tombstoned)', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); const cursorB = messageStore.canonicalizeCursor(msgB.id, thread.id); readStateStore._seed('alice', thread.id, cursorB, cursorB); messageStore.softDelete(msgB.id, 'admin'); @@ -375,22 +368,26 @@ describe('#1200 R14 route: POST /read/mark-all cross-format', () => { process.env.VISIBILITY_CURSOR_V2 = 'on'; const thread = threadStore.create('alice', 'Thread X'); - const msgA = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'early msg', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'latest msg', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgA = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'early msg', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'latest msg', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); // Seed stored cursor at A's raw ID (v1) readStateStore._seed('alice', thread.id, msgA.id); @@ -448,22 +445,26 @@ describe('#1200 R14 route: POST /read/mark-all cross-format', () => { it('stored v1 B, B tombstoned, latest=A (earlier) → no regression', async () => { const thread = threadStore.create('alice', 'Thread Y'); - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'message A (earlier)', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - const msgB = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'message B (later, tombstoned)', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'message A (earlier)', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + const msgB = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'message B (later, tombstoned)', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); readStateStore._seed('alice', thread.id, msgB.id); messageStore.softDelete(msgB.id, 'admin'); @@ -510,22 +511,26 @@ describe('#1200 R14 route: PATCH /read cross-format', () => { it('pre-reconcile upgrades v1 → v2, then advances to incoming v2', async () => { const thread = threadStore.create('alice', 'Thread P'); - const msgA = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'early', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - const msgC = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'later', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgA = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'early', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + const msgC = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'later', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); // Seed stored cursor at A's raw ID (v1) readStateStore._seed('alice', thread.id, msgA.id); @@ -645,16 +650,18 @@ describe('#1200 R14 route: PATCH /read cross-format', () => { it('does not ACK a mutable stream until the same message is finally delivered', async () => { const thread = threadStore.create('alice', 'Mutable stream PATCH boundary'); - const stream = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - content: 'partial stream', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - origin: 'stream', - deliveryStatus: 'queued', - }); + const stream = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + content: 'partial stream', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + origin: 'stream', + deliveryStatus: 'queued', + }), + ); const beforeFinal = await app.inject({ method: 'PATCH', @@ -759,14 +766,16 @@ describe('#1200 R14 route: PATCH /read cross-format', () => { it('stored v1 for pruned message → explicit read evidence repairs the slot', async () => { const thread = threadStore.create('alice', 'Thread Q'); - const msgLive = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'live message', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgLive = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'live message', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); // Seed with a v1 cursor for a message that doesn't exist in the store. // canonicalizeCursor returns the raw ID unchanged, proving that the old @@ -795,22 +804,26 @@ describe('#1200 R14 route: PATCH /read cross-format', () => { process.env.VISIBILITY_CURSOR_V2 = 'on'; try { const thread = threadStore.create('alice', 'Anchored monotonic read'); - const msgA = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'earlier visible message', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - const msgB = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'later read position', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgA = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'earlier visible message', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + const msgB = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'later read position', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); const cursorB = messageStore.canonicalizeCursor(msgB.id, thread.id); const stalePrimary = '0000000000000001-pruned-primary'; readStateStore._seed('alice', thread.id, stalePrimary, cursorB); @@ -871,30 +884,36 @@ describe('#1269 route: PATCH /read OFF→ON→OFF activation lifecycle', () => { it('OFF→ON→OFF: untouched v1, advance to v2, rollback preserves v2', async () => { const thread = threadStore.create('alice', 'Gate lifecycle thread'); - const msgA = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'msg A', - mentions: [], - timestamp: Date.now() - 3000, - threadId: thread.id, - }); - const msgB = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'msg B', - mentions: [], - timestamp: Date.now() - 2000, - threadId: thread.id, - }); - const msgC = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'msg C', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + const msgA = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'msg A', + mentions: [], + timestamp: Date.now() - 3000, + threadId: thread.id, + }), + ); + const msgB = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'msg B', + mentions: [], + timestamp: Date.now() - 2000, + threadId: thread.id, + }), + ); + const msgC = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'msg C', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); // Phase 1: OFF — untouched slot, ack with msgA → stored as v1 delete process.env.VISIBILITY_CURSOR_V2; @@ -969,7 +988,7 @@ describe('#1200 R14: sentinel production lifecycle (checker → service → reso getByThreadAfter: async () => [], }, queueChecker: { - getQueuedForThread: () => [{ source: 'user', content: 'queued msg from user', callerCatId: undefined }], + getQueuedForThread: () => [{ from: { kind: 'user', userId }, content: 'queued msg from user' }], }, }); diff --git a/packages/api/test/cursor-order-sol-remaining.test.js b/packages/api/test/cursor-order-sol-remaining.test.js index 7de8a5a78d..681e740c3f 100644 --- a/packages/api/test/cursor-order-sol-remaining.test.js +++ b/packages/api/test/cursor-order-sol-remaining.test.js @@ -22,6 +22,7 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -227,30 +228,36 @@ describe('P2-5: Tombstone store parity — getByThreadAfter keeps tombstones', ( const store = new MessageStore(); const threadId = `tombstone-mem-${Date.now()}`; - const _m1 = store.append({ - userId: 'u1', - catId: null, - content: 'keep', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); - const m2 = store.append({ - userId: 'u1', - catId: null, - content: 'delete-me', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - const _m3 = store.append({ - userId: 'u1', - catId: null, - content: 'also-keep', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const _m1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'keep', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); + const m2 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'delete-me', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + const _m3 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'also-keep', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); store.softDelete(m2.id, 'admin'); @@ -268,30 +275,36 @@ describe('P2-5: Tombstone store parity — getByThreadAfter keeps tombstones', ( const store = new RedisMessageStore(r); const threadId = `tombstone-redis-${Date.now()}`; - const m1 = await store.append({ - userId: 'u1', - catId: null, - content: 'keep-redis', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); - const m2 = await store.append({ - userId: 'u1', - catId: null, - content: 'delete-me-redis', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - const m3 = await store.append({ - userId: 'u1', - catId: null, - content: 'also-keep-redis', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const m1 = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'keep-redis', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); + const m2 = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'delete-me-redis', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + const m3 = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'also-keep-redis', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); await store.softDelete(m2.id, 'admin'); @@ -312,14 +325,16 @@ describe('P2-6: append return value must include visibilitySeq', () => { const store = new MessageStore(); const threadId = `append-ret-mem-${Date.now()}`; - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'direct', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'direct', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); assert.ok(msg.visibilitySeq != null, 'Direct append must return visibilitySeq'); assert.equal(typeof msg.visibilitySeq, 'number', 'visibilitySeq must be a number'); @@ -334,15 +349,17 @@ describe('P2-6: append return value must include visibilitySeq', () => { // #1269: use non-cat-speech (catId: null) to test hidden queued work. // Timeline-published cat speech (catId: 'opus') gets visibilitySeq at append; // hidden queued scheduler/system work does not. - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'queued', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'queued', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); assert.equal(msg.visibilitySeq, undefined, 'Hidden queued append must NOT have visibilitySeq'); }); @@ -355,14 +372,16 @@ describe('P2-6: append return value must include visibilitySeq', () => { const store = new RedisMessageStore(r); const threadId = `append-ret-redis-${Date.now()}`; - const msg = await store.append({ - userId: 'u1', - catId: null, - content: 'direct-redis', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'direct-redis', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); assert.ok(msg.visibilitySeq != null, 'Redis direct append must return visibilitySeq'); assert.equal(typeof msg.visibilitySeq, 'number', 'visibilitySeq must be a number'); @@ -505,14 +524,16 @@ describe('Lua hwm guard: NaN and fractional rejection', () => { // First: do a normal append to trigger ensureVisibilityMigrated (sets migration flag). // THEN poison the hwm — this ensures the guard is hit on the NEXT append, // not overwritten by the migration logic. - await store.append({ - userId: 'u1', - catId: null, - content: 'trigger-migration', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'trigger-migration', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); // Poison the hwm with NaN AFTER migration has run const metaKey = `msg:visibility-meta:${threadId}`; @@ -521,14 +542,16 @@ describe('Lua hwm guard: NaN and fractional rejection', () => { // Second append should fail-closed with VISIBILITY_HWM_NAN error await assert.rejects( () => - store.append({ - userId: 'u1', - catId: null, - content: 'test', - mentions: [], - timestamp: Date.now(), - threadId, - }), + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'test', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ), (err) => { assert.ok(err.message.includes('VISIBILITY_HWM_NAN'), `Expected NaN error, got: ${err.message}`); return true; @@ -557,28 +580,32 @@ describe('Lua hwm guard: NaN and fractional rejection', () => { const store = new RedisMessageStore(r); // Trigger migration first, then poison - await store.append({ - userId: 'u1', - catId: null, - content: 'trigger-migration', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'trigger-migration', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); const metaKey = `msg:visibility-meta:${threadId}`; await r.hset(metaKey, 'hwm', '1.5'); await assert.rejects( () => - store.append({ - userId: 'u1', - catId: null, - content: 'test', - mentions: [], - timestamp: Date.now(), - threadId, - }), + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'test', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ), (err) => { assert.ok(err.message.includes('VISIBILITY_HWM_INVALID'), `Expected INVALID error, got: ${err.message}`); return true; @@ -610,27 +637,31 @@ describe('Lua hwm guard: NaN and fractional rejection', () => { // migration-complete flag. Without this, markDelivered's internal call // to ensureVisibilityMigrated re-runs migration and overwrites the // poisoned hwm (Sol R4 P2-5: queued append may not set the flag). - await store.append({ - userId: 'u1', - catId: null, - content: 'trigger-migration', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'trigger-migration', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); // Step 2: Hidden queued work (non-cat-speech) — no visibilitySeq at append, // so markDelivered enters the HWM allocation path. Timeline-published cat // speech (catId: 'opus') already has visibilitySeq and would skip HWM. - const msg = await store.append({ - userId: 'scheduler', - catId: null, - content: 'queued-msg', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: 'queued-msg', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Step 3: Poison hwm AFTER migration flag is set await r.hset(metaKey, 'hwm', 'nan'); @@ -1092,26 +1123,30 @@ describe('Sol R5: HWM reject zero-mutation (comprehensive)', () => { try { // Trigger migration flag - await store.append({ - userId: 'u1', - catId: null, - content: 'trigger', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'trigger', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); // Hidden queued work (non-cat-speech) — no visibilitySeq at append, // so markDelivered enters the HWM allocation path. - const msg = await store.append({ - userId: 'scheduler', - catId: null, - content: 'queued-msg', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: null, + content: 'queued-msg', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Snapshot pre-rejection state const hashBefore = await r.hgetall(`msg:${msg.id}`); @@ -1155,14 +1190,16 @@ describe('Sol R5: HWM reject zero-mutation (comprehensive)', () => { try { // Trigger migration - const seed = await store.append({ - userId: 'u1', - catId: null, - content: 'seed', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); + const seed = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'seed', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); // Snapshot post-seed state const threadZsetBefore = await r.zrangebyscore(`msg:thread:${threadId}`, '-inf', '+inf', 'WITHSCORES'); @@ -1175,14 +1212,16 @@ describe('Sol R5: HWM reject zero-mutation (comprehensive)', () => { // Append should reject await assert.rejects( () => - store.append({ - userId: 'u1', - catId: null, - content: 'rejected', - mentions: [], - timestamp: Date.now(), - threadId, - }), + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'rejected', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ), /VISIBILITY_HWM_NAN/, ); @@ -1345,14 +1384,16 @@ describe('Sol R6 P2-1: Redis hydrateMessages includes visibilitySeq', () => { const catId = 'opus'; // Append a message that mentions a cat (so getRecentMentionsFor picks it up) - const msg = await store.append({ - userId: 'u1', - catId: null, - content: `Hey @${catId} check this`, - mentions: [catId], - timestamp: Date.now(), - threadId, - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: `Hey @${catId} check this`, + mentions: [catId], + timestamp: Date.now(), + threadId, + }), + ); // Verify append returned visibilitySeq assert.ok(msg.visibilitySeq != null, 'append must return visibilitySeq'); @@ -1569,30 +1610,36 @@ describe('Codex R9 P1: getLatestVisibleCursor skips soft-deleted (tombstoned) me const memStore = new MessageStore(); const threadId = `tombstone-mem-latest-${Date.now()}`; - const _msgA = memStore.append({ - userId: 'u1', - catId: null, - content: 'message A (live)', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); - const msgB = memStore.append({ - userId: 'u1', - catId: null, - content: 'message B (live)', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - const msgC = memStore.append({ - userId: 'u1', - catId: null, - content: 'message C (will be tombstoned)', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const _msgA = memStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'message A (live)', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); + const msgB = memStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'message B (live)', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + const msgC = memStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'message C (will be tombstoned)', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); // Soft-delete C memStore.softDelete(msgC.id, 'admin'); @@ -1611,14 +1658,16 @@ describe('Codex R9 P1: getLatestVisibleCursor skips soft-deleted (tombstoned) me const memStore = new MessageStore(); const threadId = `tombstone-mem-all-del-${Date.now()}`; - const msgOnly = memStore.append({ - userId: 'u1', - catId: null, - content: 'sole message', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const msgOnly = memStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'sole message', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); memStore.softDelete(msgOnly.id, 'admin'); const result = memStore.getLatestVisibleCursor(threadId); @@ -1657,30 +1706,36 @@ describe('Codex R9 P1: getLatestVisibleCursor skips soft-deleted (tombstoned) me const threadId = `tombstone-redis-latest-${Date.now()}`; - const _msgA = await redisStore.append({ - userId: 'u1', - catId: null, - content: 'message A (live)', - mentions: [], - timestamp: Date.now() - 2000, - threadId, - }); - const msgB = await redisStore.append({ - userId: 'u1', - catId: null, - content: 'message B (live)', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - const msgC = await redisStore.append({ - userId: 'u1', - catId: null, - content: 'message C (will be tombstoned)', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const _msgA = await redisStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'message A (live)', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); + const msgB = await redisStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'message B (live)', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + const msgC = await redisStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'message C (will be tombstoned)', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); // Soft-delete C: softDelete(id, deletedBy) await redisStore.softDelete(msgC.id, 'admin'); @@ -1699,14 +1754,16 @@ describe('Codex R9 P1: getLatestVisibleCursor skips soft-deleted (tombstoned) me const threadId = `tombstone-redis-all-del-${Date.now()}`; - const msgOnly = await redisStore.append({ - userId: 'u1', - catId: null, - content: 'sole message', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const msgOnly = await redisStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'sole message', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); await redisStore.softDelete(msgOnly.id, 'admin'); const result = await redisStore.getLatestVisibleCursor(threadId); diff --git a/packages/api/test/cursor-v2-allocator.test.js b/packages/api/test/cursor-v2-allocator.test.js index 08b2b854dd..47a0ec8cc9 100644 --- a/packages/api/test/cursor-v2-allocator.test.js +++ b/packages/api/test/cursor-v2-allocator.test.js @@ -10,6 +10,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { parseCursor } = await import('../dist/domains/cats/services/stores/cursor.js'); @@ -20,14 +21,16 @@ describe('#1200 P1-A: allocator uses server time, not payload timestamp', () => const threadId = `p1a-future-${Date.now()}`; const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000; // +1 year - store.append({ - userId: 'u1', - catId: null, - content: 'far-future', - mentions: [], - timestamp: farFuture, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'far-future', + mentions: [], + timestamp: farFuture, + threadId, + }), + ); const page = store.getByThreadAfter(threadId); assert.equal(page.length, 1); @@ -44,16 +47,36 @@ describe('#1200 P1-A: allocator uses server time, not payload timestamp', () => const threadId = `p1a-mono-${Date.now()}`; // Append with past, normal, and far-future timestamps - store.append({ userId: 'u1', catId: null, content: 'past', mentions: [], timestamp: 1000, threadId }); - store.append({ userId: 'u1', catId: null, content: 'normal', mentions: [], timestamp: Date.now(), threadId }); - store.append({ - userId: 'u1', - catId: null, - content: 'future', - mentions: [], - timestamp: Date.now() + 1_000_000_000, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'past', + mentions: [], + timestamp: 1000, + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'normal', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'future', + mentions: [], + timestamp: Date.now() + 1_000_000_000, + threadId, + }), + ); const page = store.getByThreadAfter(threadId); assert.equal(page.length, 3); @@ -80,15 +103,17 @@ describe('#1200 P1-A: allocator uses server time, not payload timestamp', () => const store = new MessageStore(); const threadId = `p1a-deliver-${Date.now()}`; - const q = store.append({ - userId: 'u1', - catId: 'opus', - content: 'queued', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'queued', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Deliver with a far-future timestamp — seq must NOT use it const farFutureDeliveredAt = Date.now() + 365 * 24 * 60 * 60 * 1000; diff --git a/packages/api/test/cursor-v2-store-integration.test.js b/packages/api/test/cursor-v2-store-integration.test.js index 57fe59b142..60df07365d 100644 --- a/packages/api/test/cursor-v2-store-integration.test.js +++ b/packages/api/test/cursor-v2-store-integration.test.js @@ -11,6 +11,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { parseCursor, cursorFor } = await import('../dist/domains/cats/services/stores/cursor.js'); @@ -20,14 +21,16 @@ describe('#1200 v2 cursor in getByThreadAfter (§8.7 graded issuance)', () => { const store = new MessageStore(); const threadId = `cursor-v2-test-${Date.now()}`; - store.append({ - userId: 'u1', - catId: null, - content: 'hello', - mentions: [], - timestamp: Date.now(), - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'hello', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const page = store.getByThreadAfter(threadId); assert.equal(page.length, 1); @@ -43,22 +46,26 @@ describe('#1200 v2 cursor in getByThreadAfter (§8.7 graded issuance)', () => { const store = new MessageStore(); const threadId = `cursor-v2-roundtrip-${Date.now()}`; - store.append({ - userId: 'u1', - catId: null, - content: 'msg1', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - store.append({ - userId: 'u1', - catId: null, - content: 'msg2', - mentions: [], - timestamp: Date.now(), - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'msg1', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'msg2', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const page1 = store.getByThreadAfter(threadId, undefined, 1); assert.equal(page1.length, 1); @@ -76,22 +83,26 @@ describe('#1200 v2 cursor in getByThreadAfter (§8.7 graded issuance)', () => { const store = new MessageStore(); const threadId = `cursor-v1-compat-${Date.now()}`; - const msg1 = store.append({ - userId: 'u1', - catId: null, - content: 'first', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - store.append({ - userId: 'u1', - catId: null, - content: 'second', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const msg1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'first', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'second', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); // Pass raw message ID (v1 cursor) — backward compat const page = store.getByThreadAfter(threadId, msg1.id); @@ -106,16 +117,36 @@ describe('#1200 getLatestVisibleCursor (§8.7 read-state)', () => { const store = new MessageStore(); const threadId = `latest-vis-cursor-${Date.now()}`; - store.append({ userId: 'u1', catId: null, content: 'first', mentions: [], timestamp: Date.now() - 2000, threadId }); - store.append({ - userId: 'u1', - catId: null, - content: 'second', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); - store.append({ userId: 'u1', catId: null, content: 'third', mentions: [], timestamp: Date.now(), threadId }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'first', + mentions: [], + timestamp: Date.now() - 2000, + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'second', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'third', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const result = store.getLatestVisibleCursor(threadId); assert.ok(result, 'Should return a cursor'); @@ -132,26 +163,30 @@ describe('#1200 getLatestVisibleCursor (§8.7 read-state)', () => { const store = new MessageStore(); const threadId = `latest-skip-queued-${Date.now()}`; - store.append({ - userId: 'u1', - catId: null, - content: 'direct', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'direct', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + }), + ); // #1269: hidden queued work (non-cat-speech) is not timeline-published, // so it has no visibilitySeq and is not returned by getLatestVisibleCursor. // Timeline-published cat speech (catId: 'opus') WOULD be visible at append. - store.append({ - userId: 'scheduler', - catId: 'system', - content: 'queued-hidden', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: 'queued-hidden', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); const result = store.getLatestVisibleCursor(threadId); assert.ok(result, 'Should return a cursor'); @@ -174,23 +209,27 @@ describe('#1200 getLatestVisibleCursor (§8.7 read-state)', () => { const threadId = `latest-late-deliver-${Date.now()}`; const baseTs = Date.now() - 10000; - const c = store.append({ - userId: 'u1', - catId: null, - content: 'C-direct', - mentions: [], - timestamp: baseTs + 100, - threadId, - }); - const q = store.append({ - userId: 'u1', - catId: 'opus', - content: 'Q-queued', - mentions: [], - timestamp: baseTs + 50, - threadId, - deliveryStatus: 'queued', - }); + const c = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'C-direct', + mentions: [], + timestamp: baseTs + 100, + threadId, + }), + ); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'Q-queued', + mentions: [], + timestamp: baseTs + 50, + threadId, + deliveryStatus: 'queued', + }), + ); store.markDelivered(q.id, baseTs + 200); const result = store.getLatestVisibleCursor(threadId); @@ -207,14 +246,16 @@ describe('#1200 canonicalizeCursor (§8.7 CAS ingress)', () => { const store = new MessageStore(); const threadId = `canon-delivered-${Date.now()}`; - const m = store.append({ - userId: 'u1', - catId: null, - content: 'direct', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const m = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'direct', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const cursor = store.canonicalizeCursor(m.id, threadId); assert.ok(cursor.startsWith('v2:'), `Expected v2 cursor, got "${cursor}"`); @@ -232,15 +273,17 @@ describe('#1200 canonicalizeCursor (§8.7 CAS ingress)', () => { // #1269: hidden queued work (non-cat-speech) has no visibilitySeq → raw ID fallback. // Timeline-published cat speech (catId: 'opus') would get v2 at append. - const q = store.append({ - userId: 'scheduler', - catId: 'system', - content: 'queued-hidden', - mentions: [], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - }); + const q = store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: 'queued-hidden', + mentions: [], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + }), + ); // Hidden queued message has no visibility position → falls back to raw ID const cursor = store.canonicalizeCursor(q.id, threadId); @@ -252,15 +295,17 @@ describe('#1200 canonicalizeCursor (§8.7 CAS ingress)', () => { const store = new MessageStore(); const threadId = `canon-late-${Date.now()}`; - const q = store.append({ - userId: 'u1', - catId: 'opus', - content: 'late-Q', - mentions: [], - timestamp: Date.now() - 1000, - threadId, - deliveryStatus: 'queued', - }); + const q = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'late-Q', + mentions: [], + timestamp: Date.now() - 1000, + threadId, + deliveryStatus: 'queued', + }), + ); store.markDelivered(q.id, Date.now()); const cursor = store.canonicalizeCursor(q.id, threadId); @@ -282,14 +327,16 @@ describe('#1200 canonicalizeCursor (§8.7 CAS ingress)', () => { const threadA = `canon-xthread-a-${Date.now()}`; const threadB = `canon-xthread-b-${Date.now()}`; - const m = store.append({ - userId: 'u1', - catId: null, - content: 'in-thread-A', - mentions: [], - timestamp: Date.now(), - threadId: threadA, - }); + const m = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'in-thread-A', + mentions: [], + timestamp: Date.now(), + threadId: threadA, + }), + ); // Canonicalize with correct thread → v2 const correct = store.canonicalizeCursor(m.id, threadA); @@ -305,14 +352,16 @@ describe('#1200 canonicalizeCursor (§8.7 CAS ingress)', () => { const store = new MessageStore(); const threadId = `canon-lex-${Date.now()}`; - const m = store.append({ - userId: 'u1', - catId: null, - content: 'test', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const m = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'test', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const v2Cursor = store.canonicalizeCursor(m.id, threadId); const v1Cursor = m.id; diff --git a/packages/api/test/defer-person-memory-routes.test.js b/packages/api/test/defer-person-memory-routes.test.js index f2341c8f0f..e26683416e 100644 --- a/packages/api/test/defer-person-memory-routes.test.js +++ b/packages/api/test/defer-person-memory-routes.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('F276 defer-person-memory callback', () => { let app; @@ -71,15 +72,17 @@ describe('F276 defer-person-memory callback', () => { }); async function ownerMessage(threadId, content, extra = {}) { - return messageStore.append({ - userId: 'owner-1', - catId: null, - content, - mentions: [], - timestamp: Date.now(), - threadId, - ...extra, - }); + return messageStore.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content, + mentions: [], + timestamp: Date.now(), + threadId, + ...extra, + }), + ); } async function invoke(payload, origin) { @@ -185,14 +188,16 @@ describe('F276 defer-person-memory callback', () => { }); it('rejects caller-owned auth fields and cross-owner source laundering', async () => { - const source = await messageStore.append({ - userId: 'owner-2', - catId: null, - content: '黄挺的私密事实', - mentions: [], - timestamp: Date.now(), - threadId: 'thread_other_owner', - }); + const source = await messageStore.append( + canonicalTestMessageInput({ + userId: 'owner-2', + catId: null, + content: '黄挺的私密事实', + mentions: [], + timestamp: Date.now(), + threadId: 'thread_other_owner', + }), + ); const origin = await ownerMessage('thread_current', '延后处理'); const forbidden = await invoke( { diff --git a/packages/api/test/deferred-person-memory-daily-task.test.js b/packages/api/test/deferred-person-memory-daily-task.test.js index fde9cdf8e5..4356602909 100644 --- a/packages/api/test/deferred-person-memory-daily-task.test.js +++ b/packages/api/test/deferred-person-memory-daily-task.test.js @@ -134,7 +134,7 @@ describe('F276 deferred person-memory daily clerk task', () => { assert.equal(calls.bound.length, 2); assert.ok(calls.bound.every((binding) => binding.processingMessageId === 'daily-trigger-message')); assert.equal(calls.delivered[0].threadId, 'thread_memory_operations'); - assert.equal(calls.delivered[0].userId, 'scheduler'); + assert.equal(calls.delivered[0].userId, 'owner-1'); assert.equal(calls.delivered[0].extra.scheduler.hiddenTrigger, true); assert.match(calls.delivered[0].content, new RegExp(receipt.receiptId)); assert.match(calls.delivered[0].content, new RegExp(secondReceipt.receiptId)); diff --git a/packages/api/test/delivery-status.test.js b/packages/api/test/delivery-status.test.js index 2606b0ccc7..22a1e68785 100644 --- a/packages/api/test/delivery-status.test.js +++ b/packages/api/test/delivery-status.test.js @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('F117: deliveryStatus + isDelivered', () => { test('isDelivered returns true for legacy messages (no deliveryStatus)', async () => { @@ -31,14 +32,16 @@ describe('F117: deliveryStatus + isDelivered', () => { test('markCanceled sets deliveryStatus to canceled', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'user-1', - catId: null, - content: 'queued msg', - mentions: [], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'queued msg', + mentions: [], + timestamp: Date.now(), + deliveryStatus: 'queued', + }), + ); const result = store.markCanceled(msg.id); assert.equal(result?.deliveryStatus, 'canceled'); assert.equal(result?.deliveryTransitioned, true); @@ -47,14 +50,16 @@ describe('F117: deliveryStatus + isDelivered', () => { test('markCanceled is a no-op for an already-delivered message', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'user-1', - catId: null, - content: 'already delivered', - mentions: [], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'already delivered', + mentions: [], + timestamp: Date.now(), + deliveryStatus: 'queued', + }), + ); store.markDelivered(msg.id, msg.timestamp); const result = store.markCanceled(msg.id); @@ -72,14 +77,16 @@ describe('F117: deliveryStatus + isDelivered', () => { test('markDelivered sets both deliveredAt and deliveryStatus', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'user-1', - catId: null, - content: 'queued msg', - mentions: [], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'queued msg', + mentions: [], + timestamp: Date.now(), + deliveryStatus: 'queued', + }), + ); const now = Date.now(); const result = store.markDelivered(msg.id, now); assert.equal(result?.deliveredAt, now); @@ -90,14 +97,16 @@ describe('F117: deliveryStatus + isDelivered', () => { test('markDelivered is no-op for legacy messages (deliveryStatus=undefined)', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'user-1', - catId: null, - content: 'immediate msg', - mentions: [], - timestamp: Date.now() - 60_000, - // no deliveryStatus → undefined (legacy/immediate path) - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'immediate msg', + mentions: [], + timestamp: Date.now() - 60_000, + // no deliveryStatus → undefined (legacy/immediate path) + }), + ); assert.equal(msg.deliveryStatus, undefined, 'precondition: no deliveryStatus'); const result = store.markDelivered(msg.id, Date.now()); @@ -113,14 +122,16 @@ describe('F117: deliveryStatus + isDelivered', () => { test('markDelivered is no-op for already-delivered messages', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'user-1', - catId: null, - content: 'already delivered', - mentions: [], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'already delivered', + mentions: [], + timestamp: Date.now(), + deliveryStatus: 'queued', + }), + ); const firstDeliveredAt = Date.now() - 1000; store.markDelivered(msg.id, firstDeliveredAt); assert.equal(msg.deliveryStatus, 'delivered'); @@ -143,35 +154,49 @@ describe('F117: getByThread filters undelivered messages', () => { const now = Date.now(); // legacy message (no deliveryStatus) — should appear - store.append({ userId: 'u1', catId: null, content: 'legacy', mentions: [], timestamp: now }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'legacy', + mentions: [], + timestamp: now, + }), + ); // delivered message — should appear - const delivered = store.append({ - userId: 'u1', - catId: null, - content: 'delivered', - mentions: [], - timestamp: now + 1, - deliveryStatus: 'queued', - }); + const delivered = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'delivered', + mentions: [], + timestamp: now + 1, + deliveryStatus: 'queued', + }), + ); store.markDelivered(delivered.id, now + 1); // queued message — should NOT appear - store.append({ - userId: 'u1', - catId: null, - content: 'queued', - mentions: [], - timestamp: now + 2, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'queued', + mentions: [], + timestamp: now + 2, + deliveryStatus: 'queued', + }), + ); // canceled message — should NOT appear - const canceled = store.append({ - userId: 'u1', - catId: null, - content: 'canceled', - mentions: [], - timestamp: now + 3, - deliveryStatus: 'queued', - }); + const canceled = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'canceled', + mentions: [], + timestamp: now + 3, + deliveryStatus: 'queued', + }), + ); store.markCanceled(canceled.id); const results = store.getByThread('default', 50, 'u1'); @@ -190,39 +215,47 @@ describe('F117: getByThreadAfter filters undelivered messages', () => { const store = new MessageStore(); const now = Date.now(); - const m1 = store.append({ - userId: 'u1', - catId: null, - content: 'delivered', - mentions: [], - timestamp: now, - deliveryStatus: 'queued', - }); + const m1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'delivered', + mentions: [], + timestamp: now, + deliveryStatus: 'queued', + }), + ); store.markDelivered(m1.id, now); - store.append({ - userId: 'u1', - catId: null, - content: 'queued', - mentions: [], - timestamp: now + 1, - deliveryStatus: 'queued', - }); - const canceled = store.append({ - userId: 'u1', - catId: null, - content: 'canceled', - mentions: [], - timestamp: now + 2, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'queued', + mentions: [], + timestamp: now + 1, + deliveryStatus: 'queued', + }), + ); + const canceled = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'canceled', + mentions: [], + timestamp: now + 2, + deliveryStatus: 'queued', + }), + ); store.markCanceled(canceled.id); - store.append({ - userId: 'u1', - catId: null, - content: 'legacy', - mentions: [], - timestamp: now + 3, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'legacy', + mentions: [], + timestamp: now + 3, + }), + ); // Fetch after first message — should only see legacy (delivered compat), not queued/canceled const results = store.getByThreadAfter('default', m1.id, undefined, 'u1'); @@ -298,36 +331,50 @@ describe('F117: getMentionsFor filters undelivered messages', () => { const now = Date.now(); // delivered mention — should appear - const delivered = store.append({ - userId: 'u1', - catId: null, - content: '@gpt52 delivered', - mentions: ['gpt52'], - timestamp: now, - deliveryStatus: 'queued', - }); + const delivered = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@gpt52 delivered', + mentions: ['gpt52'], + timestamp: now, + deliveryStatus: 'queued', + }), + ); store.markDelivered(delivered.id, now); // queued mention — should NOT appear - store.append({ - userId: 'u1', - catId: null, - content: '@gpt52 queued', - mentions: ['gpt52'], - timestamp: now + 1, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@gpt52 queued', + mentions: ['gpt52'], + timestamp: now + 1, + deliveryStatus: 'queued', + }), + ); // canceled mention — should NOT appear - const canceled = store.append({ - userId: 'u1', - catId: null, - content: '@gpt52 canceled', - mentions: ['gpt52'], - timestamp: now + 2, - deliveryStatus: 'queued', - }); + const canceled = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@gpt52 canceled', + mentions: ['gpt52'], + timestamp: now + 2, + deliveryStatus: 'queued', + }), + ); store.markCanceled(canceled.id); // legacy mention (no deliveryStatus) — should appear - store.append({ userId: 'u1', catId: null, content: '@gpt52 legacy', mentions: ['gpt52'], timestamp: now + 3 }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@gpt52 legacy', + mentions: ['gpt52'], + timestamp: now + 3, + }), + ); const mentions = store.getMentionsFor('gpt52', 50, 'u1'); const contents = mentions.map((m) => m.content); @@ -342,23 +389,27 @@ describe('F117: getMentionsFor filters undelivered messages', () => { const store = new MessageStore(); const now = Date.now(); - const delivered = store.append({ - userId: 'u1', - catId: null, - content: '@gpt52 delivered', - mentions: ['gpt52'], - timestamp: now, - deliveryStatus: 'queued', - }); + const delivered = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@gpt52 delivered', + mentions: ['gpt52'], + timestamp: now, + deliveryStatus: 'queued', + }), + ); store.markDelivered(delivered.id, now); - store.append({ - userId: 'u1', - catId: null, - content: '@gpt52 queued', - mentions: ['gpt52'], - timestamp: now + 1, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@gpt52 queued', + mentions: ['gpt52'], + timestamp: now + 1, + deliveryStatus: 'queued', + }), + ); const mentions = store.getRecentMentionsFor('gpt52', 50, 'u1'); assert.equal(mentions.length, 1); @@ -373,14 +424,16 @@ describe('F117: messages_delivered payload includes message data', () => { const store = new MessageStore(); const now = Date.now(); - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'hello cat', - mentions: ['gpt52'], - timestamp: now, - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'hello cat', + mentions: ['gpt52'], + timestamp: now, + deliveryStatus: 'queued', + }), + ); const delivered = store.markDelivered(msg.id, now + 100); @@ -406,14 +459,16 @@ describe('F117: integration regression', () => { const now = Date.now(); // Simulate queue send - const queuedMsg = store.append({ - userId: 'u1', - catId: null, - content: '@gpt52 嘿嘿大猫猫喵', - mentions: ['gpt52'], - timestamp: now, - deliveryStatus: 'queued', - }); + const queuedMsg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '@gpt52 嘿嘿大猫猫喵', + mentions: ['gpt52'], + timestamp: now, + deliveryStatus: 'queued', + }), + ); // Before cancel: message exists but should be invisible assert.equal(isDelivered(queuedMsg), false); diff --git a/packages/api/test/direct-action-carrier-recovery-e2e.test.js b/packages/api/test/direct-action-carrier-recovery-e2e.test.js index 90e31f1b91..a0b8a5bae2 100644 --- a/packages/api/test/direct-action-carrier-recovery-e2e.test.js +++ b/packages/api/test/direct-action-carrier-recovery-e2e.test.js @@ -53,62 +53,45 @@ function carrierLease(sourceThreadId, targetThreadId) { }; } -function appendCarrier(messageStore, lease, state) { +async function appendCarrier(messageStore, invocationQueue, lease, state) { const fence = buildActionSuccessorFence(lease, lease.dispatchId); - return messageStore.append({ + const from = { kind: 'agent', catId: lease.predecessorCatId }; + const message = await messageStore.append({ threadId: lease.holderThreadId, userId: lease.tenantScope, - catId: lease.predecessorCatId, - content: 'Original task implementation carrier', + from, + content: 'Original exact-HEAD review carrier', mentions: ['codex'], origin: 'callback', timestamp: 100, deliveryStatus: 'queued', - queueCustody: { - version: 1, - entryId: 'entry-original-review', - revision: 2, - intent: 'execute', - status: state === 'live' ? 'queued' : 'terminal', - allTargetCats: ['codex'], - pendingTargetCats: state === 'live' ? ['codex'] : [], - notifiedByCatIds: state === 'live' ? ['codex'] : [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: state === 'interrupted' ? ['codex'] : [], - handledByCatIds: [], - carrierByTargetCatId: { - codex: { - entryId: 'entry-original-review', - idempotencyKey: `action:${fence.leaseId}:${fence.generation}:codex`, - actionSuccessorFence: fence, - source: 'agent', - sourceCategory: 'a2a', - callerCatId: lease.predecessorCatId, - a2aTriggerMessageId: 'message-original-review', - autoExecute: true, - createdAt: 100, - }, - }, - ...(state === 'live' ? { carrierStateByTargetCatId: { codex: { status: 'queued' } } } : {}), - targetAttempts: [ - { - id: 'entry-original-review:codex:1', - targetCatId: 'codex', - sequence: 1, - state: state === 'live' ? 'queued' : 'interrupted', - ...(state === 'interrupted' - ? { invocationId: 'invocation-interrupted', terminalReason: 'runtime_restart' } - : {}), - createdAt: 100, - updatedAt: 120, - }, - ], - priority: 'normal', - createdAt: 100, - updatedAt: 120, - }, }); + const admitted = await invocationQueue.enqueueExistingMessageDurable(messageStore, message.id, { + threadId: lease.holderThreadId, + userId: lease.tenantScope, + from, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + content: message.content, + messageId: message.id, + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + sourceCategory: 'a2a', + actionSuccessorFence: fence, + }); + if (state === 'interrupted') { + const terminalized = await invocationQueue.terminalizeEntryDurable( + lease.holderThreadId, + lease.tenantScope, + admitted.entry.id, + 'interrupted', + 'runtime_restart', + ); + assert.ok(terminalized); + assert.equal(invocationQueue.list(lease.holderThreadId, lease.tenantScope).length, 0); + } + return message; } describe('direct action carrier restart recovery', () => { @@ -129,7 +112,7 @@ describe('direct action carrier restart recovery', () => { const threadStore = new ThreadStore(); registry = new InvocationRegistry(); source = await threadStore.create('user-1', 'Author'); - target = await threadStore.create('user-1', 'Implementer'); + target = await threadStore.create('user-1', 'Reviewer'); auth = await registry.create('user-1', 'opus', source.id); lease = carrierLease(source.id, target.id); unavailable = []; @@ -146,7 +129,10 @@ describe('direct action carrier restart recovery', () => { update() {}, get: () => null, }, - queueProcessor: { async tryAutoExecute() {} }, + queueProcessor: { + async requestDrain() {}, + async tryAutoExecute() {}, + }, actionSuccessorAdmissionService: { async admit() { return { admit: false, outcome: 'safe_wait', lease }; @@ -169,7 +155,7 @@ describe('direct action carrier restart recovery', () => { headers: { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }, payload: { threadId: target.id, - content: 'Implement task', + content: 'Review exact HEAD', targetCats: ['codex'], clientMessageId, action, @@ -178,75 +164,64 @@ describe('direct action carrier restart recovery', () => { } test('keeps safe_wait when exact durable custody is live', async () => { - appendCarrier(messageStore, lease, 'live'); + await appendCarrier(messageStore, invocationQueue, lease, 'live'); const response = await post('review-4058-live-reentry'); assert.equal(response.statusCode, 200); assert.equal(response.json().status, 'safe_wait'); - assert.equal(invocationQueue.list(target.id, 'user-1').length, 0); + assert.equal(invocationQueue.list(target.id, 'user-1').length, 1); }); - test('reuses the original generation once after runtime interruption', async () => { - appendCarrier(messageStore, lease, 'interrupted'); + test('re-establishes exact active-generation custody after runtime interruption', async () => { + await appendCarrier(messageStore, invocationQueue, lease, 'interrupted'); const response = await post('review-4058-recover-interrupted'); - assert.equal(response.statusCode, 200); - assert.equal(response.json().status, 'ok'); - assert.deepEqual(response.json().actionLease, { - leaseId: lease.leaseId, - generation: lease.generation, - outcome: 'replayed', - }); - const [replacement] = invocationQueue.list(target.id, 'user-1'); - assert.deepEqual(replacement.actionSuccessorFence, buildActionSuccessorFence(lease, lease.dispatchId)); + assert.equal(response.statusCode, 200, response.body); + const [recovered] = invocationQueue.list(target.id, 'user-1'); + assert.ok(recovered, 'the exact active generation must regain a durable pending carrier'); + assert.deepEqual(recovered.execution.actionSuccessorFence, buildActionSuccessorFence(lease, lease.dispatchId)); assert.deepEqual(unavailable, []); - - const messageCount = messageStore.getByThreadIncludingQueued(target.id, 20, 'user-1').length; - const laterReentry = await post('review-4058-after-recovery'); - assert.equal(laterReentry.json().status, 'safe_wait'); - assert.equal(messageStore.getByThreadIncludingQueued(target.id, 20, 'user-1').length, messageCount); }); - test('same-client retry finishes a crash after the replacement append', async () => { - appendCarrier(messageStore, lease, 'interrupted'); + test('same-client retry observes an atomically admitted replacement carrier', async () => { + await appendCarrier(messageStore, invocationQueue, lease, 'interrupted'); const clientMessageId = 'review-4058-crash-after-append'; - const replacement = messageStore.append({ - threadId: target.id, - userId: 'user-1', - catId: 'opus', - content: 'Implement task', - mentions: ['codex'], - origin: 'callback', - timestamp: 130, - deliveryStatus: 'queued', - idempotencyKey: `action-carrier-recovery:${lease.leaseId}:${lease.generation}`, - }); + const fence = buildActionSuccessorFence(lease, lease.dispatchId); + const replacement = await invocationQueue.appendAndEnqueueDurable( + messageStore, + { + threadId: target.id, + userId: 'user-1', + from: { kind: 'agent', catId: 'opus' }, + content: 'Review exact HEAD', + mentions: ['codex'], + origin: 'callback', + timestamp: 130, + deliveryStatus: 'queued', + idempotencyKey: `action-carrier-recovery:${lease.leaseId}:${lease.generation}`, + }, + { + threadId: target.id, + userId: 'user-1', + from: { kind: 'agent', catId: 'opus' }, + kind: 'message_wake', + ownerAuthProvenance: 'strict', + content: 'Review exact HEAD', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + sourceCategory: 'a2a', + actionSuccessorFence: fence, + }, + ); assert.equal(await registry.claimClientMessageId(auth.invocationId, clientMessageId), true); const response = await post(clientMessageId); assert.equal(response.statusCode, 200); - assert.equal(response.json().messageId, replacement.id); + assert.equal(response.json().status, 'safe_wait'); const [queued] = invocationQueue.list(target.id, 'user-1'); - assert.equal(queued.messageId, replacement.id); - assert.deepEqual(queued.actionSuccessorFence, buildActionSuccessorFence(lease, lease.dispatchId)); - }); - - test('503 names startup reconciliation instead of promising same-client retry delivery', async () => { - appendCarrier(messageStore, lease, 'interrupted'); - messageStore.initializeQueueCustody = async () => { - throw new Error('simulated crash after durable admission'); - }; - - const response = await post('review-4058-admitted-uncommitted'); - - assert.equal(response.statusCode, 503); - assert.deepEqual(response.json(), { - kind: 'action_carrier_recovery_pending', - message: - 'The replacement carrier has durable Queue admission, but delivery is not committed. Runtime startup reconciliation is required to restore Queue delivery; retrying this clientMessageId only confirms the admission.', - messageId: response.json().messageId, - clientMessageId: 'review-4058-admitted-uncommitted', - }); + assert.equal(queued.payload.messageId, replacement.message.id); + assert.deepEqual(queued.execution.actionSuccessorFence, fence); }); }); diff --git a/packages/api/test/draft-messages-merge.test.js b/packages/api/test/draft-messages-merge.test.js index f5af4d9ff9..29bd034350 100644 --- a/packages/api/test/draft-messages-merge.test.js +++ b/packages/api/test/draft-messages-merge.test.js @@ -15,6 +15,7 @@ import Fastify from 'fastify'; import { DraftStore } from '../dist/domains/cats/services/stores/ports/DraftStore.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { messagesRoutes } from '../dist/routes/messages.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; // Minimal mock router that satisfies the type contract function makeStubRouter() { @@ -171,14 +172,16 @@ describe('GET /api/messages — draft merge (#80)', () => { it('includes active drafts on first page (no cursor)', async () => { // Seed a formal message - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'Hello', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'Hello', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); // Seed an active draft draftStore.upsert({ @@ -210,46 +213,29 @@ describe('GET /api/messages — draft merge (#80)', () => { assert.equal(draft.catId, 'opus'); }); - it('hydrates only exposed recall tombstones and never returns their body', async () => { - const custody = (entryId, exposure) => ({ - version: 1, - entryId, - revision: 1, - intent: 'user_message', - status: 'queued', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - notifiedByCatIds: [], - seenByCatIds: exposure ? ['opus'] : [], - seenInvocationIdByCatId: exposure ? { opus: exposure.invocationId } : {}, - ...(exposure ? { bodyExposures: [exposure] } : {}), - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 100, - updatedAt: 100, - }); - const hidden = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'zero exposure secret', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-1', - deliveryStatus: 'queued', - queueCustody: custody('entry-hidden'), - }); - const exposure = { targetCatId: 'opus', invocationId: 'child-read', seenAt: 1_500 }; - const exposed = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'exposed secret', - mentions: ['opus'], - timestamp: 1_100, - threadId: 'thread-1', - deliveryStatus: 'queued', - queueCustody: custody('entry-exposed', exposure), - }); + it('never hydrates new pending-message recall tombstones or their body', async () => { + const hidden = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'zero exposure secret', + mentions: ['opus'], + timestamp: 1_000, + threadId: 'thread-1', + deliveryStatus: 'queued', + }), + ); + const second = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'second pending secret', + mentions: ['opus'], + timestamp: 1_100, + threadId: 'thread-1', + deliveryStatus: 'queued', + }), + ); assert.equal( messageStore.recallMessageToComposerDraft(hidden.id, { ownerUserId: 'user-1', @@ -261,7 +247,7 @@ describe('GET /api/messages — draft merge (#80)', () => { 'recalled', ); assert.equal( - messageStore.recallMessageToComposerDraft(exposed.id, { + messageStore.recallMessageToComposerDraft(second.id, { ownerUserId: 'user-1', threadId: 'thread-1', expectedDraftRevision: 1, @@ -284,25 +270,26 @@ describe('GET /api/messages — draft merge (#80)', () => { messages.some((message) => message.id === hidden.id), false, ); - const tombstone = messages.find((message) => message.id === exposed.id); - assert.ok(tombstone); - assert.equal(tombstone.content, ''); - assert.equal(tombstone.extra.recall.exposure, 'seen'); - assert.deepEqual(tombstone.extra.recall.exposures, [exposure]); - assert.doesNotMatch(JSON.stringify(tombstone), /exposed secret/); + assert.equal( + messages.some((message) => message.id === second.id), + false, + ); + assert.doesNotMatch(JSON.stringify(messages), /zero exposure secret|second pending secret/); }); it('excludes drafts on paginated request (with before cursor)', async () => { // Seed messages const ts = Date.now(); - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'First', - mentions: [], - timestamp: ts - 1000, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'First', + mentions: [], + timestamp: ts - 1000, + threadId: 'thread-1', + }), + ); // Seed a draft draftStore.upsert({ @@ -331,15 +318,17 @@ describe('GET /api/messages — draft merge (#80)', () => { const ts = Date.now(); // Formal message with invocationId in extra.stream - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'Completed message', - mentions: [], - timestamp: ts, - threadId: 'thread-1', - extra: { stream: { invocationId: 'inv-completed' } }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'Completed message', + mentions: [], + timestamp: ts, + threadId: 'thread-1', + extra: { stream: { invocationId: 'inv-completed' } }, + }), + ); // Draft with same invocationId (the race window between append and delete) draftStore.upsert({ @@ -700,14 +689,16 @@ describe('GET /api/messages — draft merge (#80)', () => { }); // Seed a message so user-A gets non-empty response - messageStore.append({ - userId: 'user-A', - catId: null, - content: 'Hi', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-A', + catId: null, + content: 'Hi', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); const app = await buildApp(); const res = await app.inject({ @@ -726,27 +717,31 @@ describe('GET /api/messages — draft merge (#80)', () => { const ts = Date.now(); // 1. Seed the formal message with invocationId (oldest — will be pushed off page) - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'Completed streaming response', - mentions: [], - timestamp: ts, - threadId: 'thread-1', - extra: { stream: { invocationId: 'inv-offpage' } }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'Completed streaming response', + mentions: [], + timestamp: ts, + threadId: 'thread-1', + extra: { stream: { invocationId: 'inv-offpage' } }, + }), + ); // 2. Seed enough newer messages to push formal off the first page // Using limit=5 via query param, so we need 5 newer messages for (let i = 1; i <= 5; i++) { - messageStore.append({ - userId: 'user-1', - catId: null, - content: `Filler message ${i}`, - mentions: [], - timestamp: ts + i * 1000, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: `Filler message ${i}`, + mentions: [], + timestamp: ts + i * 1000, + threadId: 'thread-1', + }), + ); } // 3. Draft with same invocationId (stale — should be deduped by wider query) @@ -785,26 +780,30 @@ describe('GET /api/messages — draft merge (#80)', () => { const ts = Date.now(); // 1. Seed the formal message (will be the 201st oldest → pushed off a 200-message page) - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'Completed at max-limit edge', - mentions: [], - timestamp: ts, - threadId: 'thread-1', - extra: { stream: { invocationId: 'inv-maxlimit' } }, - }); - - // 2. Seed 200 newer messages to push formal off the first page at limit=200 - for (let i = 1; i <= 200; i++) { - messageStore.append({ + messageStore.append( + canonicalTestMessageInput({ userId: 'user-1', - catId: null, - content: `Filler ${i}`, + catId: 'opus', + content: 'Completed at max-limit edge', mentions: [], - timestamp: ts + i * 100, + timestamp: ts, threadId: 'thread-1', - }); + extra: { stream: { invocationId: 'inv-maxlimit' } }, + }), + ); + + // 2. Seed 200 newer messages to push formal off the first page at limit=200 + for (let i = 1; i <= 200; i++) { + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: `Filler ${i}`, + mentions: [], + timestamp: ts + i * 100, + threadId: 'thread-1', + }), + ); } // 3. Stale draft with same invocationId @@ -836,14 +835,16 @@ describe('GET /api/messages — draft merge (#80)', () => { const ts = Date.now(); // Seed a user message so the thread has content - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'Do something', - mentions: [], - timestamp: ts, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'Do something', + mentions: [], + timestamp: ts, + threadId: 'thread-1', + }), + ); // Tool-first draft: no text yet, only tool events draftStore.upsert({ @@ -877,14 +878,16 @@ describe('GET /api/messages — draft merge (#80)', () => { it('draft response includes origin, extra.stream.invocationId, and thinking (Bug A+B contract)', async () => { const ts = Date.now(); - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'Hello', - mentions: [], - timestamp: ts, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'Hello', + mentions: [], + timestamp: ts, + threadId: 'thread-1', + }), + ); draftStore.upsert({ userId: 'user-1', @@ -992,14 +995,16 @@ describe('GET /api/messages — draft merge (#80)', () => { }); // Seed a formal message to have a non-empty page - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'Question', - mentions: [], - timestamp: now - 1000, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'Question', + mentions: [], + timestamp: now - 1000, + threadId: 'thread-1', + }), + ); const app = await buildApp(); const res = await app.inject({ diff --git a/packages/api/test/duty-briefing-delivery.test.js b/packages/api/test/duty-briefing-delivery.test.js index 0fbd18b942..0c4fc503c7 100644 --- a/packages/api/test/duty-briefing-delivery.test.js +++ b/packages/api/test/duty-briefing-delivery.test.js @@ -30,7 +30,7 @@ test('deliverBriefingCard: append origin=briefing + extra.rich card(照 format assert.equal(id, 'msg-1'); assert.equal(appended.threadId, 'thr-x'); assert.equal(appended.userId, 'system'); - assert.equal(appended.catId, null); + assert.deepEqual(appended.from, { kind: 'system', service: 'duty-briefing' }); assert.equal(appended.origin, 'briefing'); assert.equal(appended.timestamp, NOW); assert.deepEqual(appended.extra.rich, { v: 1, blocks: [card()] }); diff --git a/packages/api/test/export-route.test.js b/packages/api/test/export-route.test.js index f490414da7..5493eb8089 100644 --- a/packages/api/test/export-route.test.js +++ b/packages/api/test/export-route.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { formatThreadAsMarkdown, formatThreadAsText } = await import('../dist/routes/export.js'); @@ -25,7 +26,7 @@ function makeThread(overrides = {}) { /** Helper to create a stored message */ function makeMessage(overrides = {}) { - return { + return canonicalTestMessageInput({ id: 'msg-1', threadId: 'thread-1', userId: 'user-1', @@ -34,7 +35,7 @@ function makeMessage(overrides = {}) { mentions: [], timestamp: new Date('2026-02-07T10:30:00').getTime(), ...overrides, - }; + }); } describe('formatThreadAsMarkdown', () => { @@ -281,14 +282,18 @@ describe('Export Route (endpoint)', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const thread = makeThread(); const messageStore = new MessageStore(); - messageStore.append({ - ...makeMessage({ catId: 'codex-sol', content: 'published source-cat seed' }), - deliveryStatus: 'queued', - }); - messageStore.append({ - ...makeMessage({ id: 'msg-system', userId: 'system', catId: 'system', content: 'queued internal event' }), - deliveryStatus: 'queued', - }); + messageStore.append( + canonicalTestMessageInput({ + ...makeMessage({ catId: 'codex-sol', content: 'published source-cat seed' }), + deliveryStatus: 'queued', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + ...makeMessage({ id: 'msg-system', userId: 'system', catId: 'system', content: 'queued internal event' }), + deliveryStatus: 'queued', + }), + ); const app = await buildApp(mockThreadStore({ 'thread-1': thread }), messageStore); const res = await app.inject({ diff --git a/packages/api/test/f046-b5-runtime-regression-seed.test.js b/packages/api/test/f046-b5-runtime-regression-seed.test.js index 71bec311ab..c2e0d9d3f1 100644 --- a/packages/api/test/f046-b5-runtime-regression-seed.test.js +++ b/packages/api/test/f046-b5-runtime-regression-seed.test.js @@ -1,309 +1,32 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { describe, it } from 'node:test'; -import { catRegistry } from '@cat-cafe/shared'; +import { fileURLToPath } from 'node:url'; +import './helpers/setup-cat-registry.js'; -function createMockService(catId, text = 'hello') { - return { - async *invoke(_prompt) { - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} +const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); +const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); -function createCapturingService(catId, text = 'hello') { - const calls = []; - return { - calls, - async *invoke(prompt) { - calls.push(prompt); - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} - -function createSequentialCapturingService(catId, responses) { - const calls = []; - let index = 0; - return { - calls, - async *invoke(prompt) { - calls.push(prompt); - const text = responses[index] ?? responses[responses.length - 1] ?? 'ok'; - index += 1; - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} - -function createMockDeps(services, threadStore = null) { - let counter = 0; - return { - services, - invocationDeps: { - registry: { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { - getOrCreate: async () => ({}), - resolveWorkingDirectory: () => '/tmp/test', - }, - threadStore, - apiUrl: 'http://127.0.0.1:3004', - }, - messageStore: { - append: async () => ({ - id: `msg-${counter}`, - userId: '', - catId: null, - content: '', - mentions: [], - timestamp: 0, - }), - getById: () => null, - getRecent: () => [], - getMentionsFor: () => [], - getBefore: () => [], - getByThread: () => [], - getByThreadAfter: () => [], - getByThreadBefore: () => [], - }, - }; -} - -function withClaimedA2ASlot(options = {}) { - return { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - ...options, - }; -} - -describe('F046 B5 runtime regression scenarios', () => { - it('debug mode: downstream cat can see upstream response text', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const codexService = createCapturingService('codex', '已审查'); - const deps = createMockDeps({ - opus: createMockService('opus', '代码完成\n@缅因猫 请review'), - codex: codexService, - }); - - for await (const _ of routeSerial( - deps, - ['opus'], - 'write code', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - } - - assert.equal(codexService.calls.length, 1, 'codex should be called once'); - assert.ok(codexService.calls[0].includes('代码完成'), 'debug mode should include upstream response text'); - }); - - it('play mode: downstream cat cannot see upstream response text', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const codexService = createCapturingService('codex', '已审查'); - const deps = createMockDeps({ - opus: createMockService('opus', '代码完成\n@缅因猫 请review'), - codex: codexService, - }); - - for await (const _ of routeSerial( - deps, - ['opus'], - 'write code', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - } - - assert.equal(codexService.calls.length, 1, 'codex should be called once'); - assert.ok(!codexService.calls[0].includes('代码完成'), 'play mode should isolate upstream response text'); +describe('F046 B5 runtime regression scenarios after lifecycle cutover', () => { + it('keeps line-start routing independent of keyword and paragraph shape', () => { + assert.deepEqual(parseA2AMentions('@缅因猫 收到,我在等', 'opus'), ['codex']); + assert.deepEqual(parseA2AMentions('@缅因猫 请确认这个变更', 'opus'), ['codex']); + assert.deepEqual(parseA2AMentions('@缅因猫\n\n这是交接文档', 'opus'), ['codex']); + assert.deepEqual(parseA2AMentions('@缅因猫 prefix issue', 'opus'), ['codex']); }); - it('same-family review chain no longer injects invalid identity marker in debug mode', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - - const originalConfigs = catRegistry.getAllConfigs(); - catRegistry.reset(); - try { - const runtimeConfigs = toAllCatConfigs(loadCatConfig()); - for (const [id, config] of Object.entries(runtimeConfigs)) { - catRegistry.register(id, config); - } - - const opusService = createCapturingService('opus', '收到,继续处理'); - const deps = createMockDeps({ - codex: createMockService('codex', '代码完成\n@gpt52 请 review'), - gpt52: createMockService('gpt52', '我看过了,先给结论\n@opus 请继续'), - opus: opusService, - }); + it('commits downstream A2A only from a completed response instead of recursive route execution', () => { + const here = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(resolve(here, '../src/domains/cats/services/agents/routing/route-serial.ts'), 'utf8'); - for await (const _ of routeSerial( - deps, - ['codex'], - 'debug review chain', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - } - - assert.equal(opusService.calls.length, 1, 'downstream opus should be called once'); - assert.ok( - opusService.calls[0].includes('我看过了,先给结论'), - 'downstream prompt should still include upstream review text in debug mode', - ); - assert.ok( - !opusService.calls[0].includes('⚠️ Review 无效:同族 reviewer identity check 未通过'), - 'downstream prompt should not contain deprecated identity invalid marker', - ); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(originalConfigs)) { - catRegistry.register(id, config); - } - } + assert.match(source, /lifecycleResponse\?\.status === 'completed' && a2aMentions\.length > 0/); + assert.match(source, /commitCompletedA2AWake\(/); + assert.doesNotMatch(source, /Routing feedback\(one-shot\):/); }); - it('line-start @mention always routes (no keyword gate)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '@缅因猫 收到,我在等'), - codex: createMockService('codex', '收到'), - }); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'status', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - messages.push(msg); - } - - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.equal(handoffs.length, 1, 'line-start mention should trigger handoff (no keyword gate)'); - assert.ok(codexText.length > 0, 'line-start mention should invoke codex'); - }); - - it('D1 actionable mention in same paragraph: should route', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '@缅因猫 请 review 这个改动'), - codex: createMockService('codex', '收到,开始 review'), - }); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'review request', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - messages.push(msg); - } - - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.equal(handoffs.length, 1, 'actionable mention should trigger handoff'); - assert.ok(codexText.length > 0, 'actionable mention should invoke codex'); - }); - - it('D1 CJK actionable mention: should route', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '@缅因猫 请确认这个变更'), - codex: createMockService('codex', '已确认'), - }); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'confirm', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - messages.push(msg); - } - - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.ok(codexText.length > 0, 'CJK actionable mention should invoke codex'); - }); - - it('cross-paragraph @mention routes without mode setting (keyword gate removed)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); - const threadStore = new ThreadStore(); - const thread = threadStore.create('user1', 'no keyword gate'); - const deps = createMockDeps( - { - opus: createMockService('opus', '@缅因猫\n\n这是交接文档'), - codex: createMockService('codex', '收到,开始 review'), - }, - threadStore, - ); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'review request', - 'user1', - thread.id, - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - messages.push(msg); - } - - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.equal(handoffs.length, 1, 'cross-paragraph mention should route (no keyword gate)'); - assert.ok(codexText.length > 0, 'codex should be invoked'); - }); - - it('line-start @mention with arbitrary text routes (no keyword matching)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '@缅因猫 prefix issue'), - codex: createMockService('codex', '收到'), - }); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'boundary', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - messages.push(msg); - } - - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.ok(codexText.length > 0, 'line-start @mention routes regardless of text content'); - }); - - it('D2 metadata is handle-free in invocation context', async () => { - const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); - const ctx = buildInvocationContext({ + it('keeps dynamic identity metadata handle-free', () => { + const context = buildInvocationContext({ catId: 'codex', mode: 'serial', chainIndex: 1, @@ -311,47 +34,11 @@ describe('F046 B5 runtime regression scenarios', () => { teammates: ['opus'], mcpAvailable: false, directMessageFrom: 'opus', - activeParticipants: [{ catId: 'opus', lastMessageAt: 1710000000000, messageCount: 3 }], + activeParticipants: [{ catId: 'opus', lastMessageAt: 1_710_000_000_000, messageCount: 3 }], }); - assert.match(ctx, /^Direct message from 布偶猫\(opus\)/m); - assert.match(ctx, /最近活跃:布偶猫\(opus\)/); - assert.ok(!ctx.includes('Direct message from @opus'), 'metadata should not use @handle'); - assert.ok(!ctx.includes('最近活跃:@opus'), 'activity should not use @handle'); - }); - - it('no routing suppression feedback injected (suppression system removed)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); - const threadStore = new ThreadStore(); - const thread = threadStore.create('user1', 'no suppression'); - const opusService = createCapturingService('opus', '收到'); - const codexService = createSequentialCapturingService('codex', ['@布偶猫', '第二次调用']); - const deps = createMockDeps({ codex: codexService, opus: opusService }, threadStore); - - for await (const _ of routeSerial( - deps, - ['codex'], - 'first', - 'user1', - thread.id, - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - } - for await (const _ of routeSerial( - deps, - ['codex'], - 'second', - 'user1', - thread.id, - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - } - - // Bare @布偶猫 now routes, and no feedback is injected - assert.ok( - !codexService.calls[1].includes('Routing feedback(one-shot):'), - 'routing suppression feedback should never appear (system removed)', - ); + assert.match(context, /^Direct message from 布偶猫\(opus\)/m); + assert.match(context, /最近发言:布偶猫\(opus\)/); + assert.doesNotMatch(context, /Direct message from @opus|最近发言:@opus/); }); }); diff --git a/packages/api/test/f148-assemble-incremental.test.js b/packages/api/test/f148-assemble-incremental.test.js index 533700d56e..26c13dbe61 100644 --- a/packages/api/test/f148-assemble-incremental.test.js +++ b/packages/api/test/f148-assemble-incremental.test.js @@ -1,6 +1,7 @@ // @ts-check import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { adaptMessageStore, canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { assembleIncrementalContext: assembleIncrementalContextWithoutCapacity } = await import( '../dist/domains/cats/services/agents/routing/route-helpers.js' @@ -116,7 +117,7 @@ function buildDeps(messageStore, deliveryCursorStore, options = {}) { describe('F148: assembleIncrementalContext — smart window integration', () => { test('F296 AC-A3: stale artifact alone cannot become command-like truth source', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 1); @@ -146,7 +147,7 @@ describe('F148: assembleIncrementalContext — smart window integration', () => }); test('AC-A6: warm path (≤15 msgs) produces unchanged output format', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const msgs = seedMessages(messageStore, 10); @@ -163,7 +164,7 @@ describe('F148: assembleIncrementalContext — smart window integration', () => }); test('AC-A1: cold mention produces far fewer tokens than flat delivery would', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); // Create 100 messages with substantial content (cold mention scenario) @@ -197,7 +198,7 @@ describe('F148: assembleIncrementalContext — smart window integration', () => }); test('AC-A2: smart window preserves semantic chains', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); // 20 msgs, last 2 are tool_use → tool_result @@ -230,7 +231,7 @@ describe('F148: assembleIncrementalContext — smart window integration', () => }); test('AC-A3: tombstone contains all required fields', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 30); @@ -246,7 +247,7 @@ describe('F148: assembleIncrementalContext — smart window integration', () => }); test('AC-A4: evidence recall fail-open on store error', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 20); @@ -270,7 +271,7 @@ describe('F148: assembleIncrementalContext — smart window integration', () => }); test('AC-A5: tool payload scrub on non-terminal messages', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 20 * 60_000; @@ -310,7 +311,7 @@ describe('F148: assembleIncrementalContext — smart window integration', () => }); test('no evidenceStore: cold path works without it', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 20); @@ -334,7 +335,7 @@ function result_is_smart_window(result) { describe('F148 review fixes', () => { test('P1-1: threadStore.get() throwing does NOT crash assembleIncrementalContext', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 20); @@ -356,7 +357,7 @@ describe('F148 review fixes', () => { }); test('P1-2: effectiveMaxContextTokens hard cap — graduated degradation', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 30); @@ -399,7 +400,7 @@ describe('F148 review fixes', () => { // 10 messages with ~11K chars each ≈ 17K tokens, but count (10) < threshold (15) // First 6 msgs are old, then a 20-min silence gap, then 4 recent msgs // Token trigger should activate smart window → burst captures last 4, omits first 6 → tombstone - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const fatContent = 'Redis lock contention analysis. '.repeat(350); // ~11K chars ≈ 1750 tokens each @@ -432,7 +433,7 @@ describe('F148 review fixes', () => { test('P1-review: count > threshold triggers smart window regardless of content size', async () => { // 20 msgs: count (20) > threshold (15) → must enter smart window via count trigger. // Deterministic behavioral check — no wall-clock assertion. - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const now = Date.now(); @@ -458,7 +459,7 @@ describe('F148 review fixes', () => { test('AC-C2+C3: cold mention includes anchors with primacy', async () => { // 30 msgs with time gap, msg[0] has code block (thread opener), msg[5] has @-mention - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const now = Date.now(); @@ -507,7 +508,7 @@ describe('F148 review fixes', () => { test('cloud-P1: anchor content is sanitized (no history envelope injection)', async () => { // If an omitted message contains fake history envelope markers, // they must NOT appear raw in the context output via anchors. - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const now = Date.now(); @@ -618,7 +619,7 @@ After text`; // --- Phase D: Coverage Map + Thread Memory injection --- test('AC-D2: smart window includes coverage map JSON', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -644,7 +645,7 @@ After text`; }); test('AC-D2: smart window includes thread memory when available', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -669,7 +670,7 @@ After text`; }); test('AC-D2: coverage map threadMemory field reflects availability', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -692,7 +693,7 @@ After text`; }); test('P2-2: threadMemory exceeding maxThreadMemoryTokens is trimmed', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -726,7 +727,7 @@ After text`; }); test('P1-new: single-line threadMemory exceeding maxThreadMemoryTokens is hard-capped', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -758,7 +759,7 @@ After text`; }); test('cloud-P1: CJK token-dense single-line threadMemory is hard-capped', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -787,7 +788,7 @@ After text`; }); test('P1: threadMemory with envelope poison is sanitized', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -818,7 +819,7 @@ After text`; describe('F148 Phase E: coverageMap on IncrementalContextResult', () => { test('AC-E coverageMap is present when smart window triggers', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -837,7 +838,7 @@ describe('F148 Phase E: coverageMap on IncrementalContextResult', () => { }); test('briefingContext includes threadMemorySummary when threadStore has memory', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -860,7 +861,7 @@ describe('F148 Phase E: coverageMap on IncrementalContextResult', () => { }); test('briefingContext includes anchorSummaries from omitted messages', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 50 * 60_000; // First message with high importance (code block + mentions) @@ -884,7 +885,7 @@ describe('F148 Phase E: coverageMap on IncrementalContextResult', () => { }); test('VG-1 + F296 AC-A1: coverageMap counts 2 recall candidates without carrying their titles', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -915,7 +916,7 @@ describe('F148 Phase E: coverageMap on IncrementalContextResult', () => { }); test('VG-1: recall pointer counts 0 when no evidence store', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 30; i++) { @@ -936,7 +937,7 @@ describe('F148 Phase E: coverageMap on IncrementalContextResult', () => { }); test('coverageMap is undefined on warm path', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 10); const deps = buildDeps(messageStore, deliveryCursorStore); @@ -949,7 +950,7 @@ describe('F148 Phase E: coverageMap on IncrementalContextResult', () => { describe('F148 Phase E: AC-E2 anti-pollution — briefing excluded from all recall paths', () => { test('briefing messages excluded from messageStore query used by evidence indexer', async () => { // Simulates the messageListFn pattern: getByThread → filter origin=briefing - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const baseTs = Date.now() - 5 * 60_000; messageStore.append(mockMsg({ content: 'normal msg', timestamp: baseTs })); messageStore.append(mockMsg({ content: 'briefing card content', timestamp: baseTs + 60_000, origin: 'briefing' })); @@ -965,7 +966,7 @@ describe('F148 Phase E: AC-E2 anti-pollution — briefing excluded from all reca describe('F148 Phase E: origin briefing filter (AC-E2)', () => { test('messages with origin=briefing are excluded from incremental context', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 5 * 60_000; // Normal message @@ -983,7 +984,7 @@ describe('F148 Phase E: origin briefing filter (AC-E2)', () => { }); test('briefing messages excluded even in smart window path', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 30 * 60_000; for (let i = 0; i < 28; i++) { @@ -1006,7 +1007,7 @@ describe('F148 Phase E: origin briefing filter (AC-E2)', () => { describe('assembleIncrementalContext — unread visible message contract', () => { test('play mode preserves every unread visible user and cat message in timeline order', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const baseTs = Date.now() - 3_000; const firstUserMessage = await messageStore.append( @@ -1049,26 +1050,30 @@ describe('assembleIncrementalContext — unread visible message contract', () => }); test('keeps same-route output isolated unless it is the exact A2A trigger', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const threadId = 'thread-same-route'; const userId = 'user-same-route'; - const current = messageStore.append({ - userId, - catId: null, - content: '@opus then @codex solve independently', - mentions: ['opus', 'codex'], - timestamp: 1, - threadId, - }); - const earlierOutput = messageStore.append({ - userId, - catId: 'opus', - content: 'opus first-pass answer', - mentions: [], - origin: 'stream', - timestamp: 2, - threadId, - }); + const current = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: '@opus then @codex solve independently', + mentions: ['opus', 'codex'], + timestamp: 1, + threadId, + }), + ); + const earlierOutput = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: 'opus first-pass answer', + mentions: [], + origin: 'stream', + timestamp: 2, + threadId, + }), + ); const deps = buildDeps(messageStore, new DeliveryCursorStore()); const isolated = await assembleIncrementalContext(deps, userId, threadId, 'codex', current.id, 'play', { @@ -1083,8 +1088,38 @@ describe('assembleIncrementalContext — unread visible message contract', () => assert.ok(directHandoff.contextText.includes('opus first-pass answer')); }); + test('does not repeat the current direct user request as a co-creator baton', async () => { + const messageStore = adaptMessageStore(new MessageStore()); + const threadId = 'thread-direct-current-request'; + const userId = 'user-direct-current-request'; + const current = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: '@opus inspect this exact request', + mentions: ['opus'], + timestamp: 1, + threadId, + }), + ); + + const result = await assembleIncrementalContext( + buildDeps(messageStore, new DeliveryCursorStore()), + userId, + threadId, + 'opus', + current.id, + 'play', + { contextProjection: coldProjection('serial') }, + ); + + assert.ok(result.contextText.includes(current.content)); + assert.ok(!result.contextText.includes('传球: co-creator')); + assert.ok(!result.contextText.includes(`原文: "${current.content}"`)); + }); + test('defers a parallel sibling reply without consuming it before a later directed synthesis turn', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const threadId = 'thread-parallel-sibling-delivery'; const userId = 'user-parallel-sibling-delivery'; @@ -1153,7 +1188,7 @@ describe('assembleIncrementalContext — unread visible message contract', () => }); test('does not defer explicitly directed or causally independent cat output', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const threadId = 'thread-parallel-sibling-counterexamples'; const userId = 'user-parallel-sibling-counterexamples'; const source = messageStore.append({ @@ -1202,7 +1237,7 @@ describe('assembleIncrementalContext — unread visible message contract', () => }); test('projects the complete causal reply and public output after a newer user turn targets the receiver', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const threadId = 'thread-directed-window-regression'; const userId = 'user-directed-window-regression'; const fableOutput = messageStore.append({ @@ -1269,7 +1304,7 @@ describe('assembleIncrementalContext — unread visible message contract', () => }); test('server-authored causal reply metadata, not replyTo alone, creates a directed same-route projection', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const threadId = 'thread-directed-causal-projection'; const userId = 'user-directed-causal-projection'; const current = messageStore.append({ @@ -1326,7 +1361,7 @@ describe('assembleIncrementalContext — unread visible message contract', () => }); test('emits bounded content-free message refs and filter reasons for the final projection', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const threadId = 'thread-projection-audit'; const userId = 'user-projection-audit'; for (let index = 0; index < 24; index++) { @@ -1358,35 +1393,41 @@ describe('assembleIncrementalContext — unread visible message contract', () => }); test('does not advance the cursor past an earlier same-route output withheld in play mode', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const threadId = 'thread-same-route-boundary'; const userId = 'user-same-route-boundary'; - const current = messageStore.append({ - userId, - catId: null, - content: '@opus then @codex continue the chain', - mentions: ['opus', 'codex'], - timestamp: 1, - threadId, - }); - const earlierOutput = messageStore.append({ - userId, - catId: 'opus', - content: 'opus earlier output must remain unread', - mentions: [], - origin: 'stream', - timestamp: 2, - threadId, - }); - const exactTrigger = messageStore.append({ - userId, - catId: 'fable-5', - content: '@codex exact handoff', - mentions: ['codex'], - origin: 'stream', - timestamp: 3, - threadId, - }); + const current = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: '@opus then @codex continue the chain', + mentions: ['opus', 'codex'], + timestamp: 1, + threadId, + }), + ); + const earlierOutput = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: 'opus earlier output must remain unread', + mentions: [], + origin: 'stream', + timestamp: 2, + threadId, + }), + ); + const exactTrigger = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'fable-5', + content: '@codex exact handoff', + mentions: ['codex'], + origin: 'stream', + timestamp: 3, + threadId, + }), + ); const deps = buildDeps(messageStore, new DeliveryCursorStore()); const result = await assembleIncrementalContext(deps, userId, threadId, 'codex', exactTrigger.id, 'play', { @@ -1404,38 +1445,44 @@ describe('assembleIncrementalContext — unread visible message contract', () => }); test('applies the same withheld-message cursor cap on the cold smart-window path', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const threadId = 'thread-same-route-cold-boundary'; const userId = 'user-same-route-cold-boundary'; let precedingMessage; for (let index = 0; index < 16; index++) { - precedingMessage = messageStore.append({ + precedingMessage = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: `older visible message ${index}`, + mentions: [], + timestamp: index + 1, + threadId, + }), + ); + } + const earlierOutput = messageStore.append( + canonicalTestMessageInput({ userId, - catId: null, - content: `older visible message ${index}`, + catId: 'opus', + content: 'withheld cold-path output', mentions: [], - timestamp: index + 1, + origin: 'stream', + timestamp: 17, threadId, - }); - } - const earlierOutput = messageStore.append({ - userId, - catId: 'opus', - content: 'withheld cold-path output', - mentions: [], - origin: 'stream', - timestamp: 17, - threadId, - }); - const exactTrigger = messageStore.append({ - userId, - catId: 'fable-5', - content: '@codex cold exact handoff', - mentions: ['codex'], - origin: 'stream', - timestamp: 18, - threadId, - }); + }), + ); + const exactTrigger = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'fable-5', + content: '@codex cold exact handoff', + mentions: ['codex'], + origin: 'stream', + timestamp: 18, + threadId, + }), + ); const deps = buildDeps(messageStore, new DeliveryCursorStore(), { threadStore: mockThreadStore('Cold boundary thread'), }); @@ -1454,7 +1501,7 @@ describe('assembleIncrementalContext — unread visible message contract', () => describe('VG-3 P1-1: coverageMap threadMemory decisions passthrough', () => { test('cloud-P1: malformed decisions (non-array) are ignored, briefing still builds', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 20); @@ -1487,7 +1534,7 @@ describe('VG-3 P1-1: coverageMap threadMemory decisions passthrough', () => { }); test('coverageMap.threadMemory includes decisions when threadMemory has them', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); seedMessages(messageStore, 20); @@ -1524,7 +1571,7 @@ describe('VG-3 P1-1: coverageMap threadMemory decisions passthrough', () => { describe('assembleIncrementalContext — system error exclusion', () => { test('excludes userId=system error messages from incremental context', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const cursorStore = new DeliveryCursorStore(); // User message diff --git a/packages/api/test/f148-briefing-card.test.js b/packages/api/test/f148-briefing-card.test.js index 2d61d898f8..85515a1fd4 100644 --- a/packages/api/test/f148-briefing-card.test.js +++ b/packages/api/test/f148-briefing-card.test.js @@ -288,7 +288,7 @@ describe('F148 briefing card — navigation-first collapsed view', () => { const result = buildBriefingMessage(makeCoverage(), 'thread-1', {}); assert.equal(result.origin, 'briefing'); assert.equal(result.userId, 'system'); - assert.equal(result.catId, null); + assert.deepEqual(result.from, { kind: 'system', service: 'context-briefing' }); assert.ok(result.extra.rich.v === 1); assert.ok(Array.isArray(result.extra.rich.blocks)); }); diff --git a/packages/api/test/f148-context-briefing.test.js b/packages/api/test/f148-context-briefing.test.js index 6734e0edd7..9cb59f6f91 100644 --- a/packages/api/test/f148-context-briefing.test.js +++ b/packages/api/test/f148-context-briefing.test.js @@ -99,7 +99,7 @@ describe('F148 Phase E: buildBriefingMessage (AC-E1)', () => { test('returns AppendMessageInput with origin=briefing', () => { const msg = buildBriefingMessage(baseCoverageMap, 'thread-1'); assert.equal(msg.origin, 'briefing', 'must have origin=briefing'); - assert.equal(msg.catId, null, 'briefing is system-generated, catId=null'); + assert.deepEqual(msg.from, { kind: 'system', service: 'context-briefing' }); assert.equal(msg.userId, 'system', 'userId should be system'); assert.equal(msg.threadId, 'thread-1'); assert.ok(msg.content.includes('真相源'), 'content is the navigation summary'); diff --git a/packages/api/test/f148-navigation-context.test.js b/packages/api/test/f148-navigation-context.test.js index 497ee12fc3..a57680020b 100644 --- a/packages/api/test/f148-navigation-context.test.js +++ b/packages/api/test/f148-navigation-context.test.js @@ -306,6 +306,71 @@ describe('summarizeActiveTasks', () => { }); describe('formatNavigationHeader', () => { + it('renders exact other-member lifecycle state with its source handoff', () => { + const header = formatNavigationHeader({ + baton: null, + tasks: [], + currentCatId: 'codex', + executionSituation: { + kind: 'thread_execution_situation.v1', + complete: true, + activeRuns: [ + { + phase: 'processing', + targetId: 'kimi', + invocationId: 'inv-kimi', + responseMessageId: 'response-kimi', + startedAt: 200, + sources: [{ messageId: 'source-opus', from: { kind: 'agent', catId: 'opus' } }], + }, + ], + }, + }); + + assert.match(header, /成员运行态(生命周期真相)/); + assert.match(header, /\(kimi\).*来源 source-opus ← 布偶猫.*response=response-kimi.*invocation=inv-kimi/); + }); + + it('states that no other member is running only when the exact projection is complete', () => { + const header = formatNavigationHeader({ + baton: null, + tasks: [], + currentCatId: 'codex', + executionSituation: { + kind: 'thread_execution_situation.v1', + complete: true, + activeRuns: [ + { + phase: 'processing', + targetId: 'codex', + invocationId: 'self-invocation', + responseMessageId: 'self-response', + startedAt: 100, + sources: [{ messageId: 'source-user', from: { kind: 'user', userId: 'owner' } }], + }, + ], + }, + }); + + assert.match(header, /成员运行态: 当前无其他成员执行/); + assert.doesNotMatch(header, /self-invocation/); + }); + + it('does not infer execution from recent speech when the lifecycle join is incomplete', () => { + const header = formatNavigationHeader({ + baton: null, + tasks: [], + executionSituation: { + kind: 'thread_execution_situation.v1', + complete: false, + activeRuns: [], + }, + }); + + assert.match(header, /当前无法完整验证(不按最近发言推断)/); + assert.doesNotMatch(header, /当前无其他成员执行/); + }); + it('formats baton with @ message excerpt', () => { const header = formatNavigationHeader({ baton: { diff --git a/packages/api/test/f167-a2a-replacement-queue-preflight.test.js b/packages/api/test/f167-a2a-replacement-queue-preflight.test.js deleted file mode 100644 index 25a1a10620..0000000000 --- a/packages/api/test/f167-a2a-replacement-queue-preflight.test.js +++ /dev/null @@ -1,605 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, mock, test } from 'node:test'; - -import { createCatId } from '@cat-cafe/shared'; -import { createA2ADispositionHarness as dispositionHarness } from './helpers/a2a-dispatch-disposition-harness.js'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); -const { buildHandedEvent } = await import('../dist/domains/ball-custody/ball-custody-events.js'); -const { createInitialQueuedMessageCustody, QueuedMessageCustodyCoordinator } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' -); -const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - -function enqueueA2A(queue, sourceMessageId, createdAt) { - return queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - content: `handoff ${sourceMessageId}`, - source: 'agent', - sourceCategory: 'a2a', - ownerAuthProvenance: 'unknown', - targetCats: ['codex-sol'], - callerCatId: 'opus', - a2aParentInvocationId: 'parent-opus', - a2aTriggerMessageId: sourceMessageId, - intent: 'execute', - autoExecute: false, - createdAt, - }).entry; -} - -function enqueueUser(queue, sourceMessageId, createdAt) { - const entry = queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - content: `user work ${sourceMessageId}`, - source: 'user', - targetCats: ['codex-sol'], - intent: 'implement', - priority: 'normal', - ownerAuthProvenance: 'strict', - createdAt, - }).entry; - queue.backfillMessageId('thread-1', 'user-1', entry.id, sourceMessageId); - return entry; -} - -function replacedInspection() { - return { - outcome: 'replaced', - sourceMessageId: 'message-stale', - fromCatId: 'opus', - handoffSourceEventId: 'route:message-stale:codex-sol', - replacement: { - kind: 'handed', - sourceEventId: 'route:message-successor:codex-sol', - sourceMessageId: 'message-successor', - fromCatId: 'opus', - toCatId: 'codex-sol', - coordination: { id: 'coord-successor', phase: 'active', hop: 1 }, - }, - }; -} - -function createProcessor({ - queue, - staleMessage, - additionalMessages = [], - inspectHandoff, - a2aDispatchDispositionService = { inspectHandoff }, - withdrawEntry, -}) { - const routeExecution = mock.fn(async function* () { - yield { type: 'done', catId: 'codex-sol', timestamp: Date.now() }; - }); - const invocationCreate = mock.fn(async () => ({ outcome: 'created', invocationId: 'must-not-exist' })); - const log = { info: mock.fn(), warn: mock.fn(), error: mock.fn() }; - return { - log, - routeExecution, - invocationCreate, - processor: new QueueProcessor({ - queue, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - }, - invocationRecordStore: { create: invocationCreate, update: mock.fn(async () => {}) }, - router: { routeExecution, ackCollectedCursors: mock.fn(async () => {}) }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - messageStore: { - getById: mock.fn( - async (id) => [staleMessage, ...additionalMessages].find((message) => message.id === id) ?? null, - ), - }, - queueCustodyCoordinator: { withdrawEntry }, - a2aDispatchDispositionService, - log, - }), - }; -} - -describe('F167 A2A replacement Queue preflight', () => { - test('retires a carrier whose exact target already reached durable terminal truth instead of re-running provider', async () => { - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, 'message-stale', 1_000); - queue.backfillMessageId('thread-1', 'user-1', stale.id, 'message-stale'); - - const staleMessage = { - id: 'message-stale', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol terminal handoff text', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }; - const inspectHandoff = mock.fn(async () => ({ - outcome: 'current', - sourceMessageId: 'message-stale', - fromCatId: 'opus', - handoffSourceEventId: 'route:message-stale:codex-sol', - })); - const inspectTargetReplayFence = mock.fn(async () => ({ - disposition: 'terminalized', - invocationId: 'already-finished-invocation', - sourceMessageIds: ['message-stale'], - })); - const withdrawEntry = mock.fn(async () => true); - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage, - inspectHandoff, - withdrawEntry, - }); - processor.deps.queueCustodyCoordinator.inspectTargetReplayFence = inspectTargetReplayFence; - - const result = await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(result.status, 'succeeded'); - assert.equal(invocationCreate.mock.calls.length, 0); - assert.equal(routeExecution.mock.calls.length, 0); - assert.equal(withdrawEntry.mock.calls.length, 1); - const replayInput = inspectTargetReplayFence.mock.calls[0].arguments[0]; - assert.equal(replayInput.entry.id, stale.id); - assert.equal(replayInput.entry.messageId, 'message-stale'); - assert.equal(replayInput.entry.status, 'processing'); - assert.equal(replayInput.targetCatId, 'codex-sol'); - }); - - test('treats startup-retirement second withdrawal as idempotent terminal truth without provider re-entry', async () => { - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, 'message-stale', 1_000); - const store = new MessageStore(); - const staleMessage = store.append({ - id: 'message-stale', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol terminal handoff text', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }); - queue.backfillMessageId('thread-1', 'user-1', stale.id, staleMessage.id); - const oldCoroutineEntry = queue.markProcessing('thread-1', 'user-1'); - assert.ok(oldCoroutineEntry); - - const retirementStartedAt = oldCoroutineEntry.processingStartedAt ?? oldCoroutineEntry.createdAt; - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: store, - now: () => retirementStartedAt + 1_000, - }); - const retirementIntent = { - id: `prestart-retirement:thread-1:${stale.id}:${retirementStartedAt}`, - primaryEntryId: stale.id, - entryIds: [stale.id], - targetCatId: 'codex-sol', - startedAt: retirementStartedAt, - }; - const withdrawalOutcomes = []; - const withdrawEntry = mock.fn(async (entry) => { - const outcome = await coordinator.withdrawEntry(entry); - withdrawalOutcomes.push(outcome); - return outcome; - }); - const inspectTargetReplayFence = coordinator.inspectTargetReplayFence.bind(coordinator); - const { processor, routeExecution, invocationCreate, log } = createProcessor({ - queue, - staleMessage, - inspectHandoff: mock.fn(async () => ({ - outcome: 'current', - sourceMessageId: staleMessage.id, - fromCatId: 'opus', - handoffSourceEventId: `route:${staleMessage.id}:codex-sol`, - })), - withdrawEntry, - }); - processor.deps.messageStore = store; - processor.deps.queueCustodyCoordinator.persistEntry = coordinator.persistEntry.bind(coordinator); - processor.deps.queueCustodyCoordinator.inspectCarrierRetirementFence = - coordinator.inspectCarrierRetirementFence.bind(coordinator); - processor.deps.queueCustodyCoordinator.inspectTargetReplayFence = inspectTargetReplayFence; - - assert.equal( - await processor.resumeDurablePrestartRetirement([{ ...oldCoroutineEntry, prestartRetirement: retirementIntent }]), - true, - 'startup reconciler finishes the durable retirement group before ordinary Queue resume', - ); - assert.deepEqual(withdrawalOutcomes, [false], 'message cancellation already terminalized durable custody'); - assert.equal(queue.list('thread-1', 'user-1').length, 0, 'startup retirement removes the recovered Queue group'); - assert.equal(store.getById(staleMessage.id)?.deliveryStatus, 'canceled'); - assert.equal(store.getById(staleMessage.id)?.queueCustody, undefined); - - const result = await processor.executeEntry(oldCoroutineEntry); - - assert.deepEqual(withdrawalOutcomes, [false, false], 'the old coroutine observes already-retired custody'); - assert.equal(result.status, 'succeeded'); - assert.equal(invocationCreate.mock.calls.length, 0); - assert.equal(routeExecution.mock.calls.length, 0); - const reconciliation = log.info.mock.calls.find( - (call) => call.arguments[1] === '[QueueProcessor] reconciled terminalized Queue carrier at no-reentry preflight', - ); - assert.ok(reconciliation); - assert.equal(reconciliation.arguments[0].custodyChanged, false); - assert.deepEqual(reconciliation.arguments[0].custodyChanges, [{ entryId: stale.id, changed: false }]); - }); - - test('applies canceled-source no-reentry truth to non-A2A Queue carriers', async () => { - const queue = new InvocationQueue(); - const entry = enqueueUser(queue, 'message-user-retired', 1_000); - const staleMessage = { - id: 'message-user-retired', - threadId: 'thread-1', - userId: 'user-1', - catId: null, - content: 'retired user work', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'canceled', - }; - const withdrawEntry = mock.fn(async () => false); - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage, - withdrawEntry, - }); - processor.deps.queueCustodyCoordinator.inspectCarrierRetirementFence = mock.fn(async () => ({ - disposition: 'terminalized', - sourceMessageIds: [staleMessage.id], - })); - - const result = await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(result.status, 'succeeded'); - assert.equal(invocationCreate.mock.calls.length, 0); - assert.equal(routeExecution.mock.calls.length, 0); - assert.equal(withdrawEntry.mock.calls.length, 1); - assert.deepEqual(withdrawEntry.mock.calls[0].arguments[0].targetCats, entry.targetCats); - }); - - test('fails closed before provider creation when durable replay truth cannot be read', async () => { - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, 'message-stale', 1_000); - queue.backfillMessageId('thread-1', 'user-1', stale.id, 'message-stale'); - const staleMessage = { - id: 'message-stale', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol terminal handoff text', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }; - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage, - inspectHandoff: mock.fn(async () => ({ - outcome: 'current', - sourceMessageId: 'message-stale', - fromCatId: 'opus', - handoffSourceEventId: 'route:message-stale:codex-sol', - })), - withdrawEntry: mock.fn(async () => true), - }); - processor.deps.queueCustodyCoordinator.inspectTargetReplayFence = mock.fn(async () => { - throw new Error('custody unavailable'); - }); - - const result = await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(result.status, 'failed'); - assert.equal(invocationCreate.mock.calls.length, 0); - assert.equal(routeExecution.mock.calls.length, 0); - }); - - test('reports retirement-write failure separately from replay-fence read failure', async () => { - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, 'message-stale', 1_000); - queue.backfillMessageId('thread-1', 'user-1', stale.id, 'message-stale'); - const staleMessage = { - id: 'message-stale', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol terminal handoff text', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }; - const { processor, routeExecution, invocationCreate, log } = createProcessor({ - queue, - staleMessage, - inspectHandoff: mock.fn(async () => ({ - outcome: 'current', - sourceMessageId: 'message-stale', - fromCatId: 'opus', - handoffSourceEventId: 'route:message-stale:codex-sol', - })), - withdrawEntry: mock.fn(async () => { - throw new Error('retirement write unavailable'); - }), - }); - processor.deps.queueCustodyCoordinator.inspectTargetReplayFence = mock.fn(async () => ({ - disposition: 'terminalized', - sourceMessageIds: ['message-stale'], - })); - - const result = await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(result.status, 'failed'); - assert.equal(invocationCreate.mock.calls.length, 0); - assert.equal(routeExecution.mock.calls.length, 0); - const messages = log.error.mock.calls.map((call) => call.arguments[1]); - assert.ok(messages.includes('[QueueProcessor] A2A terminal retirement write failed; refusing provider re-entry')); - assert.equal( - messages.includes('[QueueProcessor] A2A no-reentry preflight unavailable; refusing provider re-entry'), - false, - ); - }); - - test('retires a replaced carrier before invocation creation and preserves its successor', async () => { - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, 'message-stale', 1_000); - queue.backfillMessageId('thread-1', 'user-1', stale.id, 'message-stale'); - const successor = enqueueA2A(queue, 'message-successor', 2_000); - queue.backfillMessageId('thread-1', 'user-1', successor.id, 'message-successor'); - - const staleMessage = { - id: 'message-stale', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol stale handoff', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }; - const inspectHandoff = mock.fn(async () => replacedInspection()); - const withdrawEntry = mock.fn(async () => true); - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage, - inspectHandoff, - withdrawEntry, - }); - - const result = await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(result.status, 'succeeded'); - assert.equal(invocationCreate.mock.calls.length, 0); - assert.equal(routeExecution.mock.calls.length, 0); - assert.equal(withdrawEntry.mock.calls.length, 1); - assert.deepEqual(inspectHandoff.mock.calls[0].arguments[0], { - threadId: 'thread-1', - catId: 'codex-sol', - sourceMessageId: 'message-stale', - }); - assert.deepEqual( - queue.list('thread-1', 'user-1').map((entry) => entry.a2aTriggerMessageId), - ['message-successor'], - ); - }); - - test('retains the stale carrier when durable custody cannot be terminalized', async () => { - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, 'message-stale', 1_000); - queue.backfillMessageId('thread-1', 'user-1', stale.id, 'message-stale'); - const staleMessage = { - id: 'message-stale', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol stale handoff', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }; - const inspectHandoff = mock.fn(async () => replacedInspection()); - const withdrawEntry = mock.fn(async () => false); - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage, - inspectHandoff, - withdrawEntry, - }); - - const result = await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(result.status, 'failed'); - assert.equal(invocationCreate.mock.calls.length, 0); - assert.equal(routeExecution.mock.calls.length, 0); - assert.equal(withdrawEntry.mock.calls.length, 1); - assert.equal(queue.list('thread-1', 'user-1')[0].status, 'queued'); - }); - - test('runs coalesced successor work when only the original A2A handoff was replaced', async () => { - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, 'message-stale', 1_000); - queue.backfillMessageId('thread-1', 'user-1', stale.id, 'message-stale'); - assert.equal( - queue.coalesceContentIntoQueuedAgent( - 'thread-1', - 'user-1', - stale.id, - 'handoff message-successor', - 'message-successor', - 'opus', - 'parent-opus', - 'unknown', - ), - true, - ); - const staleMessage = { - id: 'message-stale', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol stale handoff', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }; - const successorMessage = { - ...staleMessage, - id: 'message-successor', - content: '@codex-sol live successor handoff', - timestamp: 2_000, - }; - const inspectHandoff = mock.fn(async ({ sourceMessageId }) => - sourceMessageId === 'message-stale' - ? replacedInspection() - : { - outcome: 'live', - sourceMessageId, - fromCatId: 'opus', - handoffSourceEventId: `route:${sourceMessageId}:codex-sol`, - }, - ); - const withdrawEntry = mock.fn(async () => true); - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage, - additionalMessages: [successorMessage], - inspectHandoff, - withdrawEntry, - }); - - await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(withdrawEntry.mock.calls.length, 0); - assert.equal(invocationCreate.mock.calls.length, 1); - assert.equal(routeExecution.mock.calls.length, 1); - assert.match(routeExecution.mock.calls[0].arguments[1], /stale handoff/); - assert.match(routeExecution.mock.calls[0].arguments[1], /live successor handoff/); - assert.deepEqual( - inspectHandoff.mock.calls.map((call) => call.arguments[0].sourceMessageId), - ['message-stale', 'message-successor'], - ); - }); - - test('keeps a post-settlement surviving source live when its retired trigger was replaced', async () => { - const queue = new InvocationQueue(); - const restored = enqueueA2A(queue, 'message-retired-trigger', 1_000); - queue.backfillMessageId('thread-1', 'user-1', restored.id, 'message-surviving-source'); - - const survivingMessage = { - id: 'message-surviving-source', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - content: '@codex-sol still-live handoff', - mentions: ['codex-sol'], - timestamp: 2_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(restored), - }; - const retiredTriggerMessage = { - ...survivingMessage, - id: 'message-retired-trigger', - content: '@codex-sol retired handoff', - timestamp: 1_000, - }; - const inspectHandoff = mock.fn(async ({ sourceMessageId }) => - sourceMessageId === 'message-retired-trigger' - ? replacedInspection() - : { - outcome: 'live', - sourceMessageId, - fromCatId: 'opus', - handoffSourceEventId: `route:${sourceMessageId}:codex-sol`, - }, - ); - const withdrawEntry = mock.fn(async () => true); - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage: survivingMessage, - additionalMessages: [retiredTriggerMessage], - inspectHandoff, - withdrawEntry, - }); - - await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(withdrawEntry.mock.calls.length, 0); - assert.equal(invocationCreate.mock.calls.length, 1); - assert.equal(routeExecution.mock.calls.length, 1); - assert.deepEqual( - inspectHandoff.mock.calls.map((call) => call.arguments[0].sourceMessageId), - ['message-retired-trigger', 'message-surviving-source'], - ); - }); - - test('keeps the carrier live when successor metadata cannot prove causal lineage', async () => { - const h = await dispositionHarness(); - const successor = h.messageStore.append({ - userId: 'user-1', - catId: createCatId('opus'), - content: '@codex-sol successor with unavailable metadata', - mentions: [createCatId('codex-sol')], - timestamp: 1_500, - threadId: 'thread-1', - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: successor.id, - at: 1_500, - }), - ); - const getById = h.messageStore.getById.bind(h.messageStore); - h.messageStore.getById = async (messageId) => { - if (messageId === successor.id) throw new Error('successor metadata unavailable'); - return getById(messageId); - }; - - const queue = new InvocationQueue(); - const stale = enqueueA2A(queue, h.source.id, 1_000); - queue.backfillMessageId('thread-1', 'user-1', stale.id, h.source.id); - const staleMessage = { - id: h.source.id, - threadId: 'thread-1', - userId: 'user-1', - catId: 'fable5', - content: '@codex-sol stale handoff', - mentions: ['codex-sol'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(stale), - }; - const withdrawEntry = mock.fn(async () => true); - const { processor, routeExecution, invocationCreate } = createProcessor({ - queue, - staleMessage, - a2aDispatchDispositionService: h.service, - withdrawEntry, - }); - - const result = await processor.executeEntry(queue.markProcessing('thread-1', 'user-1')); - - assert.equal(result.status, 'succeeded'); - assert.equal(withdrawEntry.mock.calls.length, 0); - assert.equal(invocationCreate.mock.calls.length, 1); - assert.equal(routeExecution.mock.calls.length, 1); - }); -}); diff --git a/packages/api/test/f168-phase-b-dual-cursor.test.js b/packages/api/test/f168-phase-b-dual-cursor.test.js index 028dd35752..a1f2cf6bf7 100644 --- a/packages/api/test/f168-phase-b-dual-cursor.test.js +++ b/packages/api/test/f168-phase-b-dual-cursor.test.js @@ -285,7 +285,7 @@ describe('IssueCommentTaskSpec: with eventLog — dual-cursor', () => { taskStore, router, comments, - extra: { eventLog, invokeTrigger: { trigger: async () => 'dispatched' } }, + extra: { eventLog, invokeTrigger: { trigger: async () => 'enqueued' } }, }); const gate = await runGate(spec); @@ -1163,7 +1163,7 @@ describe('PR #1181 maintainer regressions: durable issue wake lifecycle', () => taskStore.addTask(makeTask()); const router = makeIssueCommentRouter(); const eventLog = makeEventLog(); - const outcomes = ['full', 'dispatched']; + const outcomes = ['full', 'enqueued']; const triggerCalls = []; const spec = createIssueCommentTaskSpec({ taskStore, @@ -1237,7 +1237,7 @@ describe('PR #1181 maintainer regressions: durable issue wake lifecycle', () => taskStore.addTask(makeTask()); const router = makeIssueCommentRouter(); const comments = [{ id: 100, author: 'maintainer', body: 'closing note' }]; - const outcomes = ['full', 'dispatched']; + const outcomes = ['full', 'enqueued']; let fetchCalls = 0; const spec = createIssueCommentTaskSpec({ taskStore, @@ -1276,7 +1276,7 @@ describe('PR #1181 maintainer regressions: durable issue wake lifecycle', () => taskStore.addTask(makeTask()); let issueState = 'closed'; let stateFetches = 0; - const outcomes = ['full', 'dispatched']; + const outcomes = ['full', 'enqueued']; const spec = createIssueCommentTaskSpec({ taskStore, issueCommentRouter: makeIssueCommentRouter(), @@ -1317,7 +1317,7 @@ describe('PR #1181 maintainer regressions: durable issue wake lifecycle', () => fetchComments: async (_repo, _issue, since) => comments.filter((comment) => comment.id > since), fetchIssueState: async () => 'closed', isEchoComment: (comment) => comment.author === 'self', - invokeTrigger: { trigger: async () => 'dispatched' }, + invokeTrigger: { trigger: async () => 'enqueued' }, eventLog: makeEventLog(), log: { info: () => {}, error: () => {}, warn: () => {} }, }); diff --git a/packages/api/test/f194-canonical-liveness-queue.test.js b/packages/api/test/f194-canonical-liveness-queue.test.js index ebf85e2d67..bc614d5aeb 100644 --- a/packages/api/test/f194-canonical-liveness-queue.test.js +++ b/packages/api/test/f194-canonical-liveness-queue.test.js @@ -31,6 +31,7 @@ const USER_ID = 'user-a'; const UNDECLARED_FRESHNESS_CARRIER_CAPABILITY = { provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }; diff --git a/packages/api/test/f194-canonical-liveness-routes.test.js b/packages/api/test/f194-canonical-liveness-routes.test.js index e71583f557..490979461a 100644 --- a/packages/api/test/f194-canonical-liveness-routes.test.js +++ b/packages/api/test/f194-canonical-liveness-routes.test.js @@ -28,6 +28,7 @@ const USER_ID = 'user-1'; const UNDECLARED_FRESHNESS_CARRIER_CAPABILITY = { provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }; diff --git a/packages/api/test/f202-phase2-d.test.js b/packages/api/test/f202-phase2-d.test.js index d1aedf9e8e..1dfbfb8ac2 100644 --- a/packages/api/test/f202-phase2-d.test.js +++ b/packages/api/test/f202-phase2-d.test.js @@ -223,7 +223,7 @@ describe('AC-D3: IssueCommentTaskSpec', () => { invokeTrigger: { trigger: async () => { events.push('triggered'); - return 'dispatched'; + return 'enqueued'; }, }, log: { info() {}, error() {}, warn() {} }, @@ -434,9 +434,9 @@ describe('AC-D3: IssueCommentTaskSpec', () => { assert.strictEqual(errors[0][0].outcome, 'full'); }); - test('execute marks notification only after the wake is dispatched or enqueued', async () => { + test('execute marks notification only after the wake is enqueued', async () => { assert.ok(createIssueCommentTaskSpec, 'createIssueCommentTaskSpec should be importable'); - for (const outcome of ['dispatched', 'enqueued']) { + for (const outcome of ['enqueued']) { const store = new TaskStore(); const task = store.upsertBySubject({ kind: 'issue_tracking', @@ -548,7 +548,7 @@ describe('P2-cloud: process pending comments before closing', () => { { id: 100, author: 'maintainer', body: 'Closing: fixed in v2.0', createdAt: '2026-01-01T00:00:00Z' }, ], fetchIssueState: async () => 'closed', - invokeTrigger: { trigger: async () => 'dispatched' }, + invokeTrigger: { trigger: async () => 'enqueued' }, log: mockLog, }); @@ -589,7 +589,7 @@ describe('P2-cloud: process pending comments before closing', () => { { id: 20, author: 'maintainer', body: 'Final note', createdAt: '2026-01-01T00:00:00Z' }, ], fetchIssueState: async () => 'closed', - invokeTrigger: { trigger: async () => 'dispatched' }, + invokeTrigger: { trigger: async () => 'enqueued' }, log: mockLog, }); @@ -673,7 +673,7 @@ describe('P2-cloud: reseeded issue cursors', () => { : []; }, fetchIssueState: async () => 'open', - invokeTrigger: { trigger: async () => 'dispatched' }, + invokeTrigger: { trigger: async () => 'enqueued' }, log: mockLog, }); diff --git a/packages/api/test/f215-malformed-toolcall.test.js b/packages/api/test/f215-malformed-toolcall.test.js index a926d50957..b5c23e8555 100644 --- a/packages/api/test/f215-malformed-toolcall.test.js +++ b/packages/api/test/f215-malformed-toolcall.test.js @@ -483,7 +483,7 @@ describe('F215 AC-D1: final failure has explicit error message', () => { }); }); -// ── AC-C3: route-serial layer — malformed relay pushes opus-4.6 to worklist ── +// ── AC-C3: route-serial layer — malformed relay publishes one durable wake ── // Minimal routeSerial deps (mirrors f046-b5-runtime-regression-seed.test.js pattern) function createMalformedRelayDeps(services) { @@ -534,7 +534,13 @@ function createMalformedOpusService(catId) { content: JSON.stringify({ type: 'invocation_created', invocationId: 'inv-malformed-48' }), timestamp: Date.now(), }; - // The relay card (AC-C3) + yield { + type: 'text', + catId, + content: '🙀 Opus 4.8 炸毛了;Ragdoll Opus 4.6 已经来接班。', + timestamp: Date.now(), + }; + // Server-owned relay signal (AC-C3) yield { type: 'system_info', catId, @@ -557,41 +563,65 @@ function createMalformedOpusService(catId) { }; } -// Service for opus-4.6 that produces normal output -function createRelay46Service(catId = 'opus') { - const calls = []; - const service = { - calls, - async *invoke(prompt, _opts) { - calls.push(prompt); - yield { type: 'text', catId, content: '我是 opus-4.6,接力完成任务。', timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; - return service; -} - -function withClaimedA2ASlot(options = {}) { +function createLifecycleRelayOptions(commits) { return { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - ...options, + ownerAuthProvenance: 'unknown', + parentInvocationId: 'parent-malformed-48', + onLifecycleInvocationStarted: async (input) => ({ + responseMessageId: 'response-malformed-48', + priorFrontierMessageId: null, + activeRun: { + threadId: input.threadId, + targetId: input.catId, + invocationId: input.invocationId, + responseMessageId: 'response-malformed-48', + inputEntryIds: [], + inputMessageIds: [], + privateInputEntryIds: [], + startedAt: input.startedAt, + }, + }), + commitCompletedA2AWake: async (input) => { + commits.push(input); + return { + ...input.message, + id: input.responseMessageId, + threadId: input.threadId, + lifecycle: { + kind: 'response', + orderKey: `${input.terminal.completedAt}:${input.responseMessageId}`, + from: { kind: 'agent', catId: input.callerCatId }, + invocationId: input.invocationId, + targetId: input.callerCatId, + inputEntryIds: [], + inputMessageIds: [], + status: 'completed', + startedAt: input.message.timestamp, + completedAt: input.terminal.completedAt, + dispatchRefs: input.targetCats.map((targetId) => ({ targetId, phase: 'assigned' })), + }, + }; + }, }; } -describe('F215 AC-C3: route-serial malformed relay pushes opus-4.6 to worklist', () => { - test('when opus48 炸毛, opus-4.6 is invoked as relay and user sees no malformed error', async () => { +describe('F215 AC-C3: route-serial malformed relay uses the completed-response wake', () => { + test('publishes opus-4.6 as durable follow-up and never invokes it inline', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - // opus48 = the cat that炸毛; opus = the 46 relay const opus48CatId = 'opus48'; - const relay46Service = createRelay46Service('opus'); + const relay46Calls = []; const deps = createMalformedRelayDeps({ [opus48CatId]: createMalformedOpusService(opus48CatId), - opus: relay46Service, + opus: { + async *invoke() { + relay46Calls.push(true); + yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + }, + }, }); + const commits = []; const allMsgs = []; for await (const msg of routeSerial( deps, @@ -599,26 +629,20 @@ describe('F215 AC-C3: route-serial malformed relay pushes opus-4.6 to worklist', 'complete my task', 'user-test', 'thread-relay-test', - withClaimedA2ASlot(), + createLifecycleRelayOptions(commits), )) { allMsgs.push(msg); } - // 1. opus-4.6 must have been invoked (relay happened) - assert.equal(relay46Service.calls.length, 1, 'opus-4.6 should be invoked as relay'); - - // 2. User must see text output from opus-4.6 - const relayText = allMsgs.find((m) => m.type === 'text' && m.catId === 'opus'); - assert.ok(relayText, 'relay46 text output must reach user'); - assert.ok(relayText.content.includes('opus-4.6'), 'relay text should identify 4.6'); - - // 3. malformed error must NOT be surfaced to user + assert.equal(relay46Calls.length, 0, 'the current route must never execute the relay inline'); + assert.equal(commits.length, 1, 'the completed recovery card must publish one durable wake'); + assert.deepEqual(commits[0].targetCats, ['opus']); + assert.deepEqual(commits[0].message.mentions, ['opus']); + assert.match(commits[0].message.content, /Opus 4\.6 已经来接班/); const malformedErrors = allMsgs.filter( (m) => m.type === 'error' && typeof m.error === 'string' && m.error.startsWith('malformed_toolcall:'), ); assert.equal(malformedErrors.length, 0, 'malformed_toolcall error must be suppressed by relay'); - - // 4. routing signal must NOT leak to frontend (砚砚 re-review: route-serial must consume+drop it) const leakedRouteSignals = allMsgs.filter((m) => { try { return m.type === 'system_info' && JSON.parse(m.content ?? '{}').type === 'malformed_toolcall_relay_46'; @@ -629,81 +653,12 @@ describe('F215 AC-C3: route-serial malformed relay pushes opus-4.6 to worklist', assert.equal(leakedRouteSignals.length, 0, 'malformed_toolcall_relay_46 routing signal must not leak to user'); }); - test('does NOT push duplicate relay cat when opus is already pending in worklist', async () => { - // P2 fix: if worklist already contains opus (e.g. user routed to [opus48, opus]), - // the relay push must be skipped — opus must be invoked exactly ONCE. - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const opus48CatId = 'opus48'; - const relay46Service = createRelay46Service('opus'); - const deps = createMalformedRelayDeps({ - [opus48CatId]: createMalformedOpusService(opus48CatId), - opus: relay46Service, - }); - - const allMsgs = []; - for await (const msg of routeSerial( - deps, - [opus48CatId, 'opus'], // opus already in worklist — must NOT be pushed again - 'complete my task', - 'user-test', - 'thread-dup-relay', - withClaimedA2ASlot(), - )) { - allMsgs.push(msg); - } - - // opus must be called exactly ONCE — either from original slot or relay, never both - assert.equal( - relay46Service.calls.length, - 1, - 'opus must be invoked exactly once, not twice (P2: no duplicate relay push)', - ); - }); - - test('DOES relay when opus already ran earlier in the route (executed vs pending check)', async () => { - // P1 #1 fix: duplicate guard must check PENDING entries only, not the full worklist. - // Scenario: [opus, opus-48] — opus runs first (executed), then opus-48 炸毛. - // relay SHOULD push opus again (executed ≠ pending duplicate). - // Old bug: worklist.includes(opus) = true even for executed opus → relay silently skipped. + test('keeps the honest partial-output notice when opus-4.6 is unavailable', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - // First slot: normal opus service (runs first, index=0) - const normalOpusService = createRelay46Service('opus'); // produces normal relay output - const opus48CatId = 'opus48'; - const deps = createMalformedRelayDeps({ - opus: normalOpusService, // opus runs FIRST as worklist[0] - [opus48CatId]: createMalformedOpusService(opus48CatId), // opus-48 炸毛 as worklist[1] - }); - - const allMsgs = []; - for await (const msg of routeSerial( - deps, - ['opus', opus48CatId], // opus executes first, then opus-48 炸毛 - 'complete my task', - 'user-test', - 'thread-executed-relay', - withClaimedA2ASlot(), - )) { - allMsgs.push(msg); - } - - // opus must be invoked TWICE: once from the original worklist slot, once from relay - // (executed opus ≠ pending duplicate — relay push is correct here) - assert.equal( - normalOpusService.calls.length, - 2, - 'opus must be invoked twice: once from worklist and once as malformed relay (P1: executed ≠ pending)', - ); - }); - - test('does NOT relay when opus-4.6 service is not available', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - - // Only opus48 service, no opus service const opus48CatId = 'opus48'; const deps = createMalformedRelayDeps({ [opus48CatId]: createMalformedOpusService(opus48CatId), - // intentionally no 'opus' service }); const allMsgs = []; @@ -711,11 +666,14 @@ describe('F215 AC-C3: route-serial malformed relay pushes opus-4.6 to worklist', allMsgs.push(msg); } - // The error should be surfaced since no relay is possible + const notices = allMsgs.filter( + (m) => m.type === 'text' && typeof m.content === 'string' && m.content.includes('最后一步没完成'), + ); + assert.equal(notices.length, 1, 'partial output must end with one honest user-visible recovery notice'); const malformedErrors = allMsgs.filter( (m) => m.type === 'error' && typeof m.error === 'string' && m.error.startsWith('malformed_toolcall:'), ); - assert.equal(malformedErrors.length, 1, 'malformed error must reach user when no relay service is available'); + assert.equal(malformedErrors.length, 0, 'the raw malformed error must not contradict the partial-output notice'); }); }); diff --git a/packages/api/test/f232-thread-artifacts-aggregator.test.js b/packages/api/test/f232-thread-artifacts-aggregator.test.js index 79c5f194cd..d959a93ae3 100644 --- a/packages/api/test/f232-thread-artifacts-aggregator.test.js +++ b/packages/api/test/f232-thread-artifacts-aggregator.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { aggregateThreadArtifacts, collectAllThreadMessages } = await import( '../dist/domains/cats/services/agents/routing/thread-artifacts-aggregator.js' @@ -193,7 +194,16 @@ test('collectAllThreadMessages paginates a REAL store with no overlap (oldest→ const base = Date.now(); // 250 > THREAD_SCAN_PAGE(200) → 强制多页 for (let i = 0; i < 250; i++) { - store.append({ userId: 'u', catId: 'opus-48', content: `m${i}`, mentions: [], timestamp: base + i, threadId: 'T' }); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: 'opus-48', + content: `m${i}`, + mentions: [], + timestamp: base + i, + threadId: 'T', + }), + ); } const all = await collectAllThreadMessages(store, 'T'); const uniqueIds = new Set(all.map((m) => m.id)); @@ -279,33 +289,39 @@ test('file block with mov extension → type=video', () => { assert.equal(r[0].type, 'video'); }); -test('getByThreadBefore (in-memory) uses queued-work delivery time without re-including its cursor', async () => { - // Ordinary queued work moves to deliveredAt in the Redis index. The in-memory - // cursor must use the same order time rather than raw msg.timestamp—— - // 当游标传 deliveredAt(> 原始 timestamp),cursor 消息自身 timestamp < deliveredAt 仍满足边界, - // 被再次包含 → collectAllThreadMessages 在同一页无限循环(dev/test 非 Redis 部署 hang)。 +test('getByThreadBefore (in-memory) keeps queued user work at its authored timeline position', async () => { + // Queue execution is a separate ledger. Processing a persisted user message + // must not move that message to the end of History; both Redis and memory + // pagination therefore retain the original authored timestamp. const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const base = Date.now(); - store.append({ userId: 'u', catId: null, content: 'older', mentions: [], timestamp: base + 50, threadId: 'T' }); - const queued = store.append({ - userId: 'u', - catId: null, - content: 'queued', - mentions: [], - timestamp: base + 100, - threadId: 'T', - deliveryStatus: 'queued', - }); - store.markDelivered(queued.id, base + 300); // re-scored: effective order time = base+300 + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'older', + mentions: [], + timestamp: base + 50, + threadId: 'T', + }), + ); + const queued = store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'queued', + mentions: [], + timestamp: base + 100, + threadId: 'T', + deliveryStatus: 'queued', + }), + ); + store.markDelivered(queued.id, base + 300); - // 游标 = queued 的 effective order time(deliveredAt),与 collectAllThreadMessages 一致。 - const page = store.getByThreadBefore('T', base + 300, 200, queued.id); + const page = store.getByThreadBefore('T', base + 100, 200, queued.id); const ids = page.map((m) => m.id); - assert.ok( - !ids.includes(queued.id), - 'cursor message must not reappear in its own before-page (effective order time, not raw timestamp)', - ); + assert.ok(!ids.includes(queued.id), 'cursor message must not reappear in its own before-page'); assert.equal(page.length, 1, 'only the genuinely-older message precedes the cursor'); assert.equal(page[0].content, 'older'); }); diff --git a/packages/api/test/f232-thread-artifacts-endpoint.test.js b/packages/api/test/f232-thread-artifacts-endpoint.test.js index 7dd908ca74..a7e9f8d9ca 100644 --- a/packages/api/test/f232-thread-artifacts-endpoint.test.js +++ b/packages/api/test/f232-thread-artifacts-endpoint.test.js @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { threadsRoutes } = await import('../dist/routes/threads.js'); const { SessionChainStore } = await import('../dist/domains/cats/services/stores/ports/SessionChainStore.js'); @@ -147,26 +148,30 @@ describe('GET /api/threads/:threadId/artifacts (F232)', () => { const messageStore = new MessageStore(); const base = Date.now(); // earliest message carries the file artifact, then push 59 newer plain messages past the default-50 window - messageStore.append({ - userId: 'alice', - catId: 'opus-48', - content: '', - mentions: [], - timestamp: base, - threadId: 'T1', - extra: { - rich: { v: 1, blocks: [{ id: 'b', kind: 'file', v: 1, url: '/uploads/early.pdf', fileName: 'early.pdf' }] }, - }, - }); - for (let i = 1; i <= 59; i++) { - messageStore.append({ + messageStore.append( + canonicalTestMessageInput({ userId: 'alice', catId: 'opus-48', - content: `m${i}`, + content: '', mentions: [], - timestamp: base + i, + timestamp: base, threadId: 'T1', - }); + extra: { + rich: { v: 1, blocks: [{ id: 'b', kind: 'file', v: 1, url: '/uploads/early.pdf', fileName: 'early.pdf' }] }, + }, + }), + ); + for (let i = 1; i <= 59; i++) { + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus-48', + content: `m${i}`, + mentions: [], + timestamp: base + i, + threadId: 'T1', + }), + ); } app = Fastify(); await app.register(threadsRoutes, { diff --git a/packages/api/test/f232-thread-artifacts-redis.test.js b/packages/api/test/f232-thread-artifacts-redis.test.js index 184eabcc00..f9d15bae47 100644 --- a/packages/api/test/f232-thread-artifacts-redis.test.js +++ b/packages/api/test/f232-thread-artifacts-redis.test.js @@ -7,6 +7,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -76,15 +77,17 @@ describe('F232 thread artifacts — Redis-backed (AC-A6)', { skip: redisIsolatio { ts: base + 500, block: { id: 'b5', kind: 'card', v: 1, title: 'ignored' } }, ]; for (const { ts, block } of rows) { - await store.append({ - userId: 'u', - catId: 'opus-48', - content: '', - mentions: [], - timestamp: ts, - threadId: T, - extra: { rich: { v: 1, blocks: [block] } }, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: 'opus-48', + content: '', + mentions: [], + timestamp: ts, + threadId: T, + extra: { rich: { v: 1, blocks: [block] } }, + }), + ); } const messages = await store.getByThread(T, 100); @@ -104,21 +107,23 @@ describe('F232 thread artifacts — Redis-backed (AC-A6)', { skip: redisIsolatio it('collects rich-block artifacts from published queued cat speech', async () => { const threadId = 'thread-f232-published-seed'; - const seed = await store.append({ - userId: 'u', - catId: 'codex-sol', - content: 'source-cat seed with an artifact', - mentions: ['opus'], - timestamp: Date.now(), - threadId, - deliveryStatus: 'queued', - extra: { - rich: { - v: 1, - blocks: [{ id: 'seed-file', kind: 'file', v: 1, url: '/uploads/seed.pdf', fileName: 'seed.pdf' }], + const seed = await store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: 'codex-sol', + content: 'source-cat seed with an artifact', + mentions: ['opus'], + timestamp: Date.now(), + threadId, + deliveryStatus: 'queued', + extra: { + rich: { + v: 1, + blocks: [{ id: 'seed-file', kind: 'file', v: 1, url: '/uploads/seed.pdf', fileName: 'seed.pdf' }], + }, }, - }, - }); + }), + ); const messages = await collectAllThreadMessages(store, threadId); assert.deepEqual( @@ -142,18 +147,20 @@ describe('F232 thread artifacts — Redis-backed (AC-A6)', { skip: redisIsolatio // 若游标用原始 timestamp(base+120) 作上界,下一页 zrevrangebyscore(< base+120) 会跳过 gap(base+150), // gap.pdf 的 artifact 从 GET /api/threads/:threadId/artifacts 漏聚合。必须用 effective score。 const append = (ts, fileName, deliveryStatus) => - store.append({ - userId: 'u', - catId: null, - content: '', - mentions: [], - timestamp: ts, - threadId: T, - ...(deliveryStatus ? { deliveryStatus } : {}), - extra: { - rich: { v: 1, blocks: [{ id: fileName, kind: 'file', v: 1, url: `/uploads/${fileName}`, fileName }] }, - }, - }); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: '', + mentions: [], + timestamp: ts, + threadId: T, + ...(deliveryStatus ? { deliveryStatus } : {}), + extra: { + rich: { v: 1, blocks: [{ id: fileName, kind: 'file', v: 1, url: `/uploads/${fileName}`, fileName }] }, + }, + }), + ); await append(base + 100, 'old.pdf'); await append(base + 150, 'gap.pdf'); @@ -178,15 +185,17 @@ describe('F232 thread artifacts — Redis-backed (AC-A6)', { skip: redisIsolatio }); it('thread index isolates: getByThread(other) does not leak this thread artifacts', async () => { - await store.append({ - userId: 'u', - catId: 'opus-48', - content: '', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-A', - extra: { rich: { v: 1, blocks: [{ id: 'b', kind: 'file', v: 1, url: '/u/a', fileName: 'a' }] } }, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: 'opus-48', + content: '', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-A', + extra: { rich: { v: 1, blocks: [{ id: 'b', kind: 'file', v: 1, url: '/u/a', fileName: 'a' }] } }, + }), + ); // thread-A has an artifact; thread-B index must not leak it const aArtifacts = aggregateThreadArtifacts({ diff --git a/packages/api/test/f247-browser-assistant-return-ingest.test.js b/packages/api/test/f247-browser-assistant-return-ingest.test.js index a02fcd7bd0..5b589e9fa5 100644 --- a/packages/api/test/f247-browser-assistant-return-ingest.test.js +++ b/packages/api/test/f247-browser-assistant-return-ingest.test.js @@ -5,6 +5,7 @@ import { describe, it } from 'node:test'; import { CloudAssistantReturnIngestService } from '../dist/domains/cats/services/cloud-bridge/cloud-assistant-return-ingest.js'; import { MemoryCloudReturnGrantStore } from '../dist/domains/cats/services/cloud-bridge/cloud-return-grant.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function idempotencyKey(scope) { const digest = createHash('sha256') @@ -15,14 +16,17 @@ function idempotencyKey(scope) { function fixture() { const store = new MessageStore(); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: 'thread-f247-browser-return', - content: '@gpt-pro answer this exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + // F117: append requires MessageFrom sender identity + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: 'thread-f247-browser-return', + content: '@gpt-pro answer this exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); const grantStore = new MemoryCloudReturnGrantStore(); const broadcasts = []; const service = new CloudAssistantReturnIngestService({ @@ -78,18 +82,21 @@ describe('F247 browser-captured assistant return ingest', () => { sourceMessageId: source.id, targetCatId: 'gpt-pro', }; - const mcpWinner = store.append({ - userId: source.userId, - catId: 'gpt-pro', - threadId: source.threadId, - content: 'MCP won the exact source race', - mentions: [], - origin: 'callback', - timestamp: 1_100, - extra: { isExplicitPost: true }, - replyTo: source.id, - idempotencyKey: idempotencyKey(scope), - }); + // F117: append requires MessageFrom sender identity + const mcpWinner = store.append( + canonicalTestMessageInput({ + userId: source.userId, + catId: 'gpt-pro', + threadId: source.threadId, + content: 'MCP won the exact source race', + mentions: [], + origin: 'callback', + timestamp: 1_100, + extra: { isExplicitPost: true }, + replyTo: source.id, + idempotencyKey: idempotencyKey(scope), + }), + ); await grantStore.issue({ ...scope, dispatchInvocationId: 'dispatch-browser-return-2' }); const outcome = await service.ingest({ @@ -109,16 +116,19 @@ describe('F247 browser-captured assistant return ingest', () => { reason: 'grant_not_found', }); - const whisper = store.append({ - userId: source.userId, - catId: 'codex-sol', - threadId: source.threadId, - content: 'private source must not be projected into a public browser return', - mentions: ['gpt-pro'], - visibility: 'whisper', - whisperTo: ['gpt-pro'], - timestamp: 1_200, - }); + // F117: append requires MessageFrom sender identity + const whisper = store.append( + canonicalTestMessageInput({ + userId: source.userId, + catId: 'codex-sol', + threadId: source.threadId, + content: 'private source must not be projected into a public browser return', + mentions: ['gpt-pro'], + visibility: 'whisper', + whisperTo: ['gpt-pro'], + timestamp: 1_200, + }), + ); await grantStore.issue({ threadId: whisper.threadId, userId: whisper.userId, diff --git a/packages/api/test/f247-cloud-receipt-provenance.test.js b/packages/api/test/f247-cloud-receipt-provenance.test.js index 910481aa6c..e6d133fb73 100644 --- a/packages/api/test/f247-cloud-receipt-provenance.test.js +++ b/packages/api/test/f247-cloud-receipt-provenance.test.js @@ -3,6 +3,7 @@ import { describe, it } from 'node:test'; import { persistUserFacingSystemInfoNotices } from '../dist/domains/cats/services/agents/routing/persist-system-info-warnings.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function receipt(sourceMessageId, overrides = {}) { return { @@ -41,14 +42,16 @@ function needsBindingContent(outboundReceipt) { describe('F247 durable outbound receipt provenance', () => { it('persists a refs-only recovery carrier for an exact direct-user needs-binding source', async () => { const store = new MessageStore(); - const source = store.append({ - userId: 'alice', - catId: null, - threadId: 'thread-owner', - content: '@gpt-pro exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + threadId: 'thread-owner', + content: '@gpt-pro exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); const outboundReceipt = receipt(source.id, { sourceSender: { kind: 'user', id: 'alice' }, status: 'failed', @@ -82,15 +85,17 @@ describe('F247 durable outbound receipt provenance', () => { it('does not persist an interactive recovery carrier for an exact cat-authored source', async () => { const store = new MessageStore(); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: 'thread-owner', - content: '@gpt-pro exact A2A source', - mentions: ['gpt-pro'], - timestamp: 1_000, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: 'thread-owner', + content: '@gpt-pro exact A2A source', + mentions: ['gpt-pro'], + timestamp: 1_000, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); const outboundReceipt = receipt(source.id, { status: 'failed', transport: 'none', @@ -115,14 +120,16 @@ describe('F247 durable outbound receipt provenance', () => { it('does not infer recovery from a generic failed cloud receipt', async () => { const store = new MessageStore(); - const source = store.append({ - userId: 'alice', - catId: null, - threadId: 'thread-owner', - content: '@gpt-pro exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + threadId: 'thread-owner', + content: '@gpt-pro exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); const outboundReceipt = receipt(source.id, { sourceSender: { kind: 'user', id: 'alice' }, status: 'failed', @@ -148,15 +155,17 @@ describe('F247 durable outbound receipt provenance', () => { it('persists replyTo only when source, sender, dispatch invocation, and target all match server context', async () => { const store = new MessageStore(); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: 'thread-owner', - content: '@gpt-pro exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: 'thread-owner', + content: '@gpt-pro exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); await persistUserFacingSystemInfoNotices({ messageStore: store, @@ -176,24 +185,28 @@ describe('F247 durable outbound receipt provenance', () => { it('drops a same-thread receipt when its source differs from the server-owned trigger', async () => { const store = new MessageStore(); - const expectedSource = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: 'thread-owner', - content: '@gpt-pro expected source', - mentions: ['gpt-pro'], - timestamp: 1_000, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); - const substitutedSource = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: 'thread-owner', - content: 'same sender, wrong source', - mentions: [], - timestamp: 1_001, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); + const expectedSource = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: 'thread-owner', + content: '@gpt-pro expected source', + mentions: ['gpt-pro'], + timestamp: 1_000, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); + const substitutedSource = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: 'thread-owner', + content: 'same sender, wrong source', + mentions: [], + timestamp: 1_001, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); await persistUserFacingSystemInfoNotices({ messageStore: store, @@ -237,16 +250,18 @@ describe('F247 durable outbound receipt provenance', () => { ]) { it(`drops the source-bound projection for ${invalidCase.name}`, async () => { const store = new MessageStore(); - const source = store.append({ - userId: invalidCase.sourceUserId ?? 'alice', - catId: invalidCase.sourceCatId === undefined ? 'codex-sol' : invalidCase.sourceCatId, - threadId: invalidCase.sourceThreadId, - content: 'private source body must not hydrate', - mentions: [], - timestamp: 1_000, - ...(invalidCase.deliveryStatus ? { deliveryStatus: invalidCase.deliveryStatus } : {}), - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: invalidCase.sourceUserId ?? 'alice', + catId: invalidCase.sourceCatId === undefined ? 'codex-sol' : invalidCase.sourceCatId, + threadId: invalidCase.sourceThreadId, + content: 'private source body must not hydrate', + mentions: [], + timestamp: 1_000, + ...(invalidCase.deliveryStatus ? { deliveryStatus: invalidCase.deliveryStatus } : {}), + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); await persistUserFacingSystemInfoNotices({ messageStore: store, diff --git a/packages/api/test/f247-cloud-return-append-winner.test.js b/packages/api/test/f247-cloud-return-append-winner.test.js index c1a26695c3..297e3d9a5d 100644 --- a/packages/api/test/f247-cloud-return-append-winner.test.js +++ b/packages/api/test/f247-cloud-return-append-winner.test.js @@ -4,6 +4,7 @@ import { describe, it } from 'node:test'; import Fastify from 'fastify'; import { MemoryCloudReturnGrantStore } from '../dist/domains/cats/services/cloud-bridge/cloud-return-grant.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('F247 atomic append-winner recovery', () => { it('routes only from the persisted message when the pre-read misses an idempotency winner', async () => { @@ -20,14 +21,17 @@ describe('F247 atomic append-winner recovery', () => { const store = new MessageStore(); const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 append winner recovery'); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro persist routing for this source', - mentions: ['gpt-pro'], - timestamp: 2_000, - }); + // F117: append requires MessageFrom sender identity + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro persist routing for this source', + mentions: ['gpt-pro'], + timestamp: 2_000, + }), + ); const scope = { threadId: thread.id, userId: 'alice', @@ -37,19 +41,21 @@ describe('F247 atomic append-winner recovery', () => { const idempotencyKey = `f247-cloud-return:${createHash('sha256') .update(JSON.stringify({ v: 1, ...scope })) .digest('hex')}`; - const persisted = store.append({ - userId: 'alice', - catId: 'gpt-pro', - threadId: thread.id, - content: 'append winner persisted return', - mentions: ['codex'], - origin: 'callback', - timestamp: 2_100, - extra: { isExplicitPost: true, targetCats: ['codex'] }, - replyTo: source.id, - deliveryStatus: 'queued', - idempotencyKey, - }); + // F117: append requires MessageFrom sender identity + const persisted = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'gpt-pro', + threadId: thread.id, + content: 'append winner persisted return', + mentions: ['codex'], + origin: 'callback', + timestamp: 2_100, + extra: { isExplicitPost: true, targetCats: ['codex'] }, + replyTo: source.id, + idempotencyKey, + }), + ); const getByIdempotencyKey = store.getByIdempotencyKey.bind(store); let lookupCount = 0; store.getByIdempotencyKey = (...args) => { @@ -75,7 +81,7 @@ describe('F247 atomic append-winner recovery', () => { invocationQueue, queueProcessor: { async onInvocationComplete() {}, - async tryAutoExecute() {}, + async requestDrain() {}, registerEntryCompleteHook() {}, unregisterEntryCompleteHook() {}, }, @@ -106,9 +112,10 @@ describe('F247 atomic append-winner recovery', () => { assert.equal(retry.json().messageId, persisted.id); const entries = invocationQueue.list(thread.id, 'alice'); assert.equal(entries.length, 1); - assert.equal(entries[0].messageId, persisted.id); - assert.deepEqual(entries[0].targetCats, ['codex']); - assert.equal(entries[0].content, persisted.content); + assert.equal(entries[0].payload.messageId, persisted.id); + assert.deepEqual(entries[0].from, { kind: 'agent', catId: 'gpt-pro' }); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(entries[0].payload.content, persisted.content); assert.equal(broadcasts.length, 0); await app.close(); }); diff --git a/packages/api/test/f247-cloud-return-contract.test.js b/packages/api/test/f247-cloud-return-contract.test.js index 5990c81e9f..d929e652d7 100644 --- a/packages/api/test/f247-cloud-return-contract.test.js +++ b/packages/api/test/f247-cloud-return-contract.test.js @@ -11,6 +11,7 @@ import { } from '../dist/domains/cats/services/cloud-bridge/cloud-return-binding.js'; import { MemoryCloudReturnGrantStore } from '../dist/domains/cats/services/cloud-bridge/cloud-return-grant.js'; import { hydrateReplyPreview, MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const apiRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = join(apiRoot, '..', '..'); @@ -27,23 +28,27 @@ describe('F247 source-bound Remote MCP return contract', () => { it('projects a Remote MCP reply against the exact source message through the existing F264 replyTo seam', async () => { const store = new MessageStore(); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: 'thread-f247-return', - content: '@gpt-pro inspect this exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); - const remoteReply = store.append({ - userId: 'alice', - catId: 'gpt-pro', - threadId: 'thread-f247-return', - content: 'Remote MCP source-bound response', - mentions: [], - timestamp: 1_100, - replyTo: source.id, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: 'thread-f247-return', + content: '@gpt-pro inspect this exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); + const remoteReply = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'gpt-pro', + threadId: 'thread-f247-return', + content: 'Remote MCP source-bound response', + mentions: [], + timestamp: 1_100, + replyTo: source.id, + }), + ); assert.equal(remoteReply.replyTo, source.id); assert.deepEqual(await hydrateReplyPreview(store, remoteReply.replyTo), { @@ -67,23 +72,27 @@ describe('F247 source-bound Remote MCP return contract', () => { const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 source-bound return'); await threadStore.addParticipants(thread.id, ['codex']); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro bind this source', - mentions: ['gpt-pro'], - timestamp: 1_000, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); - const other = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: 'do not bind this source', - mentions: [], - timestamp: 1_001, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro bind this source', + mentions: ['gpt-pro'], + timestamp: 1_000, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); + const other = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: 'do not bind this source', + mentions: [], + timestamp: 1_001, + }), + ); const binding = signer.sign({ threadId: thread.id, userId: 'alice', @@ -204,14 +213,17 @@ describe('F247 source-bound Remote MCP return contract', () => { const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 durable recovery before mutable admission'); thread.threadKind = 'concierge'; - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro return to this exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + // F117: append requires MessageFrom sender identity + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro return to this exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); await grantStore.issue({ threadId: thread.id, userId: 'alice', @@ -276,14 +288,17 @@ describe('F247 source-bound Remote MCP return contract', () => { const store = new MessageStore(); const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 retryable grant claim'); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro retry this exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + // F117: append requires MessageFrom sender identity + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro retry this exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); const scope = { threadId: thread.id, userId: 'alice', @@ -357,14 +372,17 @@ describe('F247 source-bound Remote MCP return contract', () => { const store = new MessageStore(); const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 commit recovery'); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro persist then recover this source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + // F117: append requires MessageFrom sender identity + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro persist then recover this source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); await grantStore.issue({ threadId: thread.id, userId: 'alice', @@ -427,14 +445,17 @@ describe('F247 source-bound Remote MCP return contract', () => { const store = new MessageStore(); const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 durable routing recovery'); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro persist routing for this source', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + // F117: append requires MessageFrom sender identity + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro persist routing for this source', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); const scope = { threadId: thread.id, userId: 'alice', @@ -444,23 +465,25 @@ describe('F247 source-bound Remote MCP return contract', () => { const idempotencyKey = `f247-cloud-return:${createHash('sha256') .update(JSON.stringify({ v: 1, ...scope })) .digest('hex')}`; - const persisted = store.append({ - userId: 'alice', - catId: 'gpt-pro', - threadId: thread.id, - content: 'original persisted return', - mentions: ['codex'], - origin: 'callback', - timestamp: 1_100, - extra: { isExplicitPost: true, targetCats: ['codex'] }, - replyTo: source.id, - deliveryStatus: 'queued', - idempotencyKey, - }); + // F117: append requires MessageFrom sender identity + const persisted = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'gpt-pro', + threadId: thread.id, + content: 'original persisted return', + mentions: ['codex'], + origin: 'callback', + timestamp: 1_100, + extra: { isExplicitPost: true, targetCats: ['codex'] }, + replyTo: source.id, + idempotencyKey, + }), + ); const invocationQueue = new InvocationQueue(); const queueProcessor = { async onInvocationComplete() {}, - async tryAutoExecute() {}, + async requestDrain() {}, registerEntryCompleteHook() {}, unregisterEntryCompleteHook() {}, }; @@ -508,9 +531,10 @@ describe('F247 source-bound Remote MCP return contract', () => { assert.equal(retry.json().messageId, persisted.id); const entries = invocationQueue.list(thread.id, 'alice'); assert.equal(entries.length, 1); - assert.equal(entries[0].messageId, persisted.id); - assert.deepEqual(entries[0].targetCats, ['codex']); - assert.equal(entries[0].content, persisted.content); + assert.equal(entries[0].payload.messageId, persisted.id); + assert.deepEqual(entries[0].from, { kind: 'agent', catId: 'gpt-pro' }); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(entries[0].payload.content, persisted.content); assert.equal(broadcasts.length, 0); assert.equal((await store.getById(persisted.id)).replyTo, source.id); await app.close(); @@ -554,14 +578,17 @@ describe('F247 source-bound Remote MCP return contract', () => { const store = new MessageStore(); const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 append rollback'); - const source = store.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro return after durable append', - mentions: ['gpt-pro'], - timestamp: 1_000, - }); + // F117: append requires MessageFrom sender identity + const source = store.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro return after durable append', + mentions: ['gpt-pro'], + timestamp: 1_000, + }), + ); await grantStore.issue({ threadId: thread.id, userId: 'alice', diff --git a/packages/api/test/f247-cloud-runtime-terminal.test.js b/packages/api/test/f247-cloud-runtime-terminal.test.js index 9e8a04def2..8e207f3e9c 100644 --- a/packages/api/test/f247-cloud-runtime-terminal.test.js +++ b/packages/api/test/f247-cloud-runtime-terminal.test.js @@ -24,30 +24,13 @@ function makeThreadStore() { }; } -function makeDispositionRecorder() { - const calls = []; - return { - calls, - complete: async (auth, disposition) => { - calls.push({ auth, disposition }); - return { - outcome: 'applied', - disposition, - invocationId: auth.invocationId, - sourceMessageId: auth.a2aTriggerMessageId, - fromCatId: 'codex-sol', - }; - }, - }; -} - async function drain(generator) { const messages = []; for await (const message of generator) messages.push(message); return messages; } -function makeParallelDeps({ bridge, disposition }) { +function makeParallelDeps({ bridge }) { let messageSeq = 0; return { services: { @@ -56,6 +39,7 @@ function makeParallelDeps({ bridge, disposition }) { freshnessCarrierCapability: () => ({ provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }), // biome-ignore lint/correctness/useYield: fail-fast sentinel for an unreachable provider path. @@ -71,7 +55,6 @@ function makeParallelDeps({ bridge, disposition }) { apiUrl: 'http://localhost:0', cloudInvokeBridge: bridge, cloudReturnGrantStore: { issue: async () => ({ ok: true, status: 'issued' }) }, - a2aDispatchDispositionService: disposition, }, messageStore: { append: async (message) => ({ @@ -103,6 +86,7 @@ const baseParams = { freshnessCarrierCapability: () => ({ provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }), }, @@ -122,7 +106,6 @@ const baseParams = { describe('F247 cloud runtime terminal contract', () => { it('keeps an A2A needs-binding outcome terminally completed instead of exposing direct-user retry semantics', async () => { ensureGptProRegistered(); - const disposition = makeDispositionRecorder(); const messages = await drain( invokeSingleCat( { @@ -134,25 +117,21 @@ describe('F247 cloud runtime terminal contract', () => { dispatch: async () => ({ kind: 'fallback', reason: 'needs-binding', detail: 'route absent' }), }, cloudReturnGrantStore: { issue: async () => ({ ok: true, status: 'issued' }) }, - a2aDispatchDispositionService: disposition, }, baseParams, ), ); - assert.equal(disposition.calls.length, 1); - assert.equal(disposition.calls[0].disposition, 'completed'); assert.equal(messages.at(-1).type, 'done'); assert.equal(messages.at(-1).errorCode, undefined); }); - it('waits for the Host bridge outcome, exposes one readable fallback, and terminalizes the exact A2A carrier', async () => { + it('waits for the Host bridge outcome and exposes one readable fallback before normal lifecycle completion', async () => { ensureGptProRegistered(); let releaseBridge; const bridgeOutcome = new Promise((resolve) => { releaseBridge = resolve; }); - const disposition = makeDispositionRecorder(); const bridgeCalls = []; const exposed = []; let settled = false; @@ -170,7 +149,6 @@ describe('F247 cloud runtime terminal contract', () => { }, }, cloudReturnGrantStore: { issue: async () => ({ ok: true, status: 'issued' }) }, - a2aDispatchDispositionService: disposition, }, { ...baseParams, @@ -234,32 +212,13 @@ describe('F247 cloud runtime terminal contract', () => { idempotency: { keyKind: 'source_message_id', disposition: 'not_attempted' }, }); - assert.equal(disposition.calls.length, 1); - assert.equal(disposition.calls[0].disposition, 'completed'); - assert.deepEqual( - { - invocationId: disposition.calls[0].auth.invocationId, - catId: disposition.calls[0].auth.catId, - threadId: disposition.calls[0].auth.threadId, - a2aTriggerMessageId: disposition.calls[0].auth.a2aTriggerMessageId, - originTriggerMessageId: disposition.calls[0].auth.originTriggerMessageId, - }, - { - invocationId: createdPayload.invocationId, - catId: 'gpt-pro', - threadId: 'thread-f247', - a2aTriggerMessageId: 'source-message-9', - originTriggerMessageId: 'source-message-9', - }, - ); const done = messages.find((message) => message.type === 'done'); assert.equal(done.invocationId, createdPayload.invocationId); assert.equal(done.errorCode, undefined); }); - it('reports a real Host receipt as sent and still closes the source carrier exactly once', async () => { + it('reports a real Host receipt as sent and completes through the ordinary response lifecycle', async () => { ensureGptProRegistered(); - const disposition = makeDispositionRecorder(); const messages = await drain( invokeSingleCat( { @@ -277,7 +236,6 @@ describe('F247 cloud runtime terminal contract', () => { }), }, cloudReturnGrantStore: { issue: async () => ({ ok: true, status: 'issued' }) }, - a2aDispatchDispositionService: disposition, }, { ...baseParams, @@ -312,14 +270,11 @@ describe('F247 cloud runtime terminal contract', () => { keyKind: 'source_message_id', disposition: 'fresh', }); - assert.equal(disposition.calls.length, 1); - assert.equal(disposition.calls[0].disposition, 'completed'); - assert.equal(disposition.calls[0].auth.originTriggerMessageId, 'source-message-9'); + assert.equal(messages.at(-1).type, 'done'); }); it('preserves the exact A2A source and caller through a parallel cloud route', async () => { ensureGptProRegistered(); - const disposition = makeDispositionRecorder(); const bridgeCalls = []; const messages = await drain( routeParallel( @@ -330,7 +285,6 @@ describe('F247 cloud runtime terminal contract', () => { return { kind: 'fallback', reason: 'no-adapter', detail: 'host unavailable' }; }, }, - disposition, }), ['gpt-pro'], '@gpt-pro inspect the exact carrier', @@ -348,15 +302,11 @@ describe('F247 cloud runtime terminal contract', () => { assert.equal(bridgeCalls.length, 1); assert.equal(bridgeCalls[0].calledBy, 'codex-sol'); assert.equal(bridgeCalls[0].sourceMessageId, 'source-message-parallel-4'); - assert.equal(disposition.calls.length, 1); - assert.equal(disposition.calls[0].auth.a2aTriggerMessageId, 'source-message-parallel-4'); - assert.equal(disposition.calls[0].auth.originTriggerMessageId, 'source-message-parallel-4'); assert.equal(messages.filter((message) => message.type === 'done').length, 1); }); it('fails closed but still settles an A2A trigger whose caller identity is absent', async () => { ensureGptProRegistered(); - const disposition = makeDispositionRecorder(); const bridgeCalls = []; const messages = await drain( routeParallel( @@ -367,7 +317,6 @@ describe('F247 cloud runtime terminal contract', () => { return { kind: 'sent', capturedUrl: 'https://chatgpt.com/c/should-not-send' }; }, }, - disposition, }), ['gpt-pro'], '@gpt-pro caller provenance is missing', @@ -382,8 +331,6 @@ describe('F247 cloud runtime terminal contract', () => { ); assert.equal(bridgeCalls.length, 0, 'must not misattribute an A2A call to the thread owner'); - assert.equal(disposition.calls.length, 1); - assert.equal(disposition.calls[0].auth.a2aTriggerMessageId, 'source-message-parallel-5'); const status = messages .filter((message) => message.type === 'system_info' && message.content) .map((message) => JSON.parse(message.content)) @@ -393,9 +340,8 @@ describe('F247 cloud runtime terminal contract', () => { assert.equal(messages.filter((message) => message.type === 'done').length, 1); }); - it('degrades a thread metadata read failure without skipping cloud status or exact disposition', async () => { + it('degrades a thread metadata read failure without skipping cloud status or lifecycle completion', async () => { ensureGptProRegistered(); - const disposition = makeDispositionRecorder(); const bridgeCalls = []; const messages = await drain( invokeSingleCat( @@ -416,7 +362,6 @@ describe('F247 cloud runtime terminal contract', () => { }, }, cloudReturnGrantStore: { issue: async () => ({ ok: true, status: 'issued' }) }, - a2aDispatchDispositionService: disposition, }, baseParams, ), @@ -425,8 +370,6 @@ describe('F247 cloud runtime terminal contract', () => { assert.equal(bridgeCalls.length, 1); assert.equal(bridgeCalls[0].threadTitle, null); assert.deepEqual(bridgeCalls[0].participants, []); - assert.equal(disposition.calls.length, 1); - assert.equal(disposition.calls[0].auth.a2aTriggerMessageId, 'source-message-9'); const statuses = messages .filter((message) => message.type === 'system_info' && message.content) .map((message) => JSON.parse(message.content)) diff --git a/packages/api/test/f247-fingerprint-bounds-product-chain.test.js b/packages/api/test/f247-fingerprint-bounds-product-chain.test.js index e7bd7bdb18..d23155bf16 100644 --- a/packages/api/test/f247-fingerprint-bounds-product-chain.test.js +++ b/packages/api/test/f247-fingerprint-bounds-product-chain.test.js @@ -7,6 +7,7 @@ import { dispatchBoundConversationThroughHost } from '../dist/domains/cats/servi import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { safeAdapterDiagnostic } from '../src/plugins/cloud-cat-personal-host/native-host/native-results.mjs'; import { createUnsupportedNodeHarness } from './helpers/f247-unsupported-node-harness.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const BOUNDARY_FIXTURES = [ { @@ -34,15 +35,17 @@ describe('F247 DOM fingerprint producer bounds', () => { for (const fixture of BOUNDARY_FIXTURES) { const threadId = `thread-failed-dispatch-${fixture.id}`; const messageStore = new MessageStore(); - const source = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - threadId, - content: '@gpt-pro private source body', - mentions: ['gpt-pro'], - timestamp: 1_000, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); + const source = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId, + content: '@gpt-pro private source body', + mentions: ['gpt-pro'], + timestamp: 1_000, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); const harness = createUnsupportedNodeHarness({ mutateAfterInsert: fixture.mutateAfterInsert }); let pageDiagnostic; await assert.rejects( diff --git a/packages/api/test/f247-first-message-admission.test.js b/packages/api/test/f247-first-message-admission.test.js deleted file mode 100644 index 0bf3d068c4..0000000000 --- a/packages/api/test/f247-first-message-admission.test.js +++ /dev/null @@ -1,132 +0,0 @@ -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { firstMessageHarness, waitFor } from './helpers/f247-first-message-harness.js'; - -let h; -afterEach(async () => { - await h?.close(); - h = undefined; -}); - -test('concurrent replay cannot start the cloud carrier before its source append commits', async () => { - let release; - let appending = false; - const fence = new Promise((resolve) => { - release = resolve; - }); - h = await firstMessageHarness({ - bound: true, - beforeAppend: async (message) => { - if (message.userId === 'first-message-owner' && message.catId === null) { - appending = true; - await fence; - } - }, - }); - const request = { idempotencyKey: 'ce744f53-5f51-418a-b2c3-7301e7984f83' }; - const original = h.send(request).then((response) => response); - await waitFor(() => appending); - try { - const replay = await h.send(request); - assert.equal(replay.statusCode, 202); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(h.hostCalls.length, 0); - assert.equal(h.routeCalls.length, 0, 'no provider route before the original source exists'); - assert.equal(h.queue.list(h.thread.id, h.userId)[0].status, 'queued'); - } finally { - release(); - } - const sourceId = (await original).json().userMessageId; - await waitFor(() => h.hostCalls.length === 1); - await h.settle(); - assert.equal(h.hostCalls[0].idempotencyKey, sourceId); - assert.equal(h.routeCalls.length, 1); -}); - -test('failed source persistence leaves no executable cloud carrier or provider request', async () => { - h = await firstMessageHarness({ - bound: true, - beforeAppend: (message) => { - if (message.userId === 'first-message-owner') throw new Error('source storage unavailable'); - }, - }); - assert.equal((await h.send()).statusCode, 500); - assert.equal(h.queue.list(h.thread.id, h.userId).length, 0); - assert.equal(h.routeCalls.length, 0); - assert.equal(h.hostCalls.length, 0); -}); - -for (const deliveryMode of ['immediate', 'queue']) { - test(`explicit ${deliveryMode} on an idle thread retains first-source recovery`, async () => { - h = await firstMessageHarness(); - const response = await h.send({ deliveryMode }); - assert.equal(response.statusCode, 202, response.body); - await h.settle(); - assert.equal((await h.authority(response.json().userMessageId)).statusCode, 200); - assert.equal(h.hostCalls.length, 0); - }); -} - -test('mixed local/cloud admission retries only the failed cloud target', async () => { - h = await firstMessageHarness({ targetCats: ['opus', 'gpt-pro'] }); - const sourceId = (await h.send()).json().userMessageId; - await h.settle(); - const authority = await h.authority(sourceId); - assert.equal( - authority.statusCode, - 200, - JSON.stringify({ authority: authority.json(), logs: h.logs }, (_key, value) => - value instanceof Error ? value.stack : value, - ), - ); - const attemptId = authority.json().attemptId; - const before = h.routeCalls.flatMap((call) => call.targets); - assert.equal(before.filter((catId) => catId === 'opus').length, 1); - assert.equal(before.filter((catId) => catId === 'gpt-pro').length, 1); - await h.bind(); - assert.equal((await h.retry(sourceId, attemptId)).statusCode, 202); - await waitFor(() => h.hostCalls.length === 1); - await h.settle(); - const after = h.routeCalls.flatMap((call) => call.targets); - assert.equal(after.filter((catId) => catId === 'opus').length, 1); - assert.equal(after.filter((catId) => catId === 'gpt-pro').length, 2); - assert.equal(h.hostCalls[0].idempotencyKey, sourceId); -}); - -test('ordinary local immediate dispatch retains its existing source and execution', async () => { - h = await firstMessageHarness({ targetCats: ['opus'] }); - const response = await h.send(); - assert.equal(response.statusCode, 200, response.body); - assert.equal(response.json().status, 'processing'); - await h.settle(); - assert.equal(h.messageStore.getById(response.json().userMessageId).queueCustody, undefined); - assert.equal(h.routeCalls.length, 1); - assert.equal(h.hostCalls.length, 0); -}); - -test('explicit force still preempts the owned cloud slot using its existing direct path', async () => { - h = await firstMessageHarness({ bound: true }); - const previous = h.tracker.start(h.thread.id, 'gpt-pro', h.userId, ['gpt-pro']); - const response = await h.send({ deliveryMode: 'force' }); - assert.equal(response.statusCode, 200, response.body); - assert.equal(response.json().status, 'processing'); - assert.equal(previous.signal.aborted, true); - await waitFor(() => h.hostCalls.length === 1); - await h.settle(); - assert.equal(h.messageStore.getById(response.json().userMessageId).queueCustody, undefined); - assert.equal(h.hostCalls[0].idempotencyKey, response.json().userMessageId); -}); - -test('a busy cloud slot queues the source and obtains recovery after the existing owner completes', async () => { - h = await firstMessageHarness(); - const previous = h.tracker.start(h.thread.id, 'gpt-pro', h.userId, ['gpt-pro']); - const sourceId = (await h.send()).json().userMessageId; - assert.equal(h.routeCalls.length, 0); - assert.equal(previous.signal.aborted, false); - assert.ok(h.messageStore.getById(sourceId).queueCustody); - h.tracker.complete(h.thread.id, 'gpt-pro', previous); - await h.processor.onInvocationComplete(h.thread.id, 'gpt-pro', 'succeeded', undefined, ['gpt-pro']); - await h.settle(); - assert.equal((await h.authority(sourceId)).statusCode, 200); - assert.equal(h.routeCalls.length, 1); -}); diff --git a/packages/api/test/f247-first-message-recovery.test.js b/packages/api/test/f247-first-message-recovery.test.js deleted file mode 100644 index 368147059f..0000000000 --- a/packages/api/test/f247-first-message-recovery.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { firstMessageHarness, waitFor } from './helpers/f247-first-message-harness.js'; - -let h; -afterEach(async () => { - if (h) await h.close(); - h = undefined; -}); - -test('idle first cloud message obtains a current retry fence and binds/retries the original source', async () => { - h = await firstMessageHarness(); - const posted = await h.send(); - assert.ok([200, 202].includes(posted.statusCode), posted.body); - const sourceId = posted.json().userMessageId; - await h.settle(); - assert.equal(h.hostCalls.length, 0, 'unbound first attempt does not append to Host'); - const authority = await h.authority(sourceId); - assert.equal(authority.statusCode, 200, `first-message recovery must exist: ${authority.body}`); - const attemptId = authority.json().attemptId; - const source = h.messageStore.getById(sourceId); - assert.equal(source.queueCustody.targetAttempts.at(-1).state, 'failed'); - assert.equal(h.queue.hasOrdinaryEligibleQueuedForThread(h.thread.id), false); - await h.processor.processNext(h.thread.id, h.userId); - assert.equal(h.routeCalls.length, 1, 'ordinary progress never retries the failed target'); - assert.equal((await h.bind()).statusCode, 200); - assert.equal(h.hostCalls.length, 0, 'binding alone is not a send'); - const retries = await Promise.all([h.retry(sourceId, attemptId), h.retry(sourceId, attemptId)]); - assert.deepEqual(retries.map((response) => response.statusCode).sort(), [202, 409]); - await waitFor(() => h.hostCalls.length === 1); - await h.settle(); - assert.equal(h.hostCalls[0].idempotencyKey, sourceId); - assert.deepEqual( - h.routeCalls.map((call) => call.messageId), - [sourceId, sourceId], - ); - const history = await h.request('GET', `/api/messages?threadId=${h.thread.id}`); - assert.equal(history.json().messages.filter((message) => message.type === 'user').length, 1); - assert.ok( - history.json().messages.some((message) => message.source?.meta?.cloudBridgeOutboundReceipt?.status === 'sent'), - ); - assert.equal((await h.retry(sourceId, attemptId)).statusCode, 409); - assert.equal(h.hostCalls.length, 1); -}); - -test('failed first source survives Queue reconstruction without automatic replay', async () => { - h = await firstMessageHarness(); - const sourceId = (await h.send()).json().userMessageId; - await h.settle(); - const before = await h.authority(sourceId); - assert.equal(before.statusCode, 200, before.body); - const recovered = await h.restart(); - assert.equal(recovered.messagesFailed, 0); - assert.equal(recovered.entriesRestored, 1); - assert.equal(h.queue.hasOrdinaryEligibleQueuedForThread(h.thread.id), false); - await h.processor.processNext(h.thread.id, h.userId); - assert.equal(h.routeCalls.length, 1); - const after = await h.authority(sourceId); - assert.deepEqual(after.json(), before.json()); - assert.equal((await h.authority(sourceId, 'other-owner')).statusCode, 404); - assert.equal((await h.retry(sourceId, after.json().attemptId, 'other-owner')).statusCode, 404); - await h.bind(); - assert.equal((await h.retry(sourceId, after.json().attemptId)).statusCode, 202); - await waitFor(() => h.hostCalls.length === 1); - await h.settle(); - assert.equal(h.hostCalls[0].idempotencyKey, sourceId); -}); - -test('an already bound first source sends once and repeated admission does not resurrect it', async () => { - h = await firstMessageHarness({ bound: true }); - const request = { idempotencyKey: 'bca53f76-c72b-4d46-90c6-dbe329aa82b4' }; - const sourceId = (await h.send(request)).json().userMessageId; - await waitFor(() => h.hostCalls.length === 1); - await h.settle(); - const replay = await h.send(request); - assert.equal(replay.json().userMessageId, sourceId); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(h.hostCalls.length, 1); - assert.equal(h.routeCalls.length, 1); - assert.equal(h.queue.list(h.thread.id, h.userId).length, 0); -}); diff --git a/packages/api/test/f247-owner-chrome-product-chain.test.js b/packages/api/test/f247-owner-chrome-product-chain.test.js index 44dd972f95..669e867b47 100644 --- a/packages/api/test/f247-owner-chrome-product-chain.test.js +++ b/packages/api/test/f247-owner-chrome-product-chain.test.js @@ -7,6 +7,7 @@ import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocat import { invokeSingleCat } from '../dist/domains/cats/services/agents/invocation/invoke-single-cat.js'; import { persistUserFacingSystemInfoNotices } from '../dist/domains/cats/services/agents/routing/persist-system-info-warnings.js'; import { buildCloudBridgeStatusContent } from '../dist/domains/cats/services/cloud-bridge/cloud-bridge-fallback.js'; +import { CloudReturnBindingSigner } from '../dist/domains/cats/services/cloud-bridge/cloud-return-binding.js'; import { MemoryCloudReturnGrantStore } from '../dist/domains/cats/services/cloud-bridge/cloud-return-grant.js'; import { dispatchBoundConversationThroughHost } from '../dist/domains/cats/services/cloud-bridge/conversation-host-dispatch.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; @@ -14,6 +15,7 @@ import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadSt import { callbacksRoutes } from '../dist/routes/callbacks.js'; import { safeAdapterDiagnostic } from '../src/plugins/cloud-cat-personal-host/native-host/native-results.mjs'; import { createUnsupportedNodeHarness } from './helpers/f247-unsupported-node-harness.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function ensureGptProRegistered() { if (catRegistry.has('gpt-pro')) return; @@ -39,18 +41,22 @@ describe('F247 normal owner-Chrome product chain', () => { const threadStore = new ThreadStore(); const thread = await threadStore.create('alice', 'F247 product chain'); await threadStore.updateCloudCatBinding(thread.id, 'gpt-pro', 'https://chatgpt.com/c/conversation-product-chain'); - const source = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: '@gpt-pro verify this exact source', - mentions: ['gpt-pro'], - timestamp: 1_000, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); + const source = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: '@gpt-pro verify this exact source', + mentions: ['gpt-pro'], + timestamp: 1_000, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); + const signer = new CloudReturnBindingSigner(Buffer.alloc(32, 17)); + // F117: the Host dispatch issues exactly one server-custodied return grant into + // this store before dispatching; the callback route claims it on source-bound returns. const grantStore = new MemoryCloudReturnGrantStore(); const bridgeCalls = []; - const dispositionCalls = []; const events = await drain( invokeSingleCat( { @@ -71,18 +77,6 @@ describe('F247 normal owner-Chrome product chain', () => { }, }, cloudReturnGrantStore: grantStore, - a2aDispatchDispositionService: { - async complete(auth, disposition) { - dispositionCalls.push({ auth, disposition }); - return { - outcome: 'applied', - disposition, - invocationId: auth.invocationId, - sourceMessageId: auth.a2aTriggerMessageId, - fromCatId: 'codex-sol', - }; - }, - }, }, { catId: 'gpt-pro', @@ -91,6 +85,7 @@ describe('F247 normal owner-Chrome product chain', () => { freshnessCarrierCapability: () => ({ provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }), }, @@ -112,23 +107,27 @@ describe('F247 normal owner-Chrome product chain', () => { assert.equal(bridgeCalls.length, 1); assert.equal(bridgeCalls[0].sourceMessageId, source.id); assert.equal('cloudReturnBinding' in bridgeCalls[0], false); - assert.equal(dispositionCalls.length, 1); const statusEvent = events.find( (event) => event.type === 'system_info' && event.content && JSON.parse(event.content).type === 'cloud_bridge_status', ); + const createdEvent = events.find( + (event) => + event.type === 'system_info' && event.content && JSON.parse(event.content).type === 'invocation_created', + ); const status = JSON.parse(statusEvent.content); + const created = JSON.parse(createdEvent.content); assert.deepEqual( { sourceMessageId: status.outboundReceipt.sourceMessageId, - dispatchInvocationId: status.outboundReceipt.dispatchInvocationId, + dispatchInvocationId: created.invocationId, status: status.outboundReceipt.status, transport: status.outboundReceipt.transport, hostMessageId: status.outboundReceipt.hostMessageId, }, { sourceMessageId: source.id, - dispatchInvocationId: dispositionCalls[0].auth.invocationId, + dispatchInvocationId: status.outboundReceipt.dispatchInvocationId, status: 'sent', transport: 'host', hostMessageId: 'host-message-product-chain', @@ -155,18 +154,43 @@ describe('F247 normal owner-Chrome product chain', () => { await app.register(callbacksRoutes, { registry: new InvocationRegistry(), agentKeyRegistry, + cloudReturnBindingSigner: signer, cloudReturnGrantStore: grantStore, messageStore, threadStore, socketManager: { broadcastAgentMessage: () => undefined }, }); - const wrongSource = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: thread.id, - content: 'a different source in the same thread', - timestamp: 2_000, + // F117 new contract: a legacy cloudReturnBinding without its exact replyTo source + // is rejected 400 before any grant claim; the normal source-bound path is + // authorized by the server-custodied grant, not by a client-sent binding. + const missingBinding = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': secret }, + payload: { + content: 'must not return without the exact capability', + threadId: thread.id, + cloudReturnBinding: signer.sign({ + threadId: thread.id, + userId: 'alice', + sourceMessageId: bridgeCalls[0].sourceMessageId, + dispatchInvocationId: 'inv-cloud', + targetCatId: 'gpt-pro', + }), + }, }); + assert.equal(missingBinding.statusCode, 400); + assert.equal(missingBinding.json().kind, 'cloud_return_binding_required'); + + const wrongSource = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: thread.id, + content: 'a different source in the same thread', + timestamp: 2_000, + }), + ); const substitutedSource = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', @@ -201,15 +225,17 @@ describe('F247 normal owner-Chrome product chain', () => { it('persists only the bounded text-free fingerprint from a terminal Host failure', async () => { const messageStore = new MessageStore(); - const source = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - threadId: 'thread-failed-dispatch', - content: '@gpt-pro private source body', - mentions: ['gpt-pro'], - timestamp: 1_000, - extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, - }); + const source = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + threadId: 'thread-failed-dispatch', + content: '@gpt-pro private source body', + mentions: ['gpt-pro'], + timestamp: 1_000, + extra: { stream: { invocationId: 'inv-source', turnInvocationId: 'inv-source' } }, + }), + ); const harness = createUnsupportedNodeHarness(); let pageDiagnostic; await assert.rejects( diff --git a/packages/api/test/f247-queued-cloud-receipt-provenance.test.js b/packages/api/test/f247-queued-cloud-receipt-provenance.test.js deleted file mode 100644 index 5f1757afcb..0000000000 --- a/packages/api/test/f247-queued-cloud-receipt-provenance.test.js +++ /dev/null @@ -1,112 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { createInitialQueuedMessageCustody } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { persistUserFacingSystemInfoNotices } from '../dist/domains/cats/services/agents/routing/persist-system-info-warnings.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { resolveVisibleReplyParent } from '../dist/domains/cats/services/stores/visibility.js'; - -async function persist({ sourceOverrides = {}, exposures, receiptOverrides = {}, status = 'failed', transition } = {}) { - const store = new MessageStore(); - const { entry } = new InvocationQueue().enqueue({ - threadId: 'owner-thread', - userId: 'alice', - ownerAuthProvenance: 'strict', - idempotencyKey: 'first-source', - content: 'original user body', - targetCats: ['gpt-pro', 'opus'], - source: 'user', - intent: 'execute', - }); - const source = store.append({ - threadId: 'owner-thread', - userId: 'alice', - catId: null, - content: 'original user body', - mentions: ['gpt-pro'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: { - ...createInitialQueuedMessageCustody(entry), - bodyExposures: exposures ?? [{ targetCatId: 'gpt-pro', invocationId: 'cloud-child', seenAt: 1_001 }], - }, - ...sourceOverrides, - }); - if (transition === 'deleted') store.softDelete(source.id, 'alice'); - if (transition === 'canceled') store.markCanceled(source.id); - const outboundReceipt = { - v: 1, - sourceMessageId: source.id, - sourceSender: { kind: 'user', id: 'alice' }, - dispatchInvocationId: 'cloud-child', - targetCatId: 'gpt-pro', - status, - transport: status === 'sent' ? 'host' : 'none', - ...(status === 'sent' ? { hostMessageId: 'host-confirmed-id' } : {}), - idempotency: { keyKind: 'source_message_id', disposition: status === 'sent' ? 'fresh' : 'not_attempted' }, - ...receiptOverrides, - }; - await persistUserFacingSystemInfoNotices({ - messageStore: store, - threadId: 'owner-thread', - catId: 'gpt-pro', - expectedSourceMessageId: source.id, - expectedDispatchInvocationId: 'cloud-child', - contents: [ - JSON.stringify({ - type: 'cloud_bridge_status', - message: 'Cloud status', - ...(status === 'failed' ? { reason: 'needs-binding' } : {}), - outboundReceipt, - }), - ], - }); - const notice = store - .getByThread('owner-thread') - .find((message) => message.source?.connector === 'cloud-bridge-status'); - return { store, source, notice, outboundReceipt }; -} - -for (const status of ['failed', 'sent']) { - test(`persists ${status} receipt for the exact exposed queued user source without publishing its body`, async () => { - const { store, source, notice, outboundReceipt } = await persist({ status }); - assert.deepEqual(notice.source.meta.cloudBridgeOutboundReceipt, outboundReceipt); - assert.equal(notice.replyTo, source.id); - assert.equal(notice.source.meta.cloudBridgeRecovery?.sourceMessageId, status === 'failed' ? source.id : undefined); - assert.equal(store.getById(source.id).deliveryStatus, 'queued'); - assert.equal( - await resolveVisibleReplyParent(store, source.id, { - threadId: 'owner-thread', - viewer: { type: 'cat', catId: 'opus' }, - publicReply: true, - }), - null, - 'receipt validation does not relax ordinary reply publication', - ); - }); -} - -for (const [name, options] of [ - ['no body exposure', { exposures: [] }], - ['a different child', { exposures: [{ targetCatId: 'gpt-pro', invocationId: 'other-child', seenAt: 1_001 }] }], - ['a different target', { exposures: [{ targetCatId: 'opus', invocationId: 'cloud-child', seenAt: 1_001 }] }], - ['another thread', { sourceOverrides: { threadId: 'private-thread' } }], - ['a different owner', { sourceOverrides: { userId: 'bob' } }], - ['a deleted source', { transition: 'deleted' }], - ['a canceled source', { transition: 'canceled' }], - [ - 'a system source', - { sourceOverrides: { userId: 'system' }, receiptOverrides: { sourceSender: { kind: 'user', id: 'system' } } }, - ], - ['a briefing', { sourceOverrides: { origin: 'briefing' } }], - ['an unrevealed whisper', { sourceOverrides: { visibility: 'whisper', whisperTo: ['gpt-pro'] } }], - ['a forged dispatch', { receiptOverrides: { dispatchInvocationId: 'other-child' } }], - ['a forged source', { receiptOverrides: { sourceMessageId: 'other-source' } }], -]) { - test(`refuses queued source receipt for ${name}`, async () => { - const { notice } = await persist(options); - assert.equal(notice.replyTo, undefined); - assert.equal(notice.source.meta.cloudBridgeOutboundReceipt, undefined); - assert.equal(notice.source.meta.cloudBridgeRecovery, undefined); - }); -} diff --git a/packages/api/test/f254-freshness-closure-routes.test.js b/packages/api/test/f254-freshness-closure-routes.test.js index e8740afc7a..e9d1bc051d 100644 --- a/packages/api/test/f254-freshness-closure-routes.test.js +++ b/packages/api/test/f254-freshness-closure-routes.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { threadsRoutes } = await import('../dist/routes/threads.js'); const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); @@ -43,15 +44,17 @@ describe('F254 Phase E — closure projection and retry routes', () => { const thread = await threadStore.create('user-1', 'F254 repaired route test', '/tmp/test'); const messageStore = new MessageStore(); const closureStore = new InMemoryFreshnessClosureStore(); - const original = await messageStore.append({ - userId: 'user-1', - catId: 'codex-sol', - threadId: thread.id, - content: 'published answer', - mentions: [], - timestamp: 100, - origin: 'stream', - }); + const original = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex-sol', + threadId: thread.id, + content: 'published answer', + mentions: [], + timestamp: 100, + origin: 'stream', + }), + ); const offered = await closureStore.offerSupplement({ lineageId: original.id, originalMessageId: original.id, @@ -66,24 +69,26 @@ describe('F254 Phase E — closure projection and retry routes', () => { invocationId: 'supplement-inv', now: 120, }); - const leaked = await messageStore.append({ - userId: 'user-1', - catId: 'codex-sol', - threadId: thread.id, - content: '', - mentions: [], - timestamp: 130, - origin: 'stream', - replyTo: original.id, - extra: { - supplement: { - lineageId: original.id, - supplementId: running.id, - seq: running.seq, - originalMessageId: original.id, + const leaked = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex-sol', + threadId: thread.id, + content: '', + mentions: [], + timestamp: 130, + origin: 'stream', + replyTo: original.id, + extra: { + supplement: { + lineageId: original.id, + supplementId: running.id, + seq: running.seq, + originalMessageId: original.id, + }, }, - }, - }); + }), + ); await closureStore.commitSupplement(running.id, { invocationId: 'supplement-inv', messageId: leaked.id, @@ -128,13 +133,13 @@ describe('F254 Phase E — closure projection and retry routes', () => { threadStore, freshnessClosureStore: closureStore, invocationQueue: { - enqueue(entry) { + async enqueueDurable(entry) { entries.push(entry); return { outcome: 'enqueued', entry: { ...entry, id: 'queue-1' } }; }, }, queueProcessor: { - async tryAutoExecute(threadId) { + async requestDrain(threadId) { autoExecuteThreadId = threadId; }, }, @@ -172,7 +177,7 @@ describe('F254 Phase E — closure projection and retry routes', () => { assert.equal(retried.statusCode, 202); assert.equal(entries.length, 1); assert.equal(entries[0].freshnessClosureId, 'closure-1'); - assert.equal(entries[0].autoExecute, true, 'explicit retry must be visible to tryAutoExecute'); + assert.equal(entries[0].autoExecute, true, 'explicit retry remains visible to Queue projections'); assert.equal((await closureStore.get('closure-1')).status, 'pending'); await new Promise((resolve) => setImmediate(resolve)); assert.equal(autoExecuteThreadId, thread.id); diff --git a/packages/api/test/f254-freshness-reinvoke-check.test.js b/packages/api/test/f254-freshness-reinvoke-check.test.js index e708d70fef..639d6e8bce 100644 --- a/packages/api/test/f254-freshness-reinvoke-check.test.js +++ b/packages/api/test/f254-freshness-reinvoke-check.test.js @@ -413,7 +413,11 @@ describe('F254 B3 — createFreshnessReinvokeCheck', () => { assert.ok(result.reinvokePrompt.includes('1'), 'prompt should mention notice count'); assert.ok(result.reinvokePrompt.includes('get_thread_context'), 'prompt should name the thread read surface'); assert.ok(result.reinvokePrompt.includes('thread-1'), 'prompt should bind the current thread'); - assert.ok(result.reinvokePrompt.includes('responseMode'), 'prompt should require a full read'); + assert.ok( + result.reinvokePrompt.includes('responseMode'), + 'prompt should require full bodies for the unread selection', + ); + assert.match(result.reinvokePrompt, /contextScope=unread_delta/); assert.ok(!result.reinvokePrompt.includes('list_recent'), 'prompt must not name the project-memory recent surface'); }); diff --git a/packages/api/test/f254-freshness-replay-provider.test.js b/packages/api/test/f254-freshness-replay-provider.test.js index cb91a7b9dc..84c7bc0079 100644 --- a/packages/api/test/f254-freshness-replay-provider.test.js +++ b/packages/api/test/f254-freshness-replay-provider.test.js @@ -144,7 +144,12 @@ describe('F254 AC-E9 freshness replay provider', () => { store: new InMemoryFreshnessClosureStore(), fixtureRoot, queueLifecycleSource: { - async listOwnerQueueCustodyLifecycles() { + async listOwnerDurableEntries() { + return []; + }, + }, + messageLifecycleSource: { + async listOwnerMessagesInWindow() { return []; }, }, @@ -179,44 +184,27 @@ describe('F254 AC-E9 freshness replay provider', () => { now: 1_100, }); await store.claimSupplement(offered.supplement.id, { invocationId: 'inv-supplement', now: 1_200 }); - const queueRecord = { - messageId: 'message-queue', + const queueEntry = { + id: 'entry-queue', threadId: 'thread-live', userId: 'user-1', - custody: { - version: 1, - entryId: 'entry-queue', - revision: 2, - ownerUserId: 'user-1', - intent: 'respond', - status: 'processing', - allTargetCats: ['codex-sol'], - pendingTargetCats: ['codex-sol'], - notifiedByCatIds: ['codex-sol'], - seenByCatIds: ['codex-sol'], - seenInvocationIdByCatId: { 'codex-sol': 'inv-queue' }, - bodyExposures: [{ targetCatId: 'codex-sol', invocationId: 'inv-queue', seenAt: 1_250 }], - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 1_050, - updatedAt: 1_250, - }, + targets: ['codex-sol'], + enqueuedAt: 1_050, + claimedAt: 1_250, + processingStartedAt: 1_250, + payload: { messageId: 'message-queue' }, }; const provider = new FreshnessReplayProviderImpl({ store, fixtureRoot, queueLifecycleSource: { - async listOwnerQueueCustodyLifecycles() { - return [ - queueRecord, - { - ...queueRecord, - messageId: 'message-other-owner', - userId: 'user-2', - custody: { ...queueRecord.custody, entryId: 'entry-other-owner', ownerUserId: 'user-2' }, - }, - ]; + async listOwnerDurableEntries(ownerUserId) { + return ownerUserId === 'user-1' ? [queueEntry] : []; + }, + }, + messageLifecycleSource: { + async listOwnerMessagesInWindow() { + return []; }, }, attentionEventLog: { @@ -261,7 +249,12 @@ describe('F254 AC-E9 freshness replay provider', () => { store: new InMemoryFreshnessClosureStore(), fixtureRoot, queueLifecycleSource: { - async listOwnerQueueCustodyLifecycles() { + async listOwnerDurableEntries() { + return []; + }, + }, + messageLifecycleSource: { + async listOwnerMessagesInWindow() { return []; }, }, diff --git a/packages/api/test/f254-freshness-supplement-startup-recovery.test.js b/packages/api/test/f254-freshness-supplement-startup-recovery.test.js index 8576ab532a..55f606ec47 100644 --- a/packages/api/test/f254-freshness-supplement-startup-recovery.test.js +++ b/packages/api/test/f254-freshness-supplement-startup-recovery.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it, mock } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { InMemoryFreshnessClosureStore } = await import( @@ -69,24 +70,26 @@ describe('F254 ADR-042 supplement startup reconciliation', () => { const pending = await createPending(closureStore); const running = await closureStore.claimSupplement(pending.id, { invocationId: 'inv-crashed', now: 200 }); const messageStore = new MessageStore(); - const published = await messageStore.appendAndObservePriorFrontier({ - userId: running.userId, - threadId: running.threadId, - catId: running.catId, - content: 'durable supplement body', - mentions: [], - timestamp: 250, - replyTo: running.originalMessageId, - idempotencyKey: running.id, - extra: { - supplement: { - lineageId: running.lineageId, - supplementId: running.id, - seq: running.seq, - originalMessageId: running.originalMessageId, + const published = await messageStore.appendAndObservePriorFrontier( + canonicalTestMessageInput({ + userId: running.userId, + threadId: running.threadId, + catId: running.catId, + content: 'durable supplement body', + mentions: [], + timestamp: 250, + replyTo: running.originalMessageId, + idempotencyKey: running.id, + extra: { + supplement: { + lineageId: running.lineageId, + supplementId: running.id, + seq: running.seq, + originalMessageId: running.originalMessageId, + }, }, - }, - }); + }), + ); const projections = []; const result = await reconcileFreshnessSupplementsAtStartup({ diff --git a/packages/api/test/f254-hold-ball-reminder.test.js b/packages/api/test/f254-hold-ball-reminder.test.js index 2539e4860e..e5bec6da1b 100644 --- a/packages/api/test/f254-hold-ball-reminder.test.js +++ b/packages/api/test/f254-hold-ball-reminder.test.js @@ -108,7 +108,8 @@ describe('F254 B2: hold_ball reminder', () => { assert.ok(result); assert.ok(result.text.includes('未读消息'), 'reminder should mention unread messages'); assert.ok(result.text.includes('get_thread_context'), 'reminder should suggest get_thread_context'); - assert.match(result.text, /responseMode.*full/, 'reminder should require a full contiguous thread read'); + assert.match(result.text, /responseMode.*full/, 'reminder should require full bodies for the unread selection'); + assert.match(result.text, /contextScope=unread_delta/); assert.ok(result.text.includes('threadId=thread-001'), 'reminder should identify the source thread'); assert.ok(result.text.includes('messageId=msg-123'), 'reminder should identify the newest unresolved message'); }); diff --git a/packages/api/test/f254-incident-registry-v1-2.test.js b/packages/api/test/f254-incident-registry-v1-2.test.js index 2c35b15ca7..184bdc3bd5 100644 --- a/packages/api/test/f254-incident-registry-v1-2.test.js +++ b/packages/api/test/f254-incident-registry-v1-2.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { assembleContext } = await import('../dist/domains/cats/services/context/ContextAssembler.js'); const { InMemoryFreshnessClosureStore } = await import( @@ -55,14 +56,16 @@ describe('F254 incident registry v1.2', () => { now: 120, }); - const trigger = await messageStore.append({ - userId: 'user-1', - threadId: 'thread-1', - catId: null, - content: 'new independent question', - mentions: ['fable5'], - timestamp: 200, - }); + const trigger = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + threadId: 'thread-1', + catId: null, + content: 'new independent question', + mentions: ['fable5'], + timestamp: 200, + }), + ); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); const decision = await coordinator.commit({ userId: 'user-1', @@ -71,7 +74,7 @@ describe('F254 incident registry v1.2', () => { invocationId: 'new-parent', turnInvocationId: 'new-fable-turn', originTriggerMessageId: trigger.id, - message: { + message: canonicalTestMessageInput({ userId: 'user-1', threadId: 'thread-1', catId: 'fable5', @@ -80,7 +83,7 @@ describe('F254 incident registry v1.2', () => { timestamp: 210, origin: 'stream', extra: { stream: { invocationId: 'new-parent', turnInvocationId: 'new-fable-turn' } }, - }, + }), evaluateFreshness: async () => ({ freshness: fresh(trigger.id), rawFrontierMessageId: trigger.id }), }); diff --git a/packages/api/test/f254-notice-service.test.js b/packages/api/test/f254-notice-service.test.js index 4339ba5728..6a29384b60 100644 --- a/packages/api/test/f254-notice-service.test.js +++ b/packages/api/test/f254-notice-service.test.js @@ -126,6 +126,8 @@ describe('F254 FreshnessNoticeService', () => { assert.notEqual(result, null, 'first read-only call should return notice'); assert.ok(result.noticeId, 'notice should have an ID'); assert.ok(result.text.includes('2'), 'notice should include unseen count'); + assert.match(result.text, /contextScope=unread_delta/); + assert.match(result.text, /hasMore=false.*不表示.*历史/); }); it('returns null when no unseen messages', async () => { diff --git a/packages/api/test/f254-output-commit-coordinator.test.js b/packages/api/test/f254-output-commit-coordinator.test.js index 7b9ad5451f..6e7c95c00f 100644 --- a/packages/api/test/f254-output-commit-coordinator.test.js +++ b/packages/api/test/f254-output-commit-coordinator.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { InMemoryFreshnessClosureStore } = await import( @@ -15,7 +16,7 @@ const { getFreshnessGlassBoxTelemetrySnapshot, resetFreshnessGlassBoxTelemetryFo const scope = { userId: 'user-1', threadId: 'thread-1', catId: 'codex-sol' }; function draft(content = 'draft') { - return { + return canonicalTestMessageInput({ userId: scope.userId, catId: scope.catId, content, @@ -23,7 +24,7 @@ function draft(content = 'draft') { timestamp: 200, threadId: scope.threadId, origin: 'stream', - }; + }); } function stale(messageId) { @@ -56,14 +57,16 @@ function fresh(seenCursor) { describe('F254 ADR-042 — glass-box output commit', () => { it('publishes a known-stale original, annotates its exact boundary, and offers seq 1', async () => { const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [scope.catId], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [scope.catId], + timestamp: 150, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); @@ -98,15 +101,17 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('publishes the completed original but leaves ordinary queued work solely owned by Queue', async () => { const messageStore = new MessageStore(); - const queued = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'queued update', - mentions: [scope.catId], - timestamp: 150, - threadId: scope.threadId, - deliveryStatus: 'queued', - }); + const queued = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'queued update', + mentions: [scope.catId], + timestamp: 150, + threadId: scope.threadId, + deliveryStatus: 'queued', + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); @@ -238,14 +243,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('does not claim published_with_unseen when the successful offer annotation cannot persist', async () => { const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [scope.catId], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [scope.catId], + timestamp: 150, + threadId: scope.threadId, + }), + ); messageStore.updateExtra = async () => { throw new Error('metadata store unavailable'); }; @@ -274,14 +281,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('marks the published original when supplement responsibility cannot be persisted', async () => { const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [scope.catId], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [scope.catId], + timestamp: 150, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); closureStore.offerSupplement = async () => { throw new Error('supplement store unavailable'); @@ -313,14 +322,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('keeps the atomic pre-append frontier when another message arrives during the scan', async () => { const messageStore = new MessageStore(); - const trigger = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'question', - mentions: [scope.catId], - timestamp: 100, - threadId: scope.threadId, - }); + const trigger = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'question', + mentions: [scope.catId], + timestamp: 100, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); @@ -330,14 +341,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { message: draft('answer linearized before later facts'), evaluateFreshness: async (priorFrontierMessageId) => { assert.equal(priorFrontierMessageId, trigger.id); - await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'arrived after publication', - mentions: [scope.catId], - timestamp: 250, - threadId: scope.threadId, - }); + await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'arrived after publication', + mentions: [scope.catId], + timestamp: 250, + threadId: scope.threadId, + }), + ); return { freshness: fresh(trigger.id), rawFrontierMessageId: priorFrontierMessageId }; }, }); @@ -352,14 +365,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('idempotent retry reuses the published message and original observation boundary', async () => { const messageStore = new MessageStore(); - const frontier = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'question', - mentions: [], - timestamp: 100, - threadId: scope.threadId, - }); + const frontier = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'question', + mentions: [], + timestamp: 100, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); const input = { @@ -386,14 +401,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('persists checked_no_supplement_needed without creating an empty bubble', async () => { const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [], + timestamp: 150, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); const original = await coordinator.commit({ @@ -432,14 +449,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('fails closed when a routing hook appends prose after the internal decline marker', async () => { resetFreshnessGlassBoxTelemetryForTest(); const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [], + timestamp: 150, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); const original = await coordinator.commit({ @@ -478,14 +497,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('commits a supplement as a current-timestamp reply linked to the original', async () => { const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [], + timestamp: 150, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); const original = await coordinator.commit({ @@ -530,14 +551,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('retries the supplement state transition after the reply body is already durable', async () => { const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [], + timestamp: 150, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); const original = await coordinator.commit({ @@ -583,14 +606,16 @@ describe('F254 ADR-042 — glass-box output commit', () => { it('returns a deliverable degraded decision if supplement state persistence remains unavailable', async () => { const messageStore = new MessageStore(); - const unseen = await messageStore.append({ - userId: scope.userId, - catId: null, - content: 'late correction', - mentions: [], - timestamp: 150, - threadId: scope.threadId, - }); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: scope.userId, + catId: null, + content: 'late correction', + mentions: [], + timestamp: 150, + threadId: scope.threadId, + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const coordinator = new FreshnessOutputCommitCoordinator({ messageStore, closureStore }); const original = await coordinator.commit({ diff --git a/packages/api/test/f254-output-commit-race.test.js b/packages/api/test/f254-output-commit-race.test.js index 734c6e85ea..ee4b3c637c 100644 --- a/packages/api/test/f254-output-commit-race.test.js +++ b/packages/api/test/f254-output-commit-race.test.js @@ -1,10 +1,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); function userMessage(content, timestamp, extra = {}) { - return { + return canonicalTestMessageInput({ userId: 'user-1', catId: null, content, @@ -12,11 +13,11 @@ function userMessage(content, timestamp, extra = {}) { timestamp, threadId: 'thread-1', ...extra, - }; + }); } function finalMessage(content, timestamp) { - return { + return canonicalTestMessageInput({ userId: 'user-1', catId: 'codex-sol', content, @@ -25,7 +26,7 @@ function finalMessage(content, timestamp) { threadId: 'thread-1', origin: 'stream', idempotencyKey: 'freshness-closure:closure-1:final', - }; + }); } describe('F254 Phase E — atomic output commit boundary', () => { diff --git a/packages/api/test/f254-output-commit-redis-race.test.js b/packages/api/test/f254-output-commit-redis-race.test.js index ace44c9501..eb61a82f6c 100644 --- a/packages/api/test/f254-output-commit-redis-race.test.js +++ b/packages/api/test/f254-output-commit-redis-race.test.js @@ -9,7 +9,7 @@ import { const REDIS_URL = process.env.REDIS_URL; function message(content, timestamp, overrides = {}) { - return { + const input = { userId: 'user-1', catId: null, content, @@ -18,6 +18,11 @@ function message(content, timestamp, overrides = {}) { threadId: 'thread-1', ...overrides, }; + const { catId, ...messageInput } = input; + return { + from: catId ? { kind: 'agent', catId } : { kind: 'user', userId: input.userId }, + ...messageInput, + }; } describe('RedisMessageStore F254 conditional append', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { @@ -74,6 +79,11 @@ describe('RedisMessageStore F254 conditional append', { skip: redisIsolationSkip priorFrontierMessageId: queued.id, }); assert.equal((await store.getById(result.message.id)).content, 'published answer'); + assert.deepEqual( + (await store.getByThreadAfter('thread-1', undefined, 10, 'user-1')).map((entry) => entry.id), + [trigger.id, result.message.id], + ); + assert.equal(Number.isSafeInteger(result.message.visibilitySeq), true); assert.notEqual(result.priorFrontierMessageId, trigger.id); }); @@ -195,6 +205,10 @@ describe('RedisMessageStore F254 conditional append', { skip: redisIsolationSkip const retry = await store.appendIfThreadFrontier({ ...candidate, content: 'duplicate' }, trigger.id); assert.equal(retry.kind, 'committed'); assert.equal(retry.message.id, won.message.id); + assert.deepEqual( + (await store.getByThreadAfter('thread-1', trigger.id, 10, 'user-1')).map((entry) => entry.id), + [racing.id, won.message.id], + ); assert.equal((await store.getByThread('thread-1')).filter((item) => item.catId === 'codex-sol').length, 1); }); @@ -220,4 +234,42 @@ describe('RedisMessageStore F254 conditional append', { skip: redisIsolationSkip ]); assert.deepEqual([left.kind, right.kind].sort(), ['committed', 'frontier_advanced']); }); + + it('validates the visibility allocator before conditional append side effects', async () => { + const trigger = await store.append(message('question', 100)); + const candidate = message('must not publish', 200, { + catId: 'codex-sol', + origin: 'stream', + idempotencyKey: 'f254-published:invalid-hwm', + }); + await redis.hset(MessageKeys.threadVisibilityMeta('thread-1'), 'hwm', 'invalid'); + + await assert.rejects(store.appendAndObservePriorFrontier(candidate), /VISIBILITY_HWM_INVALID/); + + assert.equal(await store.getByIdempotencyKey('user-1', 'thread-1', candidate.idempotencyKey), null); + assert.deepEqual(await redis.zrange(MessageKeys.thread('thread-1'), 0, -1), [trigger.id]); + }); + + it('keeps queued user work private while publishing queued cat speech', async () => { + const queuedUser = await store.appendAndObservePriorFrontier( + message('private queued work', 100, { deliveryStatus: 'queued' }), + ); + const queuedCat = await store.appendAndObservePriorFrontier( + message('published queued speech', 200, { + catId: 'codex-sol', + origin: 'stream', + deliveryStatus: 'queued', + }), + ); + + assert.equal(queuedUser.message.visibilitySeq, undefined); + assert.equal(Number.isSafeInteger(queuedCat.message.visibilitySeq), true); + assert.equal(await redis.zscore(MessageKeys.threadVisibility('thread-1'), queuedUser.message.id), null); + assert.deepEqual( + (await store.getByThreadAfter('thread-1', undefined, 10, 'user-1', { includeQueuedCatMessages: true })).map( + (entry) => entry.id, + ), + [queuedCat.message.id], + ); + }); }); diff --git a/packages/api/test/f254-phase-e-closure-wiring.test.js b/packages/api/test/f254-phase-e-closure-wiring.test.js index 1344e8f392..5511c77998 100644 --- a/packages/api/test/f254-phase-e-closure-wiring.test.js +++ b/packages/api/test/f254-phase-e-closure-wiring.test.js @@ -25,7 +25,7 @@ describe('F254 Phase E production wiring guard', () => { it('keeps typed successor adoption and connector commit projection wired', () => { const queue = source('domains/cats/services/agents/invocation/QueueProcessor.ts'); const connector = source('infrastructure/connectors/StreamingOutboundHook.ts'); - assert.match(queue, /entry\.freshnessClosureId/); + assert.match(queue, /entry\.execution\.freshnessClosureId/); assert.match(queue, /claimAttempt/); assert.match(connector, /onClosureCatchingUp/); assert.match(connector, /onClosureBlocked/); @@ -37,7 +37,7 @@ describe('F254 Phase E production wiring guard', () => { const queue = source('domains/cats/services/agents/invocation/QueueProcessor.ts'); const index = source('index.ts'); assert.match(queue, /deliveryCursorStore\.ackSeenCursor/); - assert.match(index, /const queueProcessor = new QueueProcessor\({[\s\S]*?deliveryCursorStore,[\s\S]*?}\)/); + assert.match(index, /const queueProcessor = new QueueProcessor\(\s*\{[\s\S]*?deliveryCursorStore,[\s\S]*?\},\s*\{/); }); it('keeps replayable closure truth connected to the eval surface', () => { @@ -47,47 +47,35 @@ describe('F254 Phase E production wiring guard', () => { assert.match(adapter, /redundantCommittedAttemptCount/); }); - it('keeps ordinary queued messages single-owned while freshness only projects notice state', () => { + it('retires post-message freshness/HELD side effects while preserving output truth refinement', () => { const callbacks = source('routes/callbacks.ts'); const refiner = source('domains/cats/services/freshness/glass-box/FreshnessOutputRefiner.ts'); - assert.match(callbacks, /notice && opts\.invocationQueue/); - assert.match(callbacks, /markQueuedNotified/); - assert.match(callbacks, /queued_notified/); + assert.doesNotMatch(callbacks, /checkFreshnessForPostMessage/); + assert.doesNotMatch(callbacks, /acknowledgeHeld/); + assert.doesNotMatch(callbacks, /markQueuedNotified/); assert.match(refiner, /freshness\.reason === 'queued_messages'/); assert.match(refiner, /return committedDecision/); }); - it('keeps restart-durable queued-message custody wired across append, execution, read, and startup', () => { + it('keeps the single durable Queue ledger wired across append, execution, read, and startup', () => { const index = source('index.ts'); const messages = source('routes/messages.ts'); - assert.match(messages, /queueCustody:\s*createInitialQueuedMessageCustody\(enqueueResult\.entry\)/); - const custodyDependencies = index.match( - /const queueCustodyCoordinator = new QueuedMessageCustodyCoordinator\(\{([\s\S]*?)\}\);/, - )?.[1]; - assert.ok(custodyDependencies, 'production must instantiate the canonical Queue custody coordinator'); - assert.match(custodyDependencies, /\bmessageStore\s*[,}]/); - assert.match( - custodyDependencies, - /readWaitRegistration:\s*\(id\)\s*=>\s*taskStore\.getWaitRegistration\(id\)/, - 'Queue settlement must read the canonical Task registration instead of relying on a route snapshot', - ); - assert.match(index, /const queueProcessor = new QueueProcessor\(\{[\s\S]*?queueCustodyCoordinator,[\s\S]*?\}\)/); - assert.match(index, /await app\.register\(queueRoutes, \{[\s\S]*?queueCustodyCoordinator,[\s\S]*?\}\)/); - assert.match(index, /const callbackOpts = \{[\s\S]*?queueCustodyCoordinator,[\s\S]*?\} as Parameters/); - assert.match( - index, - /new StartupReconciler\(\{[\s\S]*?invocationQueue,[\s\S]*?a2aDispatchDispositionService[\s\S]*?resumePrestartRetirement:\s*\(entries\) => queueProcessor\.resumeDurablePrestartRetirement\(entries\)/, - ); + assert.match(messages, /appendAndEnqueueDurable/); + assert.match(index, /new RedisQueueLedgerStore\(redis\)/); + assert.match(index, /new InvocationQueue\([\s\S]*?RedisQueueLedgerStore/); + assert.match(index, /await invocationQueue\.hydrateFromLedger\(messageStore\)/); + const ledgerHydration = index.indexOf('await invocationQueue.hydrateFromLedger(messageStore)'); const queueRecovery = index.indexOf('const startupRecovery = await reconciler.reconcileOrphans()'); const callbackAdmission = index.indexOf('registry.markStartupRecoveryComplete()'); const queueResume = index.indexOf('for (const scope of startupRecovery.queueResumeScopes)'); - assert.ok(queueRecovery >= 0, 'production bootstrap must reconcile durable Queue custody'); + assert.ok(ledgerHydration >= 0, 'production bootstrap must hydrate the durable Queue ledger'); + assert.ok(queueRecovery > ledgerHydration, 'orphan reconciliation must see the hydrated Queue ledger'); assert.ok(callbackAdmission > queueRecovery, 'callback admission must wait for Queue restart convergence'); assert.ok(queueResume > callbackAdmission, 'restored Queue work must resume only after callback admission opens'); assert.match( index.slice(queueResume), - /queueProcessor\.processNext\(scope\.threadId, scope\.userId\)/, + /queueProcessor\.requestDrain\(scope\.threadId\)/, 'restored Queue scopes must re-enter the canonical QueueProcessor', ); }); diff --git a/packages/api/test/f254-provider-native-freshness.test.js b/packages/api/test/f254-provider-native-freshness.test.js index 4e7c838ecd..ae3a9bfba0 100644 --- a/packages/api/test/f254-provider-native-freshness.test.js +++ b/packages/api/test/f254-provider-native-freshness.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getCodexCarrierMode } from '../dist/config/codex-cli.js'; import { ClaudeAgentService } from '../dist/domains/cats/services/agents/providers/ClaudeAgentService.js'; +import { ClaudeSdkAgentService } from '../dist/domains/cats/services/agents/providers/ClaudeSdkAgentService.js'; import { CodexAgentService } from '../dist/domains/cats/services/agents/providers/CodexAgentService.js'; import { CodexAppServerClient } from '../dist/domains/cats/services/agents/providers/CodexAppServerClient.js'; import { ClaudeNativeToolBoundaryClassifier } from '../dist/domains/cats/services/agents/providers/claude-native-tool-boundary.js'; @@ -20,6 +20,7 @@ import { ThreadUnseenChecker } from '../dist/domains/cats/services/freshness/Thr import { buildTmuxAgentCarrierPaneCommand } from '../dist/domains/terminal/tmux-agent-carrier-session.js'; import { buildProviderNativeFreshnessCoverage } from '../dist/infrastructure/harness-eval/freshness/provider-native-freshness-coverage.js'; import { fakeL0Compiler } from './helpers/fake-l0-compiler.js'; +import { adaptInvocationQueue, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; class AsyncInbox { #values = []; @@ -145,19 +146,22 @@ describe('F254 D2 provider-native freshness truth', () => { const { createProviderNativeFreshnessFactory } = await import( '../dist/domains/cats/services/freshness/createProviderNativeFreshnessFactory.js' ); - const queue = new InvocationQueue(); - queue.enqueue({ - ownerAuthProvenance: 'strict', - threadId: 'thread-current-parent', - userId: 'user-1', - content: 'continue in the active provider turn', - source: 'user', - targetCats: ['opus'], - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: 'parent-active' }, - }, - intent: 'execute', - }); + const queue = adaptInvocationQueue(new InvocationQueue()); + queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + threadId: 'thread-current-parent', + userId: 'user-1', + content: 'continue in the active provider turn', + source: 'user', + targetCats: ['opus'], + authorIntentByCatId: { + opus: { requested: 'continue_current', boundParentInvocationId: 'parent-active' }, + }, + intent: 'execute', + }), + ); const factory = createProviderNativeFreshnessFactory({ redis: new FakeRedis(), cursorStore: { getSeenCursor: async () => 'seen-cursor' }, @@ -401,17 +405,28 @@ describe('F254 D2 provider-native freshness truth', () => { ); }); - it('correlates a queued-only synthetic frontier with exact Queue message identities', async () => { + it('correlates a queued-only synthetic frontier with exact source Queue identities', async () => { const queueMessageId = 'queued-message-1'; const mergedMessageId = 'queued-message-2'; const queueEntries = [ - { + canonicalTestQueueInput({ entryId: 'queue-1', source: 'user', + userId: 'user-1', + threadId: 'thread-1', + targetCats: ['codex-sol'], content: 'exact queued body', messageId: queueMessageId, - mergedMessageIds: [mergedMessageId], - }, + }), + canonicalTestQueueInput({ + entryId: 'queue-2', + source: 'user', + userId: 'user-1', + threadId: 'thread-1', + targetCats: ['codex-sol'], + content: 'second exact queued body', + messageId: mergedMessageId, + }), ]; const unseenChecker = new ThreadUnseenChecker({ userId: 'user-1', @@ -453,7 +468,17 @@ describe('F254 D2 provider-native freshness truth', () => { ); const laterMergedMessageId = 'queued-message-3'; - queueEntries[0].mergedMessageIds.push(laterMergedMessageId); + queueEntries.push( + canonicalTestQueueInput({ + entryId: 'queue-3', + source: 'user', + userId: 'user-1', + threadId: 'thread-1', + targetCats: ['codex-sol'], + content: 'third exact queued body', + messageId: laterMergedMessageId, + }), + ); const newlyEligible = await broker.prepare({ provider: 'openai_codex', carrier: 'codex_app_server', @@ -461,10 +486,9 @@ describe('F254 D2 provider-native freshness truth', () => { toolSurface: 'command_execution', turnId: 'turn-queued-new-identity', }); - assert.ok(newlyEligible, 'a newly coalesced durable message must permit one new notice'); - assert.deepEqual(newlyEligible.correlationMessageIds, [queueMessageId, mergedMessageId, laterMergedMessageId]); + assert.ok(newlyEligible, 'a newly queued durable row must permit one new notice'); + assert.deepEqual(newlyEligible.correlationMessageIds, [laterMergedMessageId]); await broker.commitDelivered(newlyEligible, { acceptedTurnId: 'turn-queued-new-identity' }); - queueEntries[0].mergedMessageIds.reverse(); assert.equal( await broker.prepare({ provider: 'openai_codex', @@ -474,7 +498,7 @@ describe('F254 D2 provider-native freshness truth', () => { turnId: 'turn-queued-new-identity-duplicate', }), null, - 'the newly coalesced identity must also be eligible only once', + 'the newly queued identity must also be eligible only once', ); assert.equal( @@ -485,7 +509,7 @@ describe('F254 D2 provider-native freshness truth', () => { evidenceKind: 'queue_exact_read', }), 0, - 'a partial read of a coalesced Queue entry must fail closed', + 'reading a different source Queue entry must not settle either notice', ); assert.equal( await eventLog.markProviderNoticesSeen({ @@ -500,21 +524,28 @@ describe('F254 D2 provider-native freshness truth', () => { await eventLog.markProviderNoticesHandled({ invocationId: 'inv-queued', catId: 'codex-sol', - queueEntryId: 'queue-1', - messageIds: [queueMessageId, mergedMessageId, laterMergedMessageId], + queueEntryId: 'queue-2', + messageIds: [mergedMessageId], evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-queued' }, }), - 2, + 1, + ); + assert.equal( + await eventLog.markProviderNoticesHandled({ + invocationId: 'inv-queued', + catId: 'codex-sol', + queueEntryId: 'queue-3', + messageIds: [laterMergedMessageId], + evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-queued' }, + }), + 1, ); const events = await eventLog.queryByInvocation('inv-queued'); const delivered = events.filter((event) => event.kind === 'provider_notice_delivered'); assert.deepEqual( delivered.map((event) => event.correlationMessageIds), - [ - [queueMessageId, mergedMessageId], - [queueMessageId, mergedMessageId, laterMergedMessageId], - ], + [[mergedMessageId], [laterMergedMessageId]], ); assert.deepEqual( events.map((event) => event.kind), @@ -540,11 +571,14 @@ describe('F254 D2 provider-native freshness truth', () => { messageStore: { getByThreadAfter: async () => [] }, queueChecker: { getQueuedForThread: () => [ - { + canonicalTestQueueInput({ entryId: 'queue-missing-id', source: 'user', + userId: 'user-1', + threadId: 'thread-1', + targetCats: ['codex-sol'], content: 'queued body without durable identity', - }, + }), ], }, }); @@ -658,6 +692,8 @@ describe('F254 D2 provider-native freshness truth', () => { assert.match(text, /get_thread_context/); assert.match(text, /thread-1/); assert.match(text, /responseMode.*full/); + assert.match(text, /contextScope=unread_delta/); + assert.match(text, /hasMore=false.*不表示.*历史/); assert.doesNotMatch(text, /list_recent/); assert.doesNotMatch(text, /secret body|operator/); }); @@ -1027,18 +1063,13 @@ describe('F254 D2 provider-native freshness truth', () => { ); }); - it('keeps exec_json as default and selects app_server only by explicit opt-in', () => { - assert.equal(getCodexCarrierMode({}), 'exec_json'); - assert.equal(getCodexCarrierMode({ CAT_CAFE_CODEX_CARRIER: 'unknown' }), 'exec_json'); - assert.equal(getCodexCarrierMode({ CAT_CAFE_CODEX_CARRIER: ' app_server ' }), 'app_server'); - }); - it('reports carrier capability without extrapolating MCP coverage', () => { assert.deepEqual( new CodexAgentService({ carrierMode: 'exec_json', model: 'gpt-test' }).freshnessCarrierCapability(), { provider: 'openai_codex', carrier: 'codex_exec_json', + activeInvocationGuidance: 'unsupported', deliverySemantics: 'unsupported', }, ); @@ -1047,17 +1078,26 @@ describe('F254 D2 provider-native freshness truth', () => { { provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', }, ); assert.deepEqual(new ClaudeAgentService({ model: 'claude-test' }).freshnessCarrierCapability(), { provider: 'anthropic', carrier: 'claude_print_sdk', + activeInvocationGuidance: 'unsupported', deliverySemantics: 'unsupported', }); + assert.deepEqual(new ClaudeSdkAgentService({ model: 'claude-test' }).freshnessCarrierCapability(), { + provider: 'anthropic', + carrier: 'claude_agent_sdk', + activeInvocationGuidance: 'supported', + deliverySemantics: 'queued_internal_turn', + }); assert.deepEqual(new KimiAgentService({ model: 'kimi-test' }).freshnessCarrierCapability(), { provider: 'kimi', carrier: 'kimi_stream_json', + activeInvocationGuidance: 'unsupported', deliverySemantics: 'unsupported', }); const policy = { mode: 'read_only', allowedToolNames: [], deniedToolNames: [], replayDeniedToolNames: [] }; diff --git a/packages/api/test/f254-queue-aware-freshness.test.js b/packages/api/test/f254-queue-aware-freshness.test.js index 6960ea2709..5e30bfdad2 100644 --- a/packages/api/test/f254-queue-aware-freshness.test.js +++ b/packages/api/test/f254-queue-aware-freshness.test.js @@ -15,6 +15,11 @@ import assert from 'node:assert/strict'; import { describe, it, mock } from 'node:test'; +import { + adaptInvocationQueue, + canonicalTestMessageInput, + canonicalTestQueueInput, +} from './helpers/message-from-fixtures.js'; /** @type {typeof import('../dist/domains/cats/services/freshness/checkFreshnessForPostMessage.js')} */ let wireModule; @@ -47,17 +52,23 @@ describe('F254 Queue-Aware Freshness Gate', async () => { /** Message store that returns empty (simulating isDelivered filtering out queued msgs) */ function makeMockMessageStore(messages = []) { + const canonicalMessages = messages.map((message) => + canonicalTestMessageInput({ userId, threadId, content: '', mentions: [], timestamp: 0, ...message }), + ); return { - getById: mock.fn(async (id) => messages.find((m) => m.id === id) ?? null), - getByThreadAfter: mock.fn(async () => messages), - getByThread: mock.fn(async () => messages), + getById: mock.fn(async (id) => canonicalMessages.find((m) => m.id === id) ?? null), + getByThreadAfter: mock.fn(async () => canonicalMessages), + getByThread: mock.fn(async () => canonicalMessages), }; } /** Queue checker that reports pending queued messages */ function makeMockQueueChecker(entries = []) { + const canonicalEntries = entries.map((entry) => + canonicalTestQueueInput({ threadId, userId, targetCats: [catId], ...entry }), + ); return { - getQueuedForThread: mock.fn(() => entries), + getQueuedForThread: mock.fn(() => canonicalEntries), }; } @@ -67,19 +78,22 @@ describe('F254 Queue-Aware Freshness Gate', async () => { describe('checkFreshnessForPostMessage with queueChecker', () => { it('preserves the exact parent fence through the provider-native Queue adapter', async () => { - const queue = new queueModule.InvocationQueue(); - queue.enqueue({ - ownerAuthProvenance: 'strict', - threadId, - userId, - content: 'read this in the current parent', - source: 'user', - targetCats: [catId], - authorIntentByCatId: { - [catId]: { requested: 'continue_current', boundParentInvocationId: invocationId }, - }, - intent: 'execute', - }); + const queue = adaptInvocationQueue(new queueModule.InvocationQueue()); + queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + threadId, + userId, + content: 'read this in the current parent', + source: 'user', + targetCats: [catId], + authorIntentByCatId: { + [catId]: { requested: 'continue_current', boundParentInvocationId: invocationId }, + }, + intent: 'execute', + }), + ); const result = await wireModule.checkFreshnessForPostMessage({ userId, @@ -538,18 +552,25 @@ describe('F254 Queue-Aware Freshness Gate', async () => { assert.equal(result.decision, 'forward'); }); - it('forwards after same cat has marked the queued entry seen', async () => { - const queue = new queueModule.InvocationQueue(); - const enqueued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId, - userId, - content: 'queued body already read', - source: 'user', - targetCats: [catId], - intent: 'execute', - }); - assert.equal(queue.markQueuedSeen(threadId, userId, enqueued.entry.id, catId), true); + it('forwards after History delivery removes the exact pending target', async () => { + const queue = adaptInvocationQueue(new queueModule.InvocationQueue()); + const enqueued = queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId, + userId, + content: 'queued body already read', + source: 'user', + targetCats: [catId], + intent: 'execute', + }), + ); + assert.equal( + (await queue.reconcileQueuedMessageTargetsDurable(threadId, userId, enqueued.entry.id, [], [catId], {})) + .outcome, + 'updated', + ); const cursorStore = makeMockCursorStore(msg1); const messageStore = makeMockMessageStore([]); @@ -813,7 +834,7 @@ describe('F254 Queue-Aware Freshness Gate', async () => { assert.deepEqual(result.correlationMessageIds, [crossThreadMessageId]); }); - it('recognizes cross-thread provenance on a coalesced same-cat Queue entry', async () => { + it('recognizes cross-thread provenance on a later independent same-cat Queue row', async () => { const sameThreadMessageId = '0000000002-000001-queued-same-thread'; const crossThreadMessageId = '0000000003-000001-queued-cross-thread'; const messages = new Map([ @@ -843,13 +864,20 @@ describe('F254 Queue-Aware Freshness Gate', async () => { }; const queueChecker = makeMockQueueChecker([ { - entryId: 'queue-coalesced-cross-thread', + entryId: 'queue-same-thread', source: 'agent', sourceCategory: 'a2a', - content: 'coalesced body', + content: 'same-thread body', callerCatId: catId, messageId: sameThreadMessageId, - mergedMessageIds: [crossThreadMessageId], + }, + { + entryId: 'queue-cross-thread', + source: 'agent', + sourceCategory: 'a2a', + content: 'cross-thread body', + callerCatId: catId, + messageId: crossThreadMessageId, }, ]); @@ -861,8 +889,8 @@ describe('F254 Queue-Aware Freshness Gate', async () => { }); const result = await checker.checkUnseen({ threadId, catId }); - assert.notEqual(result, null, 'any durable cross-thread trigger keeps the coalesced entry relevant'); - assert.deepEqual(result.correlationMessageIds, [sameThreadMessageId, crossThreadMessageId]); + assert.notEqual(result, null, 'a durable cross-thread row must survive the same-cat self filter'); + assert.deepEqual(result.correlationMessageIds, [crossThreadMessageId]); }); it('keeps a queued same-cat same-thread A2A entry classified as self-source', async () => { @@ -928,18 +956,25 @@ describe('F254 Queue-Aware Freshness Gate', async () => { assert.equal(result, null, 'target reply to this cat handoff should not create a freshness notice'); }); - it('returns null after same cat has marked the queued entry seen', async () => { - const queue = new queueModule.InvocationQueue(); - const enqueued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId, - userId, - content: 'queued body already read', - source: 'user', - targetCats: [catId], - intent: 'execute', - }); - assert.equal(queue.markQueuedSeen(threadId, userId, enqueued.entry.id, catId), true); + it('returns null after History delivery removes the exact pending target', async () => { + const queue = adaptInvocationQueue(new queueModule.InvocationQueue()); + const enqueued = queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId, + userId, + content: 'queued body already read', + source: 'user', + targetCats: [catId], + intent: 'execute', + }), + ); + assert.equal( + (await queue.reconcileQueuedMessageTargetsDurable(threadId, userId, enqueued.entry.id, [], [catId], {})) + .outcome, + 'updated', + ); const checker = new unseenCheckerModule.ThreadUnseenChecker({ userId, diff --git a/packages/api/test/f254-queue-restart-custody.test.js b/packages/api/test/f254-queue-restart-custody.test.js deleted file mode 100644 index 611c6e8942..0000000000 --- a/packages/api/test/f254-queue-restart-custody.test.js +++ /dev/null @@ -1,2575 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, mock, test } from 'node:test'; - -import { TurnCustodyProjectionService } from '../dist/domains/ball-custody/TurnCustodyProjectionService.js'; -import { createActiveExecutionService } from '../dist/domains/cats/services/agents/invocation/active-execution-service.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { PerCatTerminalDispositionCollector } from '../dist/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.js'; -import { - createInitialFanoutQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { QueuedMessageCustodyStartupReconciler } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.js'; -import { StartupReconciler } from '../dist/domains/cats/services/agents/invocation/StartupReconciler.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { parseQueuedMessageCustody } from '../dist/domains/cats/services/stores/ports/queued-message-custody.js'; -import { projectQueueReceipt } from '../dist/domains/cats/services/stores/ports/queued-message-receipt.js'; - -function custody(overrides = {}) { - return { - version: 1, - entryId: 'entry-restart-1', - revision: 1, - intent: 'execute', - status: 'queued', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 1_000, - updatedAt: 1_000, - ...overrides, - }; -} - -function createMessageStore() { - const store = new MessageStore(); - store.scanByDeliveryStatus = (status) => - store - .getRecent(2_000) - .filter((message) => message.deliveryStatus === status) - .map((message) => message.id); - return store; -} - -function appendQueued(store, queueCustody = custody(), overrides = {}) { - return store.append({ - userId: 'user-1', - catId: null, - content: 'survive the restart', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-1', - deliveryStatus: 'queued', - ...(queueCustody ? { queueCustody } : {}), - ...overrides, - }); -} - -function record(overrides = {}) { - return { - id: 'inv-restart-1', - threadId: 'thread-1', - userId: 'user-1', - userMessageId: null, - targetCats: ['opus'], - intent: 'execute', - status: 'failed', - idempotencyKey: 'queue-entry-restart-1-1000', - createdAt: 1_050, - updatedAt: 1_100, - ...overrides, - }; -} - -function createRecordStore(records = []) { - const byId = new Map(records.map((item) => [item.id, item])); - return { - async get(id) { - return byId.get(id) ?? null; - }, - }; -} - -function turnExecution(overrides = {}) { - return { - invocationId: 'inv-restart-child-1', - parentInvocationId: 'inv-restart-parent-1', - threadId: 'thread-1', - userId: 'user-1', - catId: 'opus', - executionKind: 'ordinary', - status: 'succeeded', - startedAt: 1_050, - endedAt: 1_100, - ...overrides, - }; -} - -function createTurnExecutionStore(records = []) { - const byId = new Map(records.map((item) => [item.invocationId, item])); - return { - async get(id) { - return byId.get(id) ?? null; - }, - }; -} - -function createReconciler({ - messageStore, - invocationQueue, - records = [], - turnExecutions = [], - a2aDispatchDispositionService, -}) { - const liveA2AReplacementPreflight = { - async inspectHandoff({ sourceMessageId, catId }) { - return { - outcome: 'live', - sourceMessageId, - fromCatId: 'source', - handoffSourceEventId: `route:${sourceMessageId}:${catId}`, - }; - }, - }; - return new QueuedMessageCustodyStartupReconciler({ - messageStore, - invocationQueue, - invocationRecordStore: createRecordStore(records), - turnExecutionStore: createTurnExecutionStore(turnExecutions), - a2aDispatchDispositionService: a2aDispatchDispositionService ?? liveA2AReplacementPreflight, - now: () => 2_000, - log: { info() {}, warn() {} }, - }); -} - -describe('F254 Queue restart custody', () => { - test('PR7 restores a legal fan-out sibling without replaying its failed sibling', async () => { - const messageStore = createMessageStore(); - const beforeRestart = new InvocationQueue(); - const entries = ['opus', 'codex'].map((catId) => { - const result = beforeRestart.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'fan-out survives restart', - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex-sol', - a2aParentInvocationId: 'parent-fanout', - a2aTriggerMessageId: 'message-fanout-restart', - }); - assert.equal(result.outcome, 'enqueued'); - return result.entry; - }); - const message = appendQueued( - messageStore, - createInitialFanoutQueuedMessageCustody('message-fanout-restart', entries, { - requestedTargetCats: ['opus', 'codex'], - createdAt: 1_000, - }), - { - catId: 'codex-sol', - content: 'fan-out survives restart', - mentions: ['opus', 'codex'], - }, - ); - for (const entry of entries) { - beforeRestart.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - } - const failedEntry = entries.find((entry) => entry.targetCats.includes('opus')); - beforeRestart.markQueuedFailedForCatAcrossUsers( - 'thread-1', - 'opus', - 'invocation-opus-failed', - new Set([failedEntry.id]), - 'invocation_failed', - 1_100, - ); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore, now: () => 1_100 }); - await coordinator.persistEntry(beforeRestart.getEntrySnapshot('thread-1', 'user-1', failedEntry.id)); - - const afterRestart = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue: afterRestart }); - const first = await reconciler.reconcile(); - const second = await reconciler.reconcile(); - - assert.equal(first.entriesRestored, 2); - assert.equal(second.entriesRestored, 0, 'reconnect/restart replay must remain idempotent'); - const restoredFailed = afterRestart.getEntrySnapshot('thread-1', 'user-1', failedEntry.id); - assert.deepEqual(restoredFailed.queuedFailedByCatIds, ['opus']); - assert.equal(afterRestart.findInFlightAgentEntry('thread-1', 'opus', 'codex-sol'), null); - assert.deepEqual( - afterRestart.listAutoExecute('thread-1').map((entry) => entry.targetCats[0]), - ['codex'], - ); - const next = afterRestart.markProcessingAcrossUsers('thread-1'); - assert.deepEqual(next.targetCats, ['codex'], 'the legal pending sibling must not be swallowed by the failed head'); - assert.deepEqual( - messageStore.getById(message.id).queueCustody.allTargetCats, - ['opus', 'codex'], - 'restart preserves the immutable whole-group target set', - ); - }); - - test('restores an unread message as the exact same Queue owner and is idempotent', async () => { - const messageStore = createMessageStore(); - const message = appendQueued(messageStore); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const first = await reconciler.reconcile(); - const second = await reconciler.reconcile(); - - assert.equal(first.entriesRestored, 1); - assert.equal(second.entriesRestored, 0); - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.equal(restored.id, 'entry-restart-1'); - assert.equal(restored.messageId, message.id); - assert.equal(restored.status, 'queued'); - assert.equal(restored.ownerAuthProvenance, 'unknown', 'legacy custody without provenance must fail closed'); - assert.deepEqual(first.resumeScopes, [{ threadId: 'thread-1', userId: 'user-1' }]); - assert.deepEqual(second.resumeScopes, []); - assert.equal(messageStore.getById(message.id).deliveryStatus, 'queued'); - }); - - test('restores the exact server-derived owner authentication provenance', async () => { - const messageStore = createMessageStore(); - appendQueued(messageStore, custody({ ownerAuthProvenance: 'strict' })); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.equal(restored.ownerAuthProvenance, 'strict'); - }); - - test('restores a scheduler-authored managed wake under its durable user owner principal', async () => { - const messageStore = createMessageStore(); - const message = appendQueued(messageStore, custody({ ownerUserId: 'user-1', ownerAuthProvenance: 'strict' }), { - userId: 'scheduler', - source: { - connector: 'hold-ball', - label: 'managed wake', - icon: '⏱️', - meta: { wakeWhen: true, taskId: 'hold-ball-owner-restart' }, - }, - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.ok(restored); - assert.equal(restored.userId, 'user-1'); - assert.equal(restored.messageId, message.id); - assert.equal(restored.source, 'connector'); - assert.equal(restored.sourceCategory, 'scheduled'); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'scheduler', 'entry-restart-1'), null); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-foreign', 'entry-restart-1'), null); - assert.deepEqual(result.resumeScopes, [{ threadId: 'thread-1', userId: 'user-1' }]); - - const tracker = { - has: () => false, - getUserId: () => null, - cancel: () => ({ cancelled: false, catIds: [] }), - getActiveSlots: () => [], - listActiveThreadIds: () => [], - }; - const activeExecutions = createActiveExecutionService({ - invocationTracker: tracker, - turnExecutionStore: { - async listByParent() { - return []; - }, - async listRunningByUser(viewerUserId) { - return viewerUserId === restored.userId - ? [ - { - invocationId: 'invocation-restored-owner', - threadId: restored.threadId, - userId: restored.userId, - catId: restored.targetCats[0], - status: 'running', - startedAt: 2_100, - }, - ] - : []; - }, - }, - log: { info() {}, warn() {} }, - }); - const ownerSnapshot = await activeExecutions.buildSnapshot('user-1'); - assert.deepEqual(await activeExecutions.resolveWorkingPresence('thread-1', 'user-1', ownerSnapshot), { - catIds: ['opus'], - activeSince: 2_100, - complete: true, - }); - const foreignSnapshot = await activeExecutions.buildSnapshot('user-foreign'); - assert.deepEqual(await activeExecutions.resolveWorkingPresence('thread-1', 'user-foreign', foreignSnapshot), { - catIds: [], - complete: true, - }); - }); - - test('rehydrates a wait continuation carrier from its canonical connector message', async () => { - const messageStore = createMessageStore(); - const waitContinuationCarrier = { - v: 1, - waitId: 'task-pr-7', - outcomeId: 'wait:pr:owner/repo#7:g3:matched', - ownerFence: { kind: 'containing_task', generation: 3 }, - }; - appendQueued(messageStore, custody({ ownerAuthProvenance: 'strict' }), { - source: { - connector: 'github-wait', - label: 'GitHub Wait', - icon: 'github', - meta: { waitContinuationCarrier }, - }, - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.equal(restored.source, 'connector'); - assert.deepEqual(restored.waitContinuationCarrier, waitContinuationCarrier); - assert.equal(restored.actionSuccessorFence, undefined); - }); - - test('reconstructs the same durable retry attempt identity after restart', async () => { - const messageStore = createMessageStore(); - appendQueued( - messageStore, - custody({ - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'failed', - terminalReason: 'invocation_failed', - createdAt: 1_000, - updatedAt: 1_100, - }, - { - id: 'entry-restart-1:opus:2', - targetCatId: 'opus', - sequence: 2, - state: 'queued', - createdAt: 1_200, - updatedAt: 1_200, - }, - ], - updatedAt: 1_200, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.deepEqual(restored.queuedAttemptIdByCatId, { opus: 'entry-restart-1:opus:2' }); - }); - - test('does not replace the original Queue idempotency identity with the initial attempt after restart', async () => { - const messageStore = createMessageStore(); - appendQueued( - messageStore, - custody({ - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: 1_000, - updatedAt: 1_000, - }, - ], - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.equal(restored.queuedAttemptIdByCatId, undefined); - }); - - test('restores one durable cross-thread carrier per target without cloning the message body', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-restart', - ownerAuthProvenance: 'strict', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - carrierByTargetCatId: { - opus: { - entryId: 'carrier-opus', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-restart', - autoExecute: true, - createdAt: 1_000, - }, - codex: { - entryId: 'carrier-codex', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-restart', - autoExecute: true, - createdAt: 1_001, - }, - }, - carrierStateByTargetCatId: { - opus: { status: 'queued' }, - codex: { status: 'queued' }, - }, - }), - { catId: 'sonnet', mentions: ['opus', 'codex'] }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 2); - const opus = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-opus'); - const codex = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-codex'); - assert.deepEqual(opus.targetCats, ['opus']); - assert.deepEqual(codex.targetCats, ['codex']); - for (const entry of [opus, codex]) { - assert.equal(entry.messageId, message.id); - assert.equal(entry.source, 'agent'); - assert.equal(entry.sourceCategory, 'a2a'); - assert.equal(entry.callerCatId, 'sonnet'); - assert.equal(entry.a2aParentInvocationId, 'parent-source'); - assert.equal(entry.a2aTriggerMessageId, 'message-restart'); - assert.equal(entry.autoExecute, true); - assert.equal(entry.ownerAuthProvenance, 'strict'); - } - }); - - test('restores coalesced cross-thread messages into their one shared Queue carrier', async () => { - const messageStore = createMessageStore(); - const carrier = { - opus: { - entryId: 'carrier-shared', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-first', - autoExecute: true, - createdAt: 1_000, - }, - }; - const first = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-first', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - targetAttempts: [ - { - id: 'cross-thread:message-first:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: 1_000, - updatedAt: 1_000, - }, - ], - }), - { catId: 'sonnet', content: 'first handoff', timestamp: 1_000 }, - ); - const second = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-second', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - targetAttempts: [ - { - id: 'cross-thread:message-second:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: 1_001, - updatedAt: 1_001, - }, - ], - }), - { catId: 'sonnet', content: 'second handoff', timestamp: 1_001 }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 1); - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-shared'); - assert.equal(restored.messageId, first.id); - assert.deepEqual(restored.mergedMessageIds, [second.id]); - assert.equal(restored.content, 'first handoff\nsecond handoff'); - assert.deepEqual(restored.targetCats, ['opus']); - assert.equal(restored.autoExecute, true); - assert.equal(restored.queuedAttemptIdByCatId, undefined); - }); - - test('restores a coalesced target carrier when sibling messages have different global target sets', async () => { - const messageStore = createMessageStore(); - const opusCarrier = { - entryId: 'carrier-opus-shared', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-first', - autoExecute: true, - createdAt: 1_000, - }; - const codexCarrier = { - ...opusCarrier, - entryId: 'carrier-codex', - createdAt: 1_001, - }; - const first = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-first', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - carrierByTargetCatId: { opus: opusCarrier, codex: codexCarrier }, - carrierStateByTargetCatId: { opus: { status: 'queued' }, codex: { status: 'queued' } }, - }), - { catId: 'sonnet', content: 'first handoff', timestamp: 1_000, mentions: ['opus', 'codex'] }, - ); - const second = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-second', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - carrierByTargetCatId: { opus: opusCarrier }, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - }), - { catId: 'sonnet', content: 'second handoff', timestamp: 1_002, mentions: ['opus'] }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 2); - const opus = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-opus-shared'); - assert.equal(opus.messageId, first.id); - assert.deepEqual(opus.mergedMessageIds, [second.id]); - assert.deepEqual(opus.targetCats, ['opus']); - assert.deepEqual(opus.allTargetCats, ['opus']); - const codex = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-codex'); - assert.equal(codex.messageId, first.id); - assert.deepEqual(codex.mergedMessageIds, []); - assert.deepEqual(codex.targetCats, ['codex']); - }); - - test('isolates one corrupt queued record and continues restoring the remaining messages', async () => { - const messageStore = createMessageStore(); - const corrupt = appendQueued(messageStore, custody({ entryId: 'entry-corrupt' }), { content: 'corrupt' }); - const healthy = appendQueued(messageStore, custody({ entryId: 'entry-healthy' }), { content: 'healthy' }); - const getById = messageStore.getById.bind(messageStore); - messageStore.getById = (id) => { - if (id === corrupt.id) throw new Error('invalid queue custody payload'); - return getById(id); - }; - const warnings = []; - const invocationQueue = new InvocationQueue(); - const reconciler = new QueuedMessageCustodyStartupReconciler({ - messageStore, - invocationQueue, - invocationRecordStore: createRecordStore(), - now: () => 2_000, - log: { - info() {}, - warn(message) { - warnings.push(message); - }, - }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesFailed, 1); - assert.equal(result.entriesRestored, 1); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-healthy').messageId, healthy.id); - assert.ok(warnings.some((message) => message.includes(corrupt.id))); - }); - - test('terminalizes a legacy unfenced github-wait group without blocking a later healthy custody group', async () => { - const messageStore = createMessageStore(); - const legacy = appendQueued(messageStore, custody({ entryId: 'entry-legacy-wait' }), { - content: 'pre-Gate-4 github wait', - source: { - connector: 'github-wait', - label: 'GitHub Wait', - icon: 'github', - }, - }); - const healthy = appendQueued(messageStore, custody({ entryId: 'entry-healthy-after-legacy' }), { - content: 'healthy queued work', - }); - const warnings = []; - const invocationQueue = new InvocationQueue(); - const reconciler = new QueuedMessageCustodyStartupReconciler({ - messageStore, - invocationQueue, - invocationRecordStore: createRecordStore(), - now: () => 2_000, - log: { - info() {}, - warn(message) { - warnings.push(message); - }, - }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 1); - assert.equal(result.messagesTerminalized, 1); - assert.equal( - invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-healthy-after-legacy').messageId, - healthy.id, - ); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-legacy-wait'), null); - const storedLegacy = messageStore.getById(legacy.id); - assert.equal(storedLegacy.deliveryStatus, 'delivered'); - assert.equal(storedLegacy.queueCustody.status, 'terminal'); - assert.deepEqual(storedLegacy.queueCustody.pendingTargetCats, []); - assert.deepEqual(storedLegacy.queueCustody.failedByCatIds, ['opus']); - assert.ok(warnings.some((message) => message.includes(legacy.id))); - }); - - test('distinguishes malformed custody from an absent legacy custody field', () => { - assert.equal(parseQueuedMessageCustody(undefined), undefined); - assert.throws( - () => parseQueuedMessageCustody('{"version":1,"entryId":'), - (error) => error?.code === 'INVALID_QUEUE_CUSTODY', - ); - }); - - test('cancel clears active custody instead of leaving an invalid canceled/custody binding', () => { - const messageStore = createMessageStore(); - const message = appendQueued(messageStore); - - const canceled = messageStore.markCanceled(message.id); - - assert.equal(canceled.deliveryStatus, 'canceled'); - assert.equal(canceled.queueCustody, undefined); - }); - - test('preserves crash-after-take as the same durable Queue item before admission', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - processingStartedAt: 1_100, - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: 1_000, - updatedAt: 1_000, - }, - ], - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 1); - assert.equal(result.messagesTerminalized, 0); - assert.deepEqual(result.resumeScopes, [{ threadId: 'thread-1', userId: 'user-1' }]); - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.equal(restored.status, 'queued'); - assert.equal(restored.messageId, message.id); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued'); - assert.equal(stored.queueCustody.status, 'queued'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['opus']); - assert.deepEqual(stored.queueCustody.failedByCatIds, []); - assert.deepEqual(stored.queueCustody.targetAttempts, [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: 1_000, - updatedAt: 1_000, - }, - ]); - assert.equal(projectQueueReceipt(stored.queueCustody).targets[0].state, 'queued'); - }); - - test('terminalizes crash-after-accept-before-result as interrupted/runtime_restart without replay', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - awakenedInvocationIdByCatId: { opus: 'inv-failed' }, - awakenedAtByCatId: { opus: 1_050 }, - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-failed' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-failed', seenAt: 1_075 }], - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'appended', - invocationId: 'inv-failed', - seenAt: 1_075, - createdAt: 1_000, - updatedAt: 1_075, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [record({ id: 'inv-failed', status: 'failed' })], - turnExecutions: [ - turnExecution({ - invocationId: 'inv-failed', - status: 'interrupted', - terminalReason: 'process_restart', - endedAt: 2_000, - }), - ], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.deepEqual(result.resumeScopes, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, []); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual(stored.queueCustody.seenInvocationIdByCatId, {}); - assert.deepEqual(stored.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'inv-failed', seenAt: 1_075 }, - ]); - assert.deepEqual(stored.queueCustody.targetAttempts, [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'interrupted', - invocationId: 'inv-failed', - seenAt: 1_075, - terminalReason: 'runtime_restart', - createdAt: 1_000, - updatedAt: 2_000, - }, - ]); - }); - - test('does not replay an accepted child whose detached runtime survived the API restart', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - awakenedInvocationIdByCatId: { opus: 'inv-live' }, - awakenedAtByCatId: { opus: 1_050 }, - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-live' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-live', seenAt: 1_075 }], - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'appended', - invocationId: 'inv-live', - seenAt: 1_075, - createdAt: 1_000, - updatedAt: 1_075, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - turnExecutions: [turnExecution({ invocationId: 'inv-live', status: 'running', endedAt: undefined })], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 0); - assert.deepEqual(result.resumeScopes, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.queueCustody.status, 'processing'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['opus']); - assert.deepEqual(stored.queueCustody.failedByCatIds, []); - assert.deepEqual(stored.queueCustody.seenInvocationIdByCatId, { opus: 'inv-live' }); - assert.equal(stored.queueCustody.targetAttempts[0].state, 'appended'); - }); - - test('keeps an unread reminder open while its exact detached child remains live', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - awakenedInvocationIdByCatId: { opus: 'inv-live-reminder' }, - awakenedAtByCatId: { opus: 1_050 }, - reminderAttempts: [ - { - id: 'reminder-live', - targetCatId: 'opus', - invocationId: 'inv-live-reminder', - state: 'delivered', - requestedAt: 1_080, - deliveredAt: 1_090, - }, - ], - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'starting', - invocationId: 'inv-live-reminder', - createdAt: 1_000, - updatedAt: 1_050, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - turnExecutions: [turnExecution({ invocationId: 'inv-live-reminder', status: 'running', endedAt: undefined })], - }); - - await reconciler.reconcile(); - - const attempt = messageStore.getById(message.id).queueCustody.reminderAttempts[0]; - assert.equal(attempt.state, 'delivered'); - assert.equal(attempt.missedAt, undefined); - }); - - test('restores a pre-admission sibling while preserving a live accepted target', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - awakenedInvocationIdByCatId: { opus: 'inv-live' }, - awakenedAtByCatId: { opus: 1_050 }, - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-live' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-live', seenAt: 1_075 }], - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'appended', - invocationId: 'inv-live', - seenAt: 1_075, - createdAt: 1_000, - updatedAt: 1_075, - }, - { - id: 'entry-restart-1:codex:1', - targetCatId: 'codex', - sequence: 1, - state: 'queued', - createdAt: 1_000, - updatedAt: 1_000, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - { mentions: ['opus', 'codex'] }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - turnExecutions: [turnExecution({ invocationId: 'inv-live', status: 'running', endedAt: undefined })], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 1); - assert.deepEqual(result.resumeScopes, [{ threadId: 'thread-1', userId: 'user-1' }]); - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.deepEqual(restored.targetCats, ['codex']); - assert.deepEqual(restored.allTargetCats, ['opus', 'codex']); - const projected = messageStore.getById(message.id).queueCustody; - assert.equal(projected.status, 'processing'); - assert.deepEqual(projected.pendingTargetCats, ['opus', 'codex']); - assert.deepEqual(projected.seenInvocationIdByCatId, { opus: 'inv-live' }); - assert.deepEqual(projected.carrierStateByTargetCatId, { - opus: { status: 'processing', processingStartedAt: 1_100 }, - codex: { status: 'queued' }, - }); - - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore, now: () => 2_100 }); - await coordinator.persistEntry(restored); - const persisted = messageStore.getById(message.id).queueCustody; - assert.equal(persisted.status, 'processing'); - assert.deepEqual(persisted.pendingTargetCats, ['opus', 'codex']); - assert.deepEqual(persisted.seenInvocationIdByCatId, { opus: 'inv-live' }); - }); - - test('settles an interrupted sibling while preserving a live accepted target without replaying either', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - awakenedInvocationIdByCatId: { opus: 'inv-live', codex: 'inv-lost' }, - awakenedAtByCatId: { opus: 1_050, codex: 1_060 }, - seenByCatIds: ['opus', 'codex'], - seenInvocationIdByCatId: { opus: 'inv-live', codex: 'inv-lost' }, - bodyExposures: [ - { targetCatId: 'opus', invocationId: 'inv-live', seenAt: 1_075 }, - { targetCatId: 'codex', invocationId: 'inv-lost', seenAt: 1_080 }, - ], - targetAttempts: [ - { - id: 'entry-restart-1:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'appended', - invocationId: 'inv-live', - seenAt: 1_075, - createdAt: 1_000, - updatedAt: 1_075, - }, - { - id: 'entry-restart-1:codex:1', - targetCatId: 'codex', - sequence: 1, - state: 'appended', - invocationId: 'inv-lost', - seenAt: 1_080, - createdAt: 1_000, - updatedAt: 1_080, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - { mentions: ['opus', 'codex'] }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - turnExecutions: [ - turnExecution({ invocationId: 'inv-live', status: 'running', endedAt: undefined }), - turnExecution({ - invocationId: 'inv-lost', - catId: 'codex', - status: 'interrupted', - terminalReason: 'process_restart', - endedAt: 2_000, - }), - ], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 0); - assert.equal(result.failedTargets, 1); - assert.deepEqual(result.resumeScopes, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.queueCustody.status, 'processing'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['opus']); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['codex']); - assert.deepEqual(stored.queueCustody.seenInvocationIdByCatId, { opus: 'inv-live' }); - assert.deepEqual( - stored.queueCustody.targetAttempts.map(({ targetCatId, state, terminalReason }) => ({ - targetCatId, - state, - terminalReason, - })), - [ - { targetCatId: 'opus', state: 'appended', terminalReason: undefined }, - { targetCatId: 'codex', state: 'interrupted', terminalReason: 'runtime_restart' }, - ], - ); - }); - - test('terminalizes a replaced A2A carrier at startup instead of resuming its old prompt', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-replaced', - revision: 2, - receiptScope: 'cross_thread_delivery', - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-replaced-before-restart' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-replaced-before-restart', seenAt: 1_075 }], - targetAttempts: [ - { - id: 'carrier-opus:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'appended', - invocationId: 'inv-replaced-before-restart', - seenAt: 1_075, - createdAt: 1_000, - updatedAt: 1_075, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - carrierByTargetCatId: { - opus: { - entryId: 'carrier-opus', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-replaced', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'processing', processingStartedAt: 1_100 } }, - }), - { id: 'message-replaced', catId: 'sonnet', mentions: ['opus'] }, - ); - const invocationQueue = new InvocationQueue(); - const inspectHandoff = mock.fn(async () => ({ - outcome: 'replaced', - sourceMessageId: message.id, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-replaced:opus', - replacement: { - kind: 'handed', - sourceEventId: 'route:message-successor:opus', - sourceMessageId: 'message-successor', - fromCatId: 'sonnet', - toCatId: 'opus', - }, - })); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [record({ id: 'inv-replaced-before-restart', status: 'failed' })], - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.deepEqual(result.resumeScopes, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-opus'), null); - assert.deepEqual( - inspectHandoff.mock.calls.map((call) => call.arguments[0].sourceMessageId), - ['message-replaced', message.id], - ); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, []); - assert.deepEqual(stored.queueCustody.failedByCatIds, []); - assert.deepEqual(stored.queueCustody.withdrawnByCatIds, ['opus']); - assert.deepEqual(stored.queueCustody.targetAttempts, [ - { - id: 'carrier-opus:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'cancelled', - invocationId: 'inv-replaced-before-restart', - seenAt: 1_075, - terminalReason: 'source_withdrawn', - createdAt: 1_000, - updatedAt: 2_000, - }, - ]); - - const repeated = await reconciler.reconcile(); - assert.equal(repeated.entriesRestored, 0); - assert.equal(inspectHandoff.mock.calls.length, 2); - }); - - test('rechecks the A2A replacement fence after a restart custody CAS race', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-race', - revision: 2, - receiptScope: 'cross_thread_delivery', - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-race-before-restart' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-race-before-restart', seenAt: 1_075 }], - processingStartedAt: 1_100, - updatedAt: 1_100, - carrierByTargetCatId: { - opus: { - entryId: 'carrier-race', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-race', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'processing', processingStartedAt: 1_100 } }, - }), - { id: 'message-race', catId: 'sonnet', mentions: ['opus'] }, - ); - const transitionQueueCustody = messageStore.transitionQueueCustody.bind(messageStore); - let forceRevisionMismatch = true; - messageStore.transitionQueueCustody = (messageId, input) => { - if (forceRevisionMismatch) { - forceRevisionMismatch = false; - return { kind: 'revision_mismatch', actualRevision: input.expectedRevision + 1 }; - } - return transitionQueueCustody(messageId, input); - }; - let inspectionCount = 0; - const inspectHandoff = mock.fn(async () => { - inspectionCount += 1; - return inspectionCount === 1 - ? { - outcome: 'live', - sourceMessageId: message.id, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-race:opus', - } - : { - outcome: 'replaced', - sourceMessageId: message.id, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-race:opus', - replacement: { - kind: 'handed', - sourceEventId: 'route:message-successor:opus', - sourceMessageId: 'message-successor', - fromCatId: 'sonnet', - toCatId: 'opus', - }, - }; - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [record({ id: 'inv-race-before-restart', status: 'failed' })], - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.equal(inspectHandoff.mock.calls.length, 4); - assert.equal(messageStore.getById(message.id).deliveryStatus, 'delivered'); - assert.deepEqual(messageStore.getById(message.id).queueCustody.withdrawnByCatIds, ['opus']); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-race'), null); - }); - - test('keeps a non-replaced pre-admission A2A carrier recoverable exactly once', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-live', - revision: 2, - receiptScope: 'cross_thread_delivery', - status: 'processing', - targetAttempts: [ - { - id: 'carrier-live:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: 1_000, - updatedAt: 1_000, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - carrierByTargetCatId: { - opus: { - entryId: 'carrier-live', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-live', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'processing', processingStartedAt: 1_100 } }, - }), - { id: 'message-live', catId: 'sonnet', mentions: ['opus'] }, - ); - const inspectHandoff = mock.fn(async () => ({ - outcome: 'live', - sourceMessageId: message.id, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-live:opus', - })); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const first = await reconciler.reconcile(); - const second = await reconciler.reconcile(); - - assert.equal(first.entriesRestored, 1); - assert.equal(second.entriesRestored, 0); - assert.deepEqual(first.resumeScopes, [{ threadId: 'thread-1', userId: 'user-1' }]); - assert.equal(messageStore.getById(message.id).deliveryStatus, 'queued'); - assert.deepEqual(messageStore.getById(message.id).queueCustody.failedByCatIds, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-live').status, 'queued'); - assert.equal(inspectHandoff.mock.calls.length, 4); - }); - - test('terminalizes a non-replaced accepted A2A carrier when its exact child truth is missing', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-accepted-missing-child', - revision: 2, - receiptScope: 'cross_thread_delivery', - status: 'processing', - awakenedInvocationIdByCatId: { opus: 'inv-accepted-missing-child' }, - awakenedAtByCatId: { opus: 1_050 }, - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-accepted-missing-child' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-accepted-missing-child', seenAt: 1_075 }], - targetAttempts: [ - { - id: 'carrier-accepted-missing-child:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'appended', - invocationId: 'inv-accepted-missing-child', - seenAt: 1_075, - createdAt: 1_000, - updatedAt: 1_075, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - carrierByTargetCatId: { - opus: { - entryId: 'carrier-accepted-missing-child', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-accepted-missing-child', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'processing', processingStartedAt: 1_100 } }, - }), - { id: 'message-accepted-missing-child', catId: 'sonnet', mentions: ['opus'] }, - ); - const inspectHandoff = mock.fn(async () => ({ - outcome: 'live', - sourceMessageId: message.id, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-accepted-missing-child:opus', - })); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.deepEqual(result.resumeScopes, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-accepted-missing-child'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, []); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual( - stored.queueCustody.targetAttempts.map(({ state, terminalReason, invocationId }) => ({ - state, - terminalReason, - invocationId, - })), - [ - { - state: 'interrupted', - terminalReason: 'runtime_restart', - invocationId: 'inv-accepted-missing-child', - }, - ], - ); - }); - - test('keeps an A2A carrier queued when replacement preflight is unavailable', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-preflight-unavailable', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: { - opus: { - entryId: 'carrier-preflight-unavailable', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-preflight-unavailable', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - }), - { id: 'message-preflight-unavailable', catId: 'sonnet', mentions: ['opus'] }, - ); - const inspectHandoff = mock.fn(async () => { - throw new Error('ball custody event log unavailable'); - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.deepEqual(result.resumeScopes, []); - assert.equal(messageStore.getById(message.id).deliveryStatus, 'queued'); - assert.equal(messageStore.getById(message.id).queueCustody.revision, 1); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-preflight-unavailable'), null); - assert.equal(inspectHandoff.mock.calls.length, 2); - }); - - test('quarantines every coalesced carrier member when one replacement preflight is temporarily unavailable', async () => { - const messageStore = createMessageStore(); - const carrier = { - opus: { - entryId: 'carrier-preflight-group', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-preflight-group-first', - autoExecute: true, - createdAt: 1_000, - }, - }; - const first = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-preflight-group-first', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - }), - { id: 'message-preflight-group-first', catId: 'sonnet', mentions: ['opus'] }, - ); - const second = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-preflight-group-second', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - createdAt: 1_001, - updatedAt: 1_001, - }), - { id: 'message-preflight-group-second', catId: 'sonnet', mentions: ['opus'], timestamp: 1_001 }, - ); - let firstSourceReads = 0; - const inspectHandoff = mock.fn(async ({ sourceMessageId, catId }) => { - if (sourceMessageId === first.id && firstSourceReads++ === 0) { - throw new Error('ball custody event log temporarily unavailable'); - } - return { - outcome: 'live', - sourceMessageId, - fromCatId: 'sonnet', - handoffSourceEventId: `route:${sourceMessageId}:${catId}`, - }; - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const deferred = await reconciler.reconcile(); - - assert.equal(deferred.entriesRestored, 0); - assert.deepEqual(deferred.resumeScopes, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-preflight-group'), null); - assert.equal(messageStore.getById(first.id).deliveryStatus, 'queued'); - assert.equal(messageStore.getById(second.id).deliveryStatus, 'queued'); - - const retried = await reconciler.reconcile(); - - assert.equal(retried.entriesRestored, 1, 'the unchanged group is retryable once preflight truth returns'); - assert.deepEqual(retried.resumeScopes, [{ threadId: 'thread-1', userId: 'user-1' }]); - assert.deepEqual( - invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-preflight-group').mergedMessageIds, - [second.id], - ); - }); - - test('quarantines a shared carrier when an initial sibling read fails before preflight', async () => { - const messageStore = createMessageStore(); - const carrier = { - opus: { - entryId: 'carrier-outer-error-group', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-outer-error-first', - autoExecute: true, - createdAt: 1_000, - }, - }; - const first = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-outer-error-first', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - }), - { id: 'message-outer-error-first', catId: 'sonnet', content: 'first source', mentions: ['opus'] }, - ); - const second = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-outer-error-second', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - createdAt: 1_001, - updatedAt: 1_001, - }), - { - id: 'message-outer-error-second', - catId: 'sonnet', - content: 'second source', - mentions: ['opus'], - timestamp: 1_001, - }, - ); - const getById = messageStore.getById.bind(messageStore); - let firstReadFails = true; - messageStore.getById = (messageId) => { - if (messageId === first.id && firstReadFails) { - firstReadFails = false; - throw new Error('temporary source-row read failure'); - } - return getById(messageId); - }; - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const deferred = await reconciler.reconcile(); - - assert.equal(deferred.messagesFailed, 1); - assert.equal(deferred.entriesRestored, 0); - assert.deepEqual(deferred.resumeScopes, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-outer-error-group'), null); - - const retried = await reconciler.reconcile(); - - assert.equal(retried.entriesRestored, 1, 'the unchanged carrier retries once its missing sibling can be read'); - assert.deepEqual(retried.resumeScopes, [{ threadId: 'thread-1', userId: 'user-1' }]); - assert.deepEqual( - invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-outer-error-group').mergedMessageIds, - [second.id], - ); - }); - - test('withdraws a replaced A2A target while restoring its live sibling target', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-partial-target-replacement', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - carrierByTargetCatId: { - opus: { - entryId: 'carrier-partial-target-opus', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-partial-target-replacement', - autoExecute: true, - createdAt: 1_000, - }, - codex: { - entryId: 'carrier-partial-target-codex', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-partial-target-replacement', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'queued' }, codex: { status: 'queued' } }, - }), - { id: 'message-partial-target-replacement', catId: 'sonnet', mentions: ['opus', 'codex'] }, - ); - const inspectHandoff = mock.fn(async ({ catId, sourceMessageId }) => - catId === 'opus' - ? { - outcome: 'replaced', - sourceMessageId, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-partial-target-replacement:opus', - replacement: { - kind: 'handed', - sourceEventId: 'route:message-partial-target-successor:opus', - sourceMessageId: 'message-partial-target-successor', - fromCatId: 'sonnet', - toCatId: 'opus', - }, - } - : { - outcome: 'live', - sourceMessageId, - fromCatId: 'sonnet', - handoffSourceEventId: `route:${sourceMessageId}:${catId}`, - }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 1); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-partial-target-opus'), null); - assert.deepEqual( - invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-partial-target-codex').targetCats, - ['codex'], - ); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['codex']); - assert.deepEqual(stored.queueCustody.withdrawnByCatIds, ['opus']); - }); - - test('keeps a post-settlement surviving source live during startup preflight', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-surviving-source', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: { - opus: { - entryId: 'carrier-post-settlement', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-retired-trigger', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - }), - { id: 'message-surviving-source', catId: 'sonnet', mentions: ['opus'] }, - ); - const inspectHandoff = mock.fn(async ({ sourceMessageId, catId }) => - sourceMessageId === 'message-retired-trigger' - ? { - outcome: 'replaced', - sourceMessageId, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-retired-trigger:opus', - replacement: { - kind: 'handed', - sourceEventId: 'route:message-successor:opus', - sourceMessageId: 'message-successor', - fromCatId: 'sonnet', - toCatId: 'opus', - }, - } - : { - outcome: 'live', - sourceMessageId, - fromCatId: 'sonnet', - handoffSourceEventId: `route:${sourceMessageId}:${catId}`, - }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 1); - assert.equal(messageStore.getById(message.id).queueCustody.withdrawnByCatIds, undefined); - assert.deepEqual( - invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-post-settlement').messageId, - message.id, - ); - assert.deepEqual( - inspectHandoff.mock.calls.map((call) => call.arguments[0].sourceMessageId), - ['message-retired-trigger', message.id], - ); - }); - - test('keeps a coalesced carrier recoverable when only one exact A2A source was replaced', async () => { - const messageStore = createMessageStore(); - const carrier = { - opus: { - entryId: 'carrier-coalesced', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-coalesced-first', - autoExecute: true, - createdAt: 1_000, - }, - }; - const first = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-coalesced-first', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - }), - { id: 'message-coalesced-first', catId: 'sonnet', content: 'stale source', mentions: ['opus'] }, - ); - const second = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-coalesced-second', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: carrier, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - createdAt: 1_001, - updatedAt: 1_001, - }), - { id: 'message-coalesced-second', catId: 'sonnet', content: 'live source', mentions: ['opus'], timestamp: 1_001 }, - ); - const inspectHandoff = mock.fn(async ({ sourceMessageId }) => - sourceMessageId === first.id - ? { - outcome: 'replaced', - sourceMessageId, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-coalesced-first:opus', - replacement: { - kind: 'handed', - sourceEventId: 'route:message-successor:opus', - sourceMessageId: 'message-successor', - fromCatId: 'sonnet', - toCatId: 'opus', - }, - } - : { - outcome: 'live', - sourceMessageId, - fromCatId: 'sonnet', - handoffSourceEventId: `route:${sourceMessageId}:opus`, - }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - a2aDispatchDispositionService: { inspectHandoff }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 1); - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-coalesced'); - assert.equal(restored.messageId, first.id); - assert.deepEqual(restored.mergedMessageIds, [second.id]); - assert.equal(restored.content, 'stale source\nlive source'); - assert.equal(messageStore.getById(first.id).deliveryStatus, 'queued'); - assert.equal(messageStore.getById(second.id).deliveryStatus, 'queued'); - assert.equal(inspectHandoff.mock.calls.length, 6); - }); - - test('terminalizes a failed exact child when its durable output names this source message', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-response-before-failure' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-response-before-failure', seenAt: 1_075 }], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const response = messageStore.append({ - userId: 'user-1', - threadId: 'thread-1', - catId: 'opus', - content: 'durable response before the child failed', - mentions: [], - timestamp: 1_500, - extra: { - stream: { - invocationId: 'parent-response-before-failure', - turnInvocationId: 'inv-response-before-failure', - }, - causal: { kind: 'invocation_reply', triggerMessageId: message.id }, - }, - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - turnExecutions: [ - turnExecution({ - invocationId: 'inv-response-before-failure', - parentInvocationId: 'parent-response-before-failure', - status: 'failed', - terminalReason: 'provider_failure', - }), - ], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesTerminalized, 1); - assert.equal(result.handledTargets, 1); - assert.equal(result.failedTargets, 0); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.deepEqual(stored.queueCustody.targetOutcomeByCatId.opus, { - invocationId: 'inv-response-before-failure', - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-response-before-failure' }, - handledAt: 2_000, - consumption: { - kind: 'source_response', - outputMessageIds: [response.id], - }, - }); - }); - - test('terminalizes a failed historical exposure when its durable output names this source message', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 3, - status: 'queued', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: {}, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-response-after-failure', seenAt: 1_075 }], - failedByCatIds: ['opus'], - updatedAt: 1_600, - }), - { - userId: 'scheduler', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-managed-command', threadId: 'thread-1', catId: 'opus', wakeWhen: true }, - }, - }, - ); - const response = messageStore.append({ - userId: 'default-user', - threadId: 'thread-1', - catId: 'opus', - content: 'durable response persisted before failed bookkeeping cleared the active exposure', - mentions: [], - timestamp: 1_500, - extra: { - stream: { - invocationId: 'parent-response-after-failure', - turnInvocationId: 'inv-response-after-failure', - }, - causal: { kind: 'invocation_reply', triggerMessageId: message.id }, - }, - }); - const invocationQueue = new InvocationQueue(); - const ballEvents = []; - const turnCustody = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: { - async get() { - return { state: 'active', holder: 'opus' }; - }, - }, - ballCustodyEventLog: { - async read(_subjectKey, fromSequence = 0) { - return ballEvents.slice(fromSequence); - }, - }, - }); - const ballProjection = await turnCustody.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'opus', - sourceMessageId: message.id, - taskId: 'task-managed-command', - }); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - turnExecutions: [ - turnExecution({ - invocationId: 'inv-response-after-failure', - parentInvocationId: 'parent-response-after-failure', - status: 'failed', - terminalReason: 'managed_hold_disposition_missing', - }), - ], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesTerminalized, 1); - assert.equal(result.handledTargets, 1); - assert.equal(result.failedTargets, 0); - assert.deepEqual(await turnCustody.close(ballProjection), { - state: 'covered_active', - shouldBlock: true, - transitionObserved: false, - evidenceRefs: ['hold:ball:thread:thread-1'], - }); - assert.deepEqual(ballEvents, []); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'scheduler', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.deepEqual(stored.queueCustody.targetOutcomeByCatId.opus, { - invocationId: 'inv-response-after-failure', - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-response-after-failure' }, - handledAt: 2_000, - consumption: { - kind: 'source_response', - outputMessageIds: [response.id], - }, - }); - }); - - test('hydrates the thread once while checking multiple retained exposure invocations', async () => { - const messageStore = createMessageStore(); - const readThread = messageStore.getByThreadAfter.bind(messageStore); - let threadHydrations = 0; - messageStore.getByThreadAfter = (...args) => { - threadHydrations += 1; - return readThread(...args); - }; - const message = appendQueued( - messageStore, - custody({ - revision: 4, - status: 'queued', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: {}, - bodyExposures: [ - { targetCatId: 'opus', invocationId: 'inv-response-older', seenAt: 1_075 }, - { targetCatId: 'opus', invocationId: 'inv-response-newer', seenAt: 1_100 }, - ], - failedByCatIds: ['opus'], - updatedAt: 1_600, - }), - ); - messageStore.append({ - userId: 'user-1', - threadId: 'thread-1', - catId: 'opus', - content: 'the older exposure has the exact durable response', - mentions: [], - timestamp: 1_500, - extra: { - stream: { - invocationId: 'parent-response-older', - turnInvocationId: 'inv-response-older', - }, - causal: { kind: 'invocation_reply', triggerMessageId: message.id }, - }, - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesTerminalized, 1); - assert.equal(threadHydrations, 1); - }); - - test('keeps a failed historical exposure queued when output is not bound to its source message', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 3, - status: 'queued', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: {}, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-unbound-after-failure', seenAt: 1_075 }], - failedByCatIds: ['opus'], - updatedAt: 1_600, - }), - ); - messageStore.append({ - userId: 'user-1', - threadId: 'thread-1', - catId: 'opus', - content: 'same invocation, but this reply belongs to a different trigger', - mentions: [], - timestamp: 1_500, - extra: { - stream: { - invocationId: 'parent-unbound-after-failure', - turnInvocationId: 'inv-unbound-after-failure', - }, - causal: { kind: 'invocation_reply', triggerMessageId: 'different-source-message' }, - }, - }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesTerminalized, 0); - assert.equal(result.handledTargets, 0); - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.equal(restored.status, 'queued'); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued'); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual(stored.queueCustody.handledByCatIds, []); - }); - - test('terminalizes an awakened child that restarted before body exposure without replay', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - awakenedInvocationIdByCatId: { opus: 'inv-awakened-before-crash' }, - awakenedAtByCatId: { opus: 1_075 }, - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, []); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.equal(stored.queueCustody.awakenedInvocationIdByCatId, undefined); - assert.equal(stored.queueCustody.awakenedAtByCatId, undefined); - assert.deepEqual( - stored.queueCustody.targetAttempts.map(({ state, terminalReason, invocationId }) => ({ - state, - terminalReason, - invocationId, - })), - [ - { - state: 'interrupted', - terminalReason: 'runtime_restart', - invocationId: 'inv-awakened-before-crash', - }, - ], - ); - }); - - test('fails a pending Steer request closed and clears the transient steering marker', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - steerRequestedByCatIds: ['opus'], - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.deepEqual(restored.queuedFailedByCatIds, ['opus']); - assert.deepEqual(restored.steerRequestedByCatIds, []); - assert.deepEqual(restored.steeredInvocationIdByCatId, {}); - const stored = messageStore.getById(message.id); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.equal(stored.queueCustody.steerRequestedByCatIds, undefined); - assert.equal(stored.queueCustody.steeredInvocationIdByCatId, undefined); - }); - - test('closes active reminder attempts honestly during restart', async () => { - const messageStore = createMessageStore(); - const seenMessage = appendQueued( - messageStore, - custody({ - entryId: 'entry-reminder-seen', - revision: 2, - status: 'processing', - awakenedInvocationIdByCatId: { opus: 'inv-success' }, - awakenedAtByCatId: { opus: 1_050 }, - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-reminder-seen' }, - reminderAttempts: [ - { - id: 'reminder-seen', - targetCatId: 'opus', - invocationId: 'inv-reminder-seen', - state: 'delivered', - requestedAt: 1_050, - deliveredAt: 1_075, - }, - ], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const missedMessage = appendQueued( - messageStore, - custody({ - entryId: 'entry-reminder-missed', - revision: 2, - reminderAttempts: [ - { - id: 'reminder-missed', - targetCatId: 'opus', - invocationId: 'inv-reminder-missed', - state: 'requested', - requestedAt: 1_050, - }, - ], - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [ - record({ - id: 'inv-reminder-seen', - status: 'succeeded', - successfulCatIds: ['opus'], - }), - ], - }); - - await reconciler.reconcile(); - - const seenAttempt = messageStore.getById(seenMessage.id).queueCustody.reminderAttempts[0]; - assert.equal(seenAttempt.state, 'seen'); - assert.equal(seenAttempt.seenAt, 2_000); - const missedAttempt = messageStore.getById(missedMessage.id).queueCustody.reminderAttempts[0]; - assert.equal(missedAttempt.state, 'missed'); - assert.equal(missedAttempt.missedAt, 2_000); - assert.equal(missedAttempt.missedReason, 'invocation_ended_before_delivery'); - }); - - test('terminalizes only when the exact reading invocation succeeded', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-success' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-success', seenAt: 1_999 }], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - // InvocationRegistry / parent callback-auth truth may already be gone. - // Restart recovery consumes the durable child execution ledger instead. - turnExecutions: [turnExecution({ invocationId: 'inv-success' })], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesTerminalized, 1); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.handledByCatIds, ['opus']); - assert.equal(stored.queueCustody.awakenedInvocationIdByCatId, undefined); - assert.equal(stored.queueCustody.awakenedAtByCatId, undefined); - assert.deepEqual(stored.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'inv-success', seenAt: 1_999 }, - ]); - assert.equal(stored.queueCustody.targetOutcomeByCatId.opus.invocationId, 'inv-success'); - assert.equal(stored.queueCustody.targetOutcomeByCatId.opus.handledAt, 2_000); - }); - - test('does not let a parent aggregate substitute for missing child truth or replay accepted work', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-exact-missing-child' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-exact-missing-child', seenAt: 1_500 }], - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [ - record({ - id: 'inv-exact-missing-child', - status: 'succeeded', - successfulCatIds: ['opus'], - }), - ], - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'), null); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, []); - assert.deepEqual(stored.queueCustody.handledByCatIds, []); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual( - stored.queueCustody.targetAttempts.map(({ state, terminalReason, invocationId }) => ({ - state, - terminalReason, - invocationId, - })), - [ - { - state: 'interrupted', - terminalReason: 'runtime_restart', - invocationId: 'inv-exact-missing-child', - }, - ], - ); - }); - - test('recovers multi-target success and failure independently', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - seenByCatIds: ['opus', 'codex'], - seenInvocationIdByCatId: { opus: 'inv-opus-ok', codex: 'inv-codex-failed' }, - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - { mentions: ['opus', 'codex'] }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [ - record({ - id: 'inv-opus-ok', - status: 'succeeded', - targetCats: ['opus'], - successfulCatIds: ['opus'], - }), - record({ id: 'inv-codex-failed', status: 'failed', targetCats: ['codex'] }), - ], - }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.deepEqual(restored.targetCats, ['codex']); - assert.deepEqual(restored.queuedHandledByCatIds, ['opus']); - assert.deepEqual(restored.queuedFailedByCatIds, ['codex']); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['codex']); - assert.deepEqual(stored.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['codex']); - }); - - test('derives the shared-invocation witness before restart and leaves the canceled sibling queued', async () => { - const terminalDispositions = new PerCatTerminalDispositionCollector({ - targetCatIds: ['opus', 'codex'], - isCanceled: (catId) => catId === 'codex', - }); - terminalDispositions.observe({ type: 'done', catId: 'codex' }); - terminalDispositions.observe({ type: 'done', catId: 'opus' }); - const successfulCatIds = terminalDispositions.getSuccessfulCatIds(); - assert.deepEqual(successfulCatIds, ['opus']); - - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - seenByCatIds: ['opus', 'codex'], - seenInvocationIdByCatId: { opus: 'inv-shared', codex: 'inv-shared' }, - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - { mentions: ['opus', 'codex'] }, - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [ - record({ - id: 'inv-shared', - status: 'succeeded', - targetCats: ['opus', 'codex'], - successfulCatIds, - }), - ], - }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.deepEqual(restored.targetCats, ['codex']); - assert.deepEqual(restored.queuedHandledByCatIds, ['opus']); - assert.deepEqual(restored.queuedFailedByCatIds, ['codex']); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['codex']); - assert.deepEqual(stored.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['codex']); - }); - - test('fails closed when a persisted success witness names a cat outside the invocation scope', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - revision: 2, - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-corrupt-scope' }, - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - ); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ - messageStore, - invocationQueue, - records: [ - record({ - id: 'inv-corrupt-scope', - status: 'succeeded', - targetCats: ['codex'], - successfulCatIds: ['opus'], - }), - ], - }); - - await reconciler.reconcile(); - - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'entry-restart-1'); - assert.deepEqual(restored.targetCats, ['opus']); - assert.deepEqual(restored.queuedHandledByCatIds, []); - assert.deepEqual(restored.queuedFailedByCatIds, ['opus']); - assert.equal(messageStore.getById(message.id).deliveryStatus, 'queued'); - }); - - test('backfills a legacy queued message into durable custody instead of marking it delivered', async () => { - const messageStore = createMessageStore(); - const message = appendQueued(messageStore, null); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesBackfilled, 1); - const stored = messageStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued'); - assert.equal(stored.queueCustody.entryId, `legacy:${message.id}`); - const restored = invocationQueue.getEntrySnapshot('thread-1', 'user-1', stored.queueCustody.entryId); - assert.equal(restored.messageId, message.id); - }); - - test('does not reinterpret a legacy agent handoff as ordinary queued-user custody', async () => { - const messageStore = createMessageStore(); - const message = appendQueued(messageStore, null, { catId: 'opus', mentions: ['codex'] }); - const invocationQueue = new InvocationQueue(); - const reconciler = createReconciler({ messageStore, invocationQueue }); - - const result = await reconciler.reconcile(); - - assert.deepEqual(result.legacyVisibilityFallbackMessageIds, [message.id]); - assert.equal(messageStore.getById(message.id).queueCustody, undefined); - assert.equal(invocationQueue.list('thread-1', 'user-1').length, 0); - }); - - test('StartupReconciler recovers legacy agent handoffs without claiming active requests were interrupted', async () => { - const messageStore = createMessageStore(); - const reviewRequest = appendQueued(messageStore, null, { catId: 'codex-sol', mentions: ['opus'] }); - const reviewVerdict = appendQueued(messageStore, null, { - catId: 'opus', - mentions: ['codex-sol'], - timestamp: 1_001, - }); - const invocationQueue = new InvocationQueue(); - const invocationRecordStore = { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - }; - const reconciler = new StartupReconciler({ - invocationRecordStore, - invocationQueue, - messageStore, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, - }); - - const result = await reconciler.reconcileOrphans(); - - assert.equal(result.messagesRecovered, 2); - assert.equal(result.running, 0); - assert.equal(result.queued, 0); - assert.equal(result.notifiedThreads, 0); - assert.equal(messageStore.getById(reviewRequest.id).deliveryStatus, 'delivered'); - assert.equal(messageStore.getById(reviewVerdict.id).deliveryStatus, 'delivered'); - assert.equal( - messageStore.getRecent(20).some((message) => message.source?.connector === 'startup-reconciler'), - false, - ); - }); - - test('StartupReconciler restores then naturally resumes each new durable scope once', async () => { - const messageStore = createMessageStore(); - appendQueued(messageStore); - const invocationQueue = new InvocationQueue(); - const resumed = []; - const invocationRecordStore = { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - }; - const reconciler = new StartupReconciler({ - invocationRecordStore, - invocationQueue, - messageStore, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, - async resumeQueue(threadId, userId) { - resumed.push({ threadId, userId }); - }, - }); - - const first = await reconciler.reconcileOrphans(); - const second = await reconciler.reconcileOrphans(); - - assert.equal(first.queueEntriesRestored, 1); - assert.equal(first.queueEntriesResumed, 1); - assert.equal(second.queueEntriesRestored, 0); - assert.equal(second.queueEntriesResumed, 0); - assert.deepEqual(resumed, [{ threadId: 'thread-1', userId: 'user-1' }]); - }); - - test('StartupReconciler does not resume a replacement-fenced old A2A prompt', async () => { - const messageStore = createMessageStore(); - const message = appendQueued( - messageStore, - custody({ - entryId: 'cross-thread:message-no-old-prompt', - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: { - opus: { - entryId: 'carrier-no-old-prompt', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-no-old-prompt', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - }), - { id: 'message-no-old-prompt', catId: 'sonnet', mentions: ['opus'] }, - ); - const invocationQueue = new InvocationQueue(); - const resumeQueue = mock.fn(async () => {}); - const inspectHandoff = mock.fn(async () => ({ - outcome: 'replaced', - sourceMessageId: message.id, - fromCatId: 'sonnet', - handoffSourceEventId: 'route:message-no-old-prompt:opus', - replacement: { - kind: 'handed', - sourceEventId: 'route:message-successor:opus', - sourceMessageId: 'message-successor', - fromCatId: 'sonnet', - toCatId: 'opus', - }, - })); - const reconciler = new StartupReconciler({ - invocationRecordStore: { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - }, - invocationQueue, - messageStore, - a2aDispatchDispositionService: { inspectHandoff }, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, - resumeQueue, - }); - - const result = await reconciler.reconcileOrphans(); - - assert.equal(result.queueEntriesRestored, 0); - assert.equal(result.queueEntriesResumed, 0); - assert.equal(resumeQueue.mock.calls.length, 0); - assert.equal(invocationQueue.getEntrySnapshot('thread-1', 'user-1', 'carrier-no-old-prompt'), null); - assert.equal(messageStore.getById(message.id).deliveryStatus, 'delivered'); - assert.deepEqual(messageStore.getById(message.id).queueCustody.withdrawnByCatIds, ['opus']); - }); -}); diff --git a/packages/api/test/f254-queued-message-custody-coordinator.test.js b/packages/api/test/f254-queued-message-custody-coordinator.test.js deleted file mode 100644 index d8674a5f99..0000000000 --- a/packages/api/test/f254-queued-message-custody-coordinator.test.js +++ /dev/null @@ -1,983 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialCrossThreadQueuedMessageCustody, - createInitialFanoutQueuedMessageCustody, - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; - -const allowRetry = (store) => async (transitions) => { - for (const transition of transitions) { - const result = store.transitionQueueCustody(transition.messageId, { - expectedRevision: transition.current.revision, - next: transition.next, - }); - assert.equal(result.kind, 'updated'); - } - return { outcome: 'committed' }; -}; - -function enqueueUser(queue, targetCats = ['opus', 'codex'], ownerAuthProvenance = 'unknown') { - const result = queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - content: 'durable work', - source: 'user', - targetCats, - intent: 'implement', - priority: 'normal', - ownerAuthProvenance, - }); - assert.equal(result.outcome, 'enqueued'); - assert.ok(result.entry); - return result.entry; -} - -function appendCustodiedMessage(store, queue, entry) { - const message = store.append({ - threadId: entry.threadId, - userId: entry.userId, - catId: null, - content: entry.content, - mentions: entry.targetCats, - timestamp: entry.createdAt, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(entry), - }); - queue.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - return message; -} - -describe('F254 queued message custody coordinator', () => { - test('PR7 refuses to persist an action fence under a different Queue idempotency identity', () => { - const queue = new InvocationQueue(); - const entry = queue.enqueue({ - idempotencyKey: 'queue-custody:wrong-action-source:codex', - ownerAuthProvenance: 'strict', - threadId: 'thread-action-identity', - userId: 'user-1', - content: 'fenced action', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - a2aTriggerMessageId: 'message-action-identity', - actionSuccessorFence: { - leaseId: 'lease-action-identity', - generation: 3, - dispatchId: 'cross-post:action-identity', - }, - }).entry; - - assert.throws( - () => createInitialFanoutQueuedMessageCustody('message-action-identity', [entry]), - /action-successor Queue carrier has mismatched idempotency/, - ); - }); - - test('PR7 converges a fan-out sibling after more than three custody CAS conflicts', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entries = ['opus', 'codex'].map((catId) => { - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-fanout', - userId: 'user-1', - content: 'same-thread fan-out', - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex-sol', - a2aParentInvocationId: 'parent-fanout', - a2aTriggerMessageId: 'message-fanout', - }); - assert.equal(result.outcome, 'enqueued'); - return result.entry; - }); - const message = store.append({ - id: 'message-fanout', - threadId: 'thread-fanout', - userId: 'user-1', - catId: 'codex-sol', - content: 'same-thread fan-out', - mentions: ['opus', 'codex'], - timestamp: 100, - deliveryStatus: 'queued', - queueCustody: createInitialFanoutQueuedMessageCustody('message-fanout', entries, { - requestedTargetCats: ['opus', 'codex'], - createdAt: 100, - }), - }); - for (const entry of entries) { - queue.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - } - assert.equal(queue.markProcessingById('thread-fanout', entries[0].id, 'opus'), true); - const processing = queue.getEntrySnapshot('thread-fanout', 'user-1', entries[0].id); - let transitionCalls = 0; - const contendedStore = { - getById: (messageId) => store.getById(messageId), - transitionQueueCustody(messageId, input) { - transitionCalls += 1; - if (transitionCalls <= 3) { - return { kind: 'revision_mismatch', actualRevision: input.expectedRevision }; - } - return store.transitionQueueCustody(messageId, input); - }, - }; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: contendedStore }); - - assert.deepEqual(await coordinator.persistEntry(processing), [message.id]); - assert.equal(transitionCalls, 4, 'the fourth linearization attempt must remain reachable'); - assert.deepEqual(store.getById(message.id).queueCustody.carrierStateByTargetCatId, { - opus: { status: 'processing', processingStartedAt: processing.processingStartedAt }, - codex: { status: 'queued' }, - }); - }); - - test('replay fence treats terminal truth in any coalesced source as a no-reentry boundary', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const appendSource = (content) => - store.append({ - threadId: entry.threadId, - userId: entry.userId, - catId: 'codex-sol', - content, - mentions: ['opus'], - timestamp: entry.createdAt, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(entry), - }); - const first = appendSource('first source'); - const second = appendSource('second source'); - const secondCustody = store.getById(second.id).queueCustody; - const partiallyCommittedMessages = new Map([ - [first.id, store.getById(first.id)], - [ - second.id, - { - ...store.getById(second.id), - deliveryStatus: 'delivered', - queueCustody: { - ...secondCustody, - revision: secondCustody.revision + 1, - status: 'terminal', - pendingTargetCats: [], - handledByCatIds: ['opus'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'turn-already-finished' }, - bodyExposures: [ - { - targetCatId: 'opus', - invocationId: 'turn-already-finished', - seenAt: entry.createdAt + 50, - }, - ], - targetOutcomeByCatId: { - opus: { - invocationId: 'turn-already-finished', - disposition: 'responded', - handledAt: entry.createdAt + 100, - evidenceRef: { kind: 'invocation_lineage', invocationId: 'turn-already-finished' }, - }, - }, - }, - }, - ], - ]); - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: { getById: async (messageId) => partiallyCommittedMessages.get(messageId) ?? null }, - }); - - const result = await coordinator.inspectTargetReplayFence({ - entry: { ...entry, messageId: first.id, mergedMessageIds: [second.id] }, - targetCatId: 'opus', - }); - - assert.deepEqual(result, { - disposition: 'terminalized', - invocationId: 'turn-already-finished', - sourceMessageIds: [first.id, second.id], - }); - }); - - test('replay fence defers missing or legacy custody to the canonical attempt classifier', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const source = store.append({ - threadId: entry.threadId, - userId: entry.userId, - catId: 'codex-sol', - content: 'unverified source', - mentions: ['opus'], - timestamp: entry.createdAt, - deliveryStatus: 'queued', - }); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store }); - - assert.deepEqual( - await coordinator.inspectTargetReplayFence({ - entry: { ...entry, messageId: source.id, mergedMessageIds: [] }, - targetCatId: 'opus', - }), - { disposition: 'dispatchable', sourceMessageIds: [source.id] }, - ); - }); - - test('replay fence still fails closed when source truth cannot be read', async () => { - const queue = new InvocationQueue(); - const entry = enqueueUser(queue, ['opus']); - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: { - getById: async () => { - throw new Error('message store unavailable'); - }, - }, - }); - - await assert.rejects( - coordinator.inspectTargetReplayFence({ - entry: { ...entry, messageId: 'message-unavailable', mergedMessageIds: [] }, - targetCatId: 'opus', - }), - /message store unavailable/, - ); - }); - - test('carrier retirement fence refuses a partially canceled source group without withdrawing live siblings', async () => { - const queue = new InvocationQueue(); - const entry = enqueueUser(queue, ['opus']); - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: { - getById: async (messageId) => - messageId === 'message-retired' - ? { id: messageId, deliveryStatus: 'canceled' } - : { id: messageId, deliveryStatus: 'queued' }, - }, - }); - - await assert.rejects( - coordinator.inspectCarrierRetirementFence({ - entries: [{ ...entry, messageId: 'message-retired', mergedMessageIds: ['message-live'] }], - }), - /partially terminalized/, - ); - }); - - test('replay fence terminalizes an entry superseded by another exact target carrier', async () => { - const queue = new InvocationQueue(); - const entry = enqueueUser(queue, ['opus']); - const custody = createInitialQueuedMessageCustody(entry); - const source = { - id: 'message-superseded-carrier', - threadId: entry.threadId, - userId: entry.userId, - catId: 'codex-sol', - content: 'superseded carrier', - mentions: ['opus'], - timestamp: entry.createdAt, - deliveryStatus: 'queued', - queueCustody: { - ...custody, - carrierByTargetCatId: { - opus: { entryId: 'replacement-entry' }, - }, - }, - }; - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: { getById: async (messageId) => (messageId === source.id ? source : null) }, - }); - - assert.deepEqual( - await coordinator.inspectTargetReplayFence({ - entry: { ...entry, messageId: source.id, mergedMessageIds: [] }, - targetCatId: 'opus', - }), - { disposition: 'terminalized', sourceMessageIds: [source.id] }, - ); - }); - - test('replay fence rejects a mismatched entry when the exact target carrier is missing', async () => { - const queue = new InvocationQueue(); - const entry = enqueueUser(queue, ['opus']); - const source = { - id: 'message-mismatched-carrier', - threadId: entry.threadId, - userId: entry.userId, - catId: 'codex-sol', - content: 'mismatched carrier', - mentions: ['opus'], - timestamp: entry.createdAt, - deliveryStatus: 'queued', - queueCustody: { - ...createInitialQueuedMessageCustody(entry), - entryId: 'replacement-entry', - }, - }; - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: { getById: async (messageId) => (messageId === source.id ? source : null) }, - }); - - await assert.rejects( - coordinator.inspectTargetReplayFence({ - entry: { ...entry, messageId: source.id, mergedMessageIds: [] }, - targetCatId: 'opus', - }), - /entry mismatch/, - ); - }); - - test('updates one cross-thread target carrier without overwriting its sibling', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const message = store.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: 'fable5', - content: 'coordinate independently', - mentions: ['opus', 'codex'], - timestamp: 100, - deliveryStatus: 'queued', - extra: { crossPost: { sourceThreadId: 'thread-source' } }, - }); - const entries = ['opus', 'codex'].map((catId) => { - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: message.content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - callerCatId: 'fable5', - a2aParentInvocationId: 'parent-1', - a2aTriggerMessageId: message.id, - }); - assert.equal(result.outcome, 'enqueued'); - queue.backfillMessageId('thread-1', 'user-1', result.entry.id, message.id); - return queue.getEntrySnapshot('thread-1', 'user-1', result.entry.id); - }); - const initialized = store.initializeQueueCustody( - message.id, - createInitialCrossThreadQueuedMessageCustody(message.id, entries), - ); - assert.equal(initialized.kind, 'initialized'); - - const opusEntry = entries.find((entry) => entry.targetCats[0] === 'opus'); - const seenAt = Math.max(...entries.map((entry) => entry.createdAt)) + 50; - assert.equal(queue.markProcessingById('thread-1', opusEntry.id), true); - assert.equal(queue.markQueuedAwakened('thread-1', 'user-1', opusEntry.id, 'opus', 'child-opus', seenAt - 10), true); - queue.markProcessingSeen('thread-1', 'user-1', opusEntry.id, ['opus'], 'child-opus', seenAt); - const processingOpus = queue.getEntrySnapshot('thread-1', 'user-1', opusEntry.id); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => seenAt + 50 }); - await coordinator.persistEntry(processingOpus); - - const custody = store.getById(message.id).queueCustody; - assert.deepEqual(custody.pendingTargetCats, ['opus', 'codex']); - assert.deepEqual(custody.awakenedInvocationIdByCatId, { opus: 'child-opus' }); - assert.deepEqual(custody.awakenedAtByCatId, { opus: seenAt - 10 }); - assert.deepEqual(custody.seenByCatIds, ['opus']); - assert.deepEqual(custody.seenInvocationIdByCatId, { opus: 'child-opus' }); - assert.deepEqual(custody.bodyExposures, [{ targetCatId: 'opus', invocationId: 'child-opus', seenAt }]); - assert.equal(custody.carrierByTargetCatId.opus.entryId, opusEntry.id); - assert.equal( - custody.carrierByTargetCatId.codex.entryId, - entries.find((entry) => entry.targetCats[0] === 'codex').id, - ); - }); - - test('retires only the durable Queue target bound to the exact completed action fence', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const message = store.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: 'fable5', - content: 'review exact HEAD', - mentions: ['codex-sol'], - timestamp: 100, - deliveryStatus: 'queued', - extra: { crossPost: { sourceThreadId: 'thread-source' } }, - }); - const exactFence = { - leaseId: 'lease-head-a', - generation: 4, - dispatchId: 'dispatch-head-a', - terminalPredicateDigest: 'digest-head-a', - invocationLineageRef: 'dispatch:dispatch-head-a', - }; - const entries = [['codex-sol', exactFence]].map(([catId, actionSuccessorFence]) => { - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: message.content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'review', - autoExecute: true, - callerCatId: 'fable5', - a2aParentInvocationId: 'parent-1', - a2aTriggerMessageId: message.id, - idempotencyKey: `action:${actionSuccessorFence.leaseId}:${actionSuccessorFence.generation}:${catId}`, - actionSuccessorFence, - }); - assert.equal(result.outcome, 'enqueued'); - queue.backfillMessageId('thread-1', 'user-1', result.entry.id, message.id); - return queue.getEntrySnapshot('thread-1', 'user-1', result.entry.id); - }); - assert.equal( - store.initializeQueueCustody(message.id, createInitialCrossThreadQueuedMessageCustody(message.id, entries)).kind, - 'initialized', - ); - const retiredAt = entries[0].createdAt + 100; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => retiredAt }); - - const mismatch = await coordinator.retireActionSuccessorFence(message.id, { - ...exactFence, - leaseId: 'lease-head-b', - }); - assert.equal(mismatch, null); - assert.deepEqual(store.getById(message.id).queueCustody.pendingTargetCats, ['codex-sol']); - const first = await coordinator.retireActionSuccessorFence(message.id, exactFence); - const replay = await coordinator.retireActionSuccessorFence(message.id, exactFence); - const custody = store.getById(message.id).queueCustody; - - assert.equal(first.changed, true); - assert.equal(replay.changed, false); - assert.deepEqual(custody.pendingTargetCats, []); - assert.deepEqual(custody.withdrawnByCatIds, ['codex-sol']); - assert.equal(custody.carrierByTargetCatId['codex-sol'].actionSuccessorFence.leaseId, 'lease-head-a'); - assert.deepEqual(custody.actionSuccessorTerminalFenceByTargetCatId['codex-sol'], exactFence); - }); - - test('builds initial custody from the exact live Queue identity', () => { - const queue = new InvocationQueue(); - const entry = enqueueUser(queue, ['opus', 'codex'], 'strict'); - - assert.deepEqual(createInitialQueuedMessageCustody(entry), { - version: 1, - entryId: entry.id, - revision: 1, - ownerUserId: 'user-1', - intent: 'implement', - ownerAuthProvenance: 'strict', - status: 'queued', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - targetAttempts: [ - { - id: `${entry.id}:opus:1`, - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: entry.createdAt, - updatedAt: entry.createdAt, - }, - { - id: `${entry.id}:codex:1`, - targetCatId: 'codex', - sequence: 1, - state: 'queued', - createdAt: entry.createdAt, - updatedAt: entry.createdAt, - }, - ], - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: entry.createdAt, - updatedAt: entry.createdAt, - }); - }); - - test('appends one retry attempt without cloning the authored message or accepting a double click', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const message = appendCustodiedMessage(store, queue, entry); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => entry.createdAt + 500 }); - - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'child-failed', entry.createdAt + 10); - queue.markQueuedFailedForCatAcrossUsers( - entry.threadId, - 'opus', - 'child-failed', - new Set([entry.id]), - 'invocation_failed', - entry.createdAt + 20, - ); - await coordinator.persistEntry(queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id)); - const failedAttempt = store.getById(message.id).queueCustody.targetAttempts[0]; - assert.equal(failedAttempt.state, 'failed'); - - const failedEntry = queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - const retried = await coordinator.retryFailedTarget(failedEntry, 'opus', failedAttempt.id, allowRetry(store)); - assert.equal(retried.outcome, 'retried'); - assert.equal(retried.attempt.id, `${entry.id}:opus:2`); - const retry = queue.retryFailedTarget(entry.threadId, entry.userId, entry.id, 'opus'); - assert.ok(retry); - assert.equal( - queue.retryFailedTarget(entry.threadId, entry.userId, entry.id, 'opus'), - null, - 'second click cannot reopen it', - ); - assert.deepEqual(await coordinator.retryFailedTarget(retry.after, 'opus', failedAttempt.id, allowRetry(store)), { - outcome: 'not_retryable', - }); - - const custody = store.getById(message.id).queueCustody; - assert.equal(store.getById(message.id).content, 'durable work'); - assert.deepEqual( - custody.targetAttempts.map((attempt) => ({ id: attempt.id, state: attempt.state })), - [ - { id: `${entry.id}:opus:1`, state: 'failed' }, - { id: `${entry.id}:opus:2`, state: 'queued' }, - ], - ); - }); - - test('retries an invocation-cancelled attempt but preserves author withdrawal as terminal', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const message = appendCustodiedMessage(store, queue, entry); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => entry.createdAt + 500 }); - - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'child-stopped', entry.createdAt + 10); - queue.markQueuedFailedForCatAcrossUsers( - entry.threadId, - 'opus', - 'child-stopped', - new Set([entry.id]), - 'invocation_cancelled', - entry.createdAt + 20, - ); - await coordinator.persistEntry(queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id)); - const stoppedAttempt = store.getById(message.id).queueCustody.targetAttempts[0]; - assert.deepEqual( - { state: stoppedAttempt.state, terminalReason: stoppedAttempt.terminalReason }, - { state: 'cancelled', terminalReason: 'invocation_cancelled' }, - ); - - const stoppedEntry = queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - const retried = await coordinator.retryFailedTarget(stoppedEntry, 'opus', stoppedAttempt.id, allowRetry(store)); - assert.equal(retried.outcome, 'retried'); - assert.equal(retried.attempt.sequence, 2); - assert.ok( - queue.retryFailedTarget(entry.threadId, entry.userId, entry.id, 'opus'), - 'a stopped invocation still leaves its carrier retryable', - ); - - const withdrawnEntry = enqueueUser(queue, ['opus', 'codex']); - const withdrawnMessage = appendCustodiedMessage(store, queue, withdrawnEntry); - const withdrawnCarrier = queue.getEntrySnapshot(withdrawnEntry.threadId, withdrawnEntry.userId, withdrawnEntry.id); - await coordinator.persistEntry(withdrawnCarrier); - await coordinator.withdrawEntry({ ...withdrawnCarrier, targetCats: ['codex'] }); - const withdrawnAttempt = store - .getById(withdrawnMessage.id) - .queueCustody.targetAttempts.find((attempt) => attempt.targetCatId === 'codex'); - assert.ok(withdrawnAttempt); - assert.equal(withdrawnAttempt.terminalReason, 'source_withdrawn'); - assert.deepEqual( - await coordinator.retryFailedTarget(withdrawnCarrier, 'codex', withdrawnAttempt.id, allowRetry(store)), - { outcome: 'not_retryable' }, - 'an author withdrawal must never be reopened by retry', - ); - }); - - test('records a later provider exposure as a new attempt and settles that exact turn', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const message = appendCustodiedMessage(store, queue, entry); - let now = entry.createdAt + 10; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => now }); - - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'inv-first', now); - await coordinator.persistEntry(queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id)); - - now += 10; - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'inv-later', now); - const laterExposure = queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - await coordinator.persistEntry(laterExposure); - - const afterExposure = store.getById(message.id).queueCustody; - assert.deepEqual( - afterExposure.targetAttempts.map((attempt) => ({ - sequence: attempt.sequence, - state: attempt.state, - invocationId: attempt.invocationId, - })), - [ - { sequence: 1, state: 'appended', invocationId: 'inv-first' }, - { sequence: 2, state: 'appended', invocationId: 'inv-later' }, - ], - ); - assert.equal(afterExposure.seenInvocationIdByCatId.opus, 'inv-later'); - - now += 10; - await coordinator.commitSuccessfulTargets(laterExposure, ['opus'], 'inv-later', now); - const settled = store.getById(message.id).queueCustody; - assert.equal(settled.status, 'terminal'); - assert.deepEqual( - settled.targetAttempts.map((attempt) => ({ sequence: attempt.sequence, state: attempt.state })), - [ - { sequence: 1, state: 'appended' }, - { sequence: 2, state: 'handled' }, - ], - ); - }); - - test('withdraws target custody independently while preserving the authored message', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus', 'codex']); - const message = appendCustodiedMessage(store, queue, entry); - const persistedEntry = queue.list(entry.threadId, entry.userId).find((candidate) => candidate.id === entry.id); - assert.ok(persistedEntry?.messageId); - let now = entry.createdAt + 1_000; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => now }); - - assert.equal(await coordinator.withdrawEntry({ ...persistedEntry, targetCats: ['opus'] }), true); - let stored = store.getById(message.id); - assert.equal(stored?.deliveryStatus, 'queued', 'withdrawal must not delete or publish the authored body'); - assert.equal(stored?.queueCustody?.status, 'queued'); - assert.deepEqual(stored?.queueCustody?.pendingTargetCats, ['codex']); - assert.deepEqual(stored?.queueCustody?.withdrawnByCatIds, ['opus']); - assert.deepEqual(stored?.queueCustody?.withdrawnAtByCatId, { opus: now }); - - now += 50; - assert.equal(await coordinator.withdrawEntry({ ...persistedEntry, targetCats: ['codex'] }), true); - stored = store.getById(message.id); - assert.equal(stored?.deliveryStatus, 'queued', 'terminal withdrawal remains in the owner timeline'); - assert.equal(stored?.queueCustody?.status, 'terminal'); - assert.deepEqual(stored?.queueCustody?.pendingTargetCats, []); - assert.deepEqual(stored?.queueCustody?.withdrawnByCatIds, ['opus', 'codex']); - assert.deepEqual(stored?.queueCustody?.withdrawnAtByCatId, { opus: now - 50, codex: now }); - assert.equal(await coordinator.withdrawEntry({ ...persistedEntry, targetCats: ['codex'] }), false); - }); - - test('persists a promoted entry whose queue position is negative', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - enqueueUser(queue, ['opus']); - const entry = enqueueUser(queue, ['opus']); - const message = appendCustodiedMessage(store, queue, entry); - - assert.equal(queue.promote(entry.threadId, entry.userId, entry.id), true); - const promoted = queue.list(entry.threadId, entry.userId).find((candidate) => candidate.id === entry.id); - assert.ok(promoted); - assert.equal(promoted.position, -1, 'InvocationQueue uses a negative position to sort promoted work first'); - - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: store, - now: () => entry.createdAt + 1_000, - }); - await coordinator.persistEntry(promoted); - - assert.equal(store.getById(message.id)?.queueCustody?.position, -1); - }); - - test('persists exact processing/read evidence before the caller continues', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue); - const message = appendCustodiedMessage(store, queue, entry); - assert.equal(queue.markProcessingById(entry.threadId, entry.id), true); - const seenAt = entry.createdAt + 500; - assert.deepEqual(queue.markProcessingSeen(entry.threadId, entry.userId, entry.id, ['opus'], 'inv-1', seenAt), [ - 'opus', - ]); - const processing = queue.list(entry.threadId, entry.userId).find((candidate) => candidate.id === entry.id); - assert.ok(processing); - - const persistedAt = entry.createdAt + 1_000; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => persistedAt }); - await coordinator.persistEntry(processing); - - assert.deepEqual(store.getById(message.id)?.queueCustody, { - ...createInitialQueuedMessageCustody(entry), - revision: 2, - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-1' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-1', seenAt }], - targetAttempts: [ - { - id: `${entry.id}:opus:1`, - targetCatId: 'opus', - sequence: 1, - state: 'appended', - createdAt: entry.createdAt, - updatedAt: seenAt, - invocationId: 'inv-1', - seenAt, - }, - { - id: `${entry.id}:codex:1`, - targetCatId: 'codex', - sequence: 1, - state: 'queued', - createdAt: entry.createdAt, - updatedAt: entry.createdAt, - }, - ], - processingStartedAt: processing.processingStartedAt, - updatedAt: persistedAt, - }); - }); - - test('persists the restart-stable pre-start retirement group before destructive terminalization', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const message = appendCustodiedMessage(store, queue, entry); - assert.equal(queue.markProcessingById(entry.threadId, entry.id), true); - const processing = queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - const intent = { - id: `prestart-retirement:${entry.id}`, - primaryEntryId: entry.id, - entryIds: [entry.id, 'sibling-entry'], - targetCatId: 'opus', - startedAt: processing.processingStartedAt, - }; - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: store, - now: () => entry.createdAt + 1_000, - }); - - await coordinator.persistEntry({ ...processing, prestartRetirement: intent }); - - assert.deepEqual(store.getById(message.id)?.queueCustody?.prestartRetirement, intent); - assert.equal(store.getById(message.id)?.queueCustody?.status, 'processing'); - }); - - test('serializes concurrent projections so an older notified snapshot cannot overwrite seen', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const message = appendCustodiedMessage(store, queue, entry); - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: store, - now: () => entry.createdAt + 1_000, - }); - - assert.equal(queue.markQueuedNotified(entry.threadId, entry.userId, entry.id, 'opus'), true); - const notified = structuredClone(queue.list(entry.threadId, entry.userId)[0]); - assert.equal(queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'inv-seen'), true); - const seen = queue.list(entry.threadId, entry.userId)[0]; - - await Promise.all([coordinator.persistEntry(notified), coordinator.persistEntry(seen)]); - - const custody = store.getById(message.id)?.queueCustody; - assert.equal(custody?.revision, 3); - assert.deepEqual(custody?.notifiedByCatIds, []); - assert.deepEqual(custody?.seenByCatIds, ['opus']); - assert.deepEqual(custody?.seenInvocationIdByCatId, { opus: 'inv-seen' }); - }); - - test('handles only targets proven by the same successful invocation and delivers after the last one', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue); - const message = appendCustodiedMessage(store, queue, entry); - let now = entry.createdAt + 1_000; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => now }); - - queue.markProcessingById(entry.threadId, entry.id); - queue.markProcessingSeen(entry.threadId, entry.userId, entry.id, ['opus', 'codex'], 'inv-shared'); - const processing = queue.list(entry.threadId, entry.userId)[0]; - await coordinator.persistEntry(processing); - - now += 100; - const first = await coordinator.commitSuccessfulTargets(processing, ['opus'], 'inv-shared', now); - assert.deepEqual(first, { - perMessage: [ - { - messageId: message.id, - handledTargetCats: ['opus'], - pendingTargetCats: ['codex'], - fullyConsumed: false, - }, - ], - }); - assert.equal(store.getById(message.id)?.deliveryStatus, 'queued'); - - now += 50; - const wrong = await coordinator.commitSuccessfulTargets(processing, ['codex'], 'inv-wrong', now); - assert.deepEqual(wrong, { - perMessage: [ - { messageId: message.id, handledTargetCats: [], pendingTargetCats: ['codex'], fullyConsumed: false }, - ], - }); - - now += 50; - const second = await coordinator.commitSuccessfulTargets(processing, ['codex'], 'inv-shared', now); - assert.deepEqual(second, { - perMessage: [{ messageId: message.id, handledTargetCats: ['codex'], pendingTargetCats: [], fullyConsumed: true }], - }); - const stored = store.getById(message.id); - assert.equal(stored?.deliveryStatus, 'delivered'); - assert.equal(stored?.deliveredAt, now); - assert.equal(stored?.queueCustody?.status, 'terminal'); - assert.deepEqual(stored?.queueCustody?.handledByCatIds, ['opus', 'codex']); - }); - - test('settles coalesced cross-thread messages per message when their global target projections diverge', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'first handoff', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-first', - }).entry; - const binding = { - entryId: entry.id, - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-first', - autoExecute: true, - createdAt: entry.createdAt, - }; - const codexBinding = { ...binding, entryId: 'carrier-codex', createdAt: entry.createdAt + 1 }; - const common = { - version: 1, - revision: 2, - receiptScope: 'cross_thread_delivery', - intent: 'execute', - status: 'processing', - notifiedByCatIds: [], - awakenedInvocationIdByCatId: { opus: 'child-opus' }, - awakenedAtByCatId: { opus: entry.createdAt + 10 }, - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'child-opus' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'child-opus', seenAt: entry.createdAt + 20 }], - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: entry.createdAt, - updatedAt: entry.createdAt + 20, - }; - const first = store.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: 'sonnet', - content: 'first handoff', - mentions: ['opus', 'codex'], - timestamp: entry.createdAt, - deliveryStatus: 'queued', - queueCustody: { - ...common, - entryId: 'cross-thread:message-first', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - carrierByTargetCatId: { opus: binding, codex: codexBinding }, - carrierStateByTargetCatId: { opus: { status: 'processing' }, codex: { status: 'queued' } }, - }, - }); - const second = store.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: 'sonnet', - content: 'second handoff', - mentions: ['opus'], - timestamp: entry.createdAt + 1, - deliveryStatus: 'queued', - queueCustody: { - ...common, - entryId: 'cross-thread:message-second', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - carrierByTargetCatId: { opus: binding }, - carrierStateByTargetCatId: { opus: { status: 'processing' } }, - }, - }); - queue.backfillMessageId('thread-1', 'user-1', entry.id, first.id); - assert.equal( - queue.coalesceContentIntoQueuedAgent( - 'thread-1', - 'user-1', - entry.id, - second.content, - second.id, - 'sonnet', - 'parent-source', - ), - true, - ); - const coalesced = queue.getEntrySnapshot('thread-1', 'user-1', entry.id); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store }); - - const settlement = await coordinator.commitSuccessfulTargets( - coalesced, - ['opus'], - 'child-opus', - entry.createdAt + 100, - ); - - assert.deepEqual(settlement.perMessage, [ - { - messageId: first.id, - handledTargetCats: ['opus'], - pendingTargetCats: ['codex'], - fullyConsumed: false, - }, - { - messageId: second.id, - handledTargetCats: ['opus'], - pendingTargetCats: [], - fullyConsumed: true, - }, - ]); - assert.equal(store.getById(first.id).deliveryStatus, 'queued'); - assert.deepEqual(store.getById(first.id).queueCustody.pendingTargetCats, ['codex']); - assert.equal(store.getById(second.id).deliveryStatus, 'delivered'); - assert.equal(store.getById(second.id).queueCustody.status, 'terminal'); - }); - - test('normalizes same-millisecond success so handledAt strictly follows exact seenAt', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const entry = enqueueUser(queue, ['opus']); - const message = appendCustodiedMessage(store, queue, entry); - const boundaryAt = entry.createdAt + 1_000; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => boundaryAt }); - - queue.markProcessingById(entry.threadId, entry.id); - queue.markProcessingSeen(entry.threadId, entry.userId, entry.id, ['opus'], 'inv-same-ms', boundaryAt); - const processing = queue.list(entry.threadId, entry.userId)[0]; - await coordinator.persistEntry(processing); - - await coordinator.commitSuccessfulTargets(processing, ['opus'], 'inv-same-ms', boundaryAt); - - const outcome = store.getById(message.id)?.queueCustody?.targetOutcomeByCatId?.opus; - assert.equal(outcome?.handledAt, boundaryAt + 1); - }); -}); diff --git a/packages/api/test/f254-queued-message-custody-redis.test.js b/packages/api/test/f254-queued-message-custody-redis.test.js deleted file mode 100644 index 9d96901777..0000000000 --- a/packages/api/test/f254-queued-message-custody-redis.test.js +++ /dev/null @@ -1,1162 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, beforeEach, describe, test } from 'node:test'; -import { makeQueuedMessageCustody as makeCustody } from './helpers/queued-message-custody.js'; -import { - assertRedisIsolationOrThrow, - cleanupPrefixedRedisKeys, - redisIsolationSkipReason, -} from './helpers/redis-test-helpers.js'; - -const REDIS_URL = process.env.REDIS_URL; - -describe('F254 queued message custody Redis CAS', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { - let redis; - let store; - let invocationStore; - let InvocationQueue; - let QueuedMessageCustodyStartupReconciler; - let rebindCrossThreadQueueCarrierActionFence; - let connected = false; - - before(async () => { - assertRedisIsolationOrThrow(REDIS_URL, 'F254 queued message custody Redis CAS'); - const [ - { createRedisClient }, - { RedisMessageStore }, - { RedisInvocationRecordStore }, - invocationQueueModule, - startupReconcilerModule, - custodyCoordinatorModule, - ] = await Promise.all([ - import('@cat-cafe/shared/utils'), - import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'), - import('../dist/domains/cats/services/stores/redis/RedisInvocationRecordStore.js'), - import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'), - import('../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.js'), - import('../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'), - ]); - InvocationQueue = invocationQueueModule.InvocationQueue; - QueuedMessageCustodyStartupReconciler = startupReconcilerModule.QueuedMessageCustodyStartupReconciler; - rebindCrossThreadQueueCarrierActionFence = custodyCoordinatorModule.rebindCrossThreadQueueCarrierActionFence; - redis = createRedisClient({ url: REDIS_URL }); - try { - await redis.ping(); - connected = true; - store = new RedisMessageStore(redis); - invocationStore = new RedisInvocationRecordStore(redis); - } catch { - await redis.quit().catch(() => {}); - } - }); - - beforeEach(async (t) => { - if (!connected) return t.skip('Redis not connected'); - await cleanupPrefixedRedisKeys(redis, ['msg:*', 'invoc:*', 'idemp:*']); - }); - - after(async () => { - if (!connected) return; - await cleanupPrefixedRedisKeys(redis, ['msg:*', 'invoc:*', 'idemp:*']); - await redis.quit(); - }); - - test('round-trips TTL-0 custody and lets only one concurrent revision win', async () => { - const message = await store.append({ - userId: 'user-1', - catId: null, - content: 'redis queued work', - mentions: ['opus', 'codex'], - timestamp: 1_000, - threadId: 'thread-redis', - deliveryStatus: 'queued', - queueCustody: makeCustody(), - }); - - assert.deepEqual((await store.getById(message.id)).queueCustody, makeCustody()); - assert.equal(await redis.ttl(`msg:${message.id}`), -1); - const replayRecords = await store.listOwnerQueueCustodyLifecycles('user-1'); - assert.deepEqual(replayRecords, [ - { - messageId: message.id, - threadId: 'thread-redis', - userId: 'user-1', - custody: makeCustody(), - }, - ]); - assert.equal('content' in replayRecords[0], false, 'replay source must remain content-free'); - - await store.append({ - userId: 'user-1', - catId: null, - content: 'mismatched custody owner', - mentions: ['opus'], - timestamp: 1_001, - threadId: 'thread-redis', - deliveryStatus: 'queued', - queueCustody: makeCustody({ entryId: 'entry-other-owner', ownerUserId: 'user-2' }), - }); - assert.deepEqual( - await store.listOwnerQueueCustodyLifecycles('user-1'), - replayRecords, - 'owner-scoped replay must reject custody whose durable owner disagrees with the message owner', - ); - - const left = makeCustody({ revision: 2, status: 'processing', processingStartedAt: 1_100, updatedAt: 1_100 }); - const right = makeCustody({ revision: 2, seenByCatIds: ['codex'], updatedAt: 1_101 }); - const results = await Promise.all([ - store.transitionQueueCustody(message.id, { expectedRevision: 1, next: left }), - store.transitionQueueCustody(message.id, { expectedRevision: 1, next: right }), - ]); - - assert.deepEqual(results.map((result) => result.kind).sort(), ['revision_mismatch', 'updated']); - const stored = await store.getById(message.id); - assert.equal(stored.queueCustody.revision, 2); - }); - - test('durably fences fan-out admission before custody and replaces the intent atomically', async () => { - const message = await store.append({ - userId: 'user-1', - catId: 'opus', - content: 'redis pre-CAS fan-out', - mentions: ['codex', 'codex-terra'], - timestamp: 1_000, - threadId: 'thread-fanout-admission', - deliveryStatus: 'queued', - }); - const admission = { - version: 1, - admissionId: `queue-custody:${message.id}`, - ownerUserId: 'user-1', - ownerAuthProvenance: 'unknown', - intent: 'execute', - targetCats: ['codex'], - requestedTargetCats: ['codex', 'codex-terra'], - callerCatId: 'opus', - a2aParentInvocationId: 'parent-1', - priority: 'normal', - createdAt: 1_000, - }; - - const results = await Promise.all([ - store.initializeQueueCustodyAdmission(message.id, admission), - store.initializeQueueCustodyAdmission(message.id, structuredClone(admission)), - ]); - assert.deepEqual(results.map((result) => result.kind).sort(), ['existing', 'initialized']); - assert.deepEqual((await store.getById(message.id)).queueCustodyAdmission, admission); - assert.equal(await redis.ttl(`msg:${message.id}`), -1); - assert.equal((await store.markDelivered(message.id, 1_001)).deliveryTransitioned, false); - - const custody = makeCustody({ - entryId: `fanout:${message.id}`, - ownerUserId: 'user-1', - ownerAuthProvenance: 'unknown', - intent: 'execute', - allTargetCats: ['codex', 'codex-terra'], - carrierByTargetCatId: { - codex: { - entryId: 'carrier-codex', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'opus', - a2aParentInvocationId: 'parent-1', - a2aTriggerMessageId: message.id, - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { codex: { status: 'queued' } }, - pendingTargetCats: ['codex'], - failedByCatIds: ['codex-terra'], - }); - assert.equal((await store.initializeQueueCustody(message.id, custody)).kind, 'initialized'); - - const stored = await store.getById(message.id); - assert.equal(stored.queueCustodyAdmission, undefined); - assert.deepEqual(stored.queueCustody.allTargetCats, ['codex', 'codex-terra']); - assert.equal(await redis.hget(`msg:${message.id}`, 'queueCustodyAdmission'), null); - - const canceled = await store.append({ - userId: 'user-1', - catId: 'opus', - content: 'cancel redis pre-CAS fan-out', - mentions: ['codex'], - timestamp: 1_002, - threadId: 'thread-fanout-admission', - deliveryStatus: 'queued', - }); - assert.equal( - ( - await store.initializeQueueCustodyAdmission(canceled.id, { - ...admission, - admissionId: `queue-custody:${canceled.id}`, - requestedTargetCats: ['codex'], - }) - ).kind, - 'initialized', - ); - assert.equal((await store.markCanceled(canceled.id)).deliveryStatus, 'canceled'); - assert.equal((await store.getById(canceled.id)).queueCustodyAdmission, undefined); - assert.equal(await redis.hget(`msg:${canceled.id}`, 'queueCustodyAdmission'), null); - }); - - test('round-trips an action-successor carrier rebind and refuses generation rewind', async () => { - const message = await store.append({ - userId: 'user-1', - catId: 'opus', - content: 'redis action carrier', - mentions: ['codex'], - timestamp: 1_000, - threadId: 'thread-action-carrier-redis', - deliveryStatus: 'queued', - queueCustody: makeCustody({ - entryId: 'cross-thread:action-redis', - ownerUserId: 'user-1', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['codex'], - pendingTargetCats: ['codex'], - carrierByTargetCatId: { - codex: { - entryId: 'carrier-action-codex', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'opus', - a2aTriggerMessageId: 'source-action-redis', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { codex: { status: 'queued' } }, - }), - }); - const fence = { - leaseId: 'lease-action-redis', - generation: 7, - dispatchId: 'cross-post:action-redis', - terminalPredicateDigest: 'predicate-action-redis', - }; - - const [rebound] = await rebindCrossThreadQueueCarrierActionFence( - store, - [message], - 'carrier-action-codex', - fence, - () => 1_100, - ); - - assert.equal(rebound.queueCustody.revision, 2); - assert.deepEqual(rebound.queueCustody.carrierByTargetCatId.codex.actionSuccessorFence, fence); - assert.equal(rebound.queueCustody.carrierByTargetCatId.codex.idempotencyKey, 'action:lease-action-redis:7:codex'); - assert.equal(await redis.ttl(`msg:${message.id}`), -1); - - const [idempotent] = await rebindCrossThreadQueueCarrierActionFence( - store, - [rebound], - 'carrier-action-codex', - fence, - () => 1_200, - ); - assert.equal(idempotent.queueCustody.revision, 2, 'exact replay must not write a second custody revision'); - - await assert.rejects( - rebindCrossThreadQueueCarrierActionFence( - store, - [idempotent], - 'carrier-action-codex', - { ...fence, generation: 6 }, - () => 1_300, - ), - /cannot replace or rewind authority/, - ); - assert.equal((await store.getById(message.id)).queueCustody.revision, 2); - }); - - test('atomically adopts one legacy-visible carrier before Queue custody admission', async () => { - const legacy = await store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'approved carrier persisted before Queue admission', - mentions: ['codex-terra'], - timestamp: 1_000, - threadId: 'thread-approved-carrier', - }); - - const results = await Promise.all([store.prepareQueueAdmission(legacy.id), store.prepareQueueAdmission(legacy.id)]); - assert.deepEqual(results.map((result) => result.kind).sort(), ['existing', 'prepared']); - const prepared = await store.getById(legacy.id); - assert.equal(prepared.deliveryStatus, 'queued'); - assert.equal(prepared.queueCustody, undefined); - assert.equal(await redis.ttl(`msg:${legacy.id}`), -1); - - const custody = makeCustody({ - entryId: 'carrier-codex-terra', - receiptScope: 'cross_thread_delivery', - ownerUserId: 'user-1', - allTargetCats: ['codex-terra'], - pendingTargetCats: ['codex-terra'], - carrierByTargetCatId: { - 'codex-terra': { - entryId: 'carrier-codex-terra', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'codex-sol', - a2aTriggerMessageId: legacy.id, - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { 'codex-terra': { status: 'queued' } }, - }); - assert.equal((await store.initializeQueueCustody(legacy.id, custody)).kind, 'initialized'); - assert.equal((await store.prepareQueueAdmission(legacy.id)).kind, 'existing'); - - const delivered = await store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'terminal carrier', - mentions: ['codex-terra'], - timestamp: 1_001, - threadId: 'thread-approved-carrier', - deliveryStatus: 'queued', - }); - await store.markDelivered(delivered.id, 1_002); - assert.deepEqual(await store.prepareQueueAdmission(delivered.id), { kind: 'conflict' }); - }); - - test('forward queued-inclusive reads preserve raw thread order across an exposed queued cursor', async () => { - const threadId = 'thread-queued-inclusive-forward-redis'; - const before = await store.append({ - userId: 'user-1', - catId: null, - content: 'before', - mentions: [], - timestamp: 1_000, - threadId, - }); - const exposed = await store.append({ - userId: 'user-1', - catId: null, - content: 'exposed queued body', - mentions: ['opus'], - timestamp: 2_000, - threadId, - deliveryStatus: 'queued', - queueCustody: makeCustody({ - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'sealed-child' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'sealed-child', seenAt: 2_100 }], - }), - }); - const after = await store.append({ - userId: 'user-1', - catId: 'codex', - content: 'after', - mentions: [], - timestamp: 2_000, - threadId, - }); - const options = { - includeQueuedCatMessages: true, - includeExposedQueuedUserMessagesForCatId: 'opus', - }; - - assert.deepEqual( - (await store.getByThreadAfter(threadId, before.id, 20, 'user-1', options)).map((message) => message.id), - [exposed.id, after.id], - ); - assert.deepEqual( - (await store.getByThreadAfter(threadId, exposed.id, 20, 'user-1', options)).map((message) => message.id), - [after.id], - ); - assert.deepEqual( - ( - await store.getByThreadAfter(threadId, before.id, 20, 'user-1', { - ...options, - includeExposedQueuedUserMessagesForCatId: 'codex-sol', - }) - ).map((message) => message.id), - [after.id], - ); - }); - - test('CAS-rebinds one exact source to its verified replacement without persisting the proof', async () => { - const initial = makeCustody({ - entryId: 'entry-replaced', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - }); - const message = await store.append({ - userId: 'user-1', - catId: null, - content: 'recover this exact queued source', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-replacement', - deliveryStatus: 'queued', - queueCustody: initial, - }); - const next = { ...initial, entryId: 'entry-replacement', revision: 2, updatedAt: 1_100 }; - - const result = await store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next, - replacement: { - kind: 'verified', - previousEntryId: initial.entryId, - replacementEntryId: next.entryId, - sourceMessageId: message.id, - }, - }); - - assert.equal(result.kind, 'updated'); - const stored = await store.getById(message.id); - assert.deepEqual(stored.queueCustody, next); - assert.equal('replacement' in stored.queueCustody, false); - assert.equal(await redis.ttl(`msg:${message.id}`), -1); - }); - - test('round-trips a cross-thread exact-child awakening before body exposure', async () => { - const custody = makeCustody({ - entryId: 'cross-thread:message-awakened', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - carrierByTargetCatId: { - opus: { - entryId: 'carrier-opus-awakened', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'codex', - a2aParentInvocationId: 'parent-awakened', - a2aTriggerMessageId: 'message-awakened', - autoExecute: true, - createdAt: 1_000, - }, - }, - carrierStateByTargetCatId: { - opus: { status: 'processing', processingStartedAt: 1_050 }, - }, - status: 'processing', - awakenedInvocationIdByCatId: { opus: 'child-awakened' }, - awakenedAtByCatId: { opus: 1_075 }, - processingStartedAt: 1_050, - updatedAt: 1_075, - }); - const message = await store.append({ - userId: 'user-1', - catId: 'codex', - content: 'terminal coordination release', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-cross-awakened', - deliveryStatus: 'queued', - queueCustody: custody, - }); - - const stored = await store.getById(message.id); - - assert.deepEqual(stored.queueCustody, custody); - assert.equal(await redis.ttl(`msg:${message.id}`), -1); - }); - - test('startup replacement preflight reads the durable queued A2A source group before restoring it', async () => { - const threadId = 'thread-startup-replaced-a2a'; - const source = await store.append({ - userId: 'user-1', - catId: 'codex', - content: '@opus original handoff', - mentions: ['opus'], - timestamp: 1_000, - threadId, - }); - const message = await store.append({ - userId: 'user-1', - catId: 'codex', - content: '@opus old queued prompt', - mentions: ['opus'], - timestamp: 1_001, - threadId, - deliveryStatus: 'queued', - queueCustody: makeCustody({ - entryId: 'cross-thread:startup-replaced-a2a', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - carrierByTargetCatId: { - opus: { - entryId: 'carrier-startup-replaced-a2a', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'codex', - a2aParentInvocationId: 'parent-startup-replaced-a2a', - a2aTriggerMessageId: source.id, - autoExecute: true, - createdAt: 1_001, - }, - }, - carrierStateByTargetCatId: { opus: { status: 'queued' } }, - createdAt: 1_001, - updatedAt: 1_001, - }), - }); - const inspected = []; - const reconciler = new QueuedMessageCustodyStartupReconciler({ - messageStore: store, - invocationRecordStore: invocationStore, - invocationQueue: new InvocationQueue(), - a2aDispatchDispositionService: { - async inspectHandoff(input) { - inspected.push(input); - return { - outcome: 'replaced', - sourceMessageId: input.sourceMessageId, - fromCatId: 'codex', - handoffSourceEventId: `route:${input.sourceMessageId}:opus`, - replacement: { - kind: 'handed', - sourceEventId: 'route:startup-successor:opus', - sourceMessageId: 'message-startup-successor', - fromCatId: 'codex', - toCatId: 'opus', - }, - }; - }, - }, - now: () => 2_000, - log: { info() {}, warn() {} }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.deepEqual( - inspected.map((input) => input.sourceMessageId), - [source.id, message.id], - ); - const stored = await store.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, []); - assert.deepEqual(stored.queueCustody.withdrawnByCatIds, ['opus']); - }); - - test('browser timeline includes durable queued user work without exposing it to default reads', async () => { - const ordinary = await store.append({ - userId: 'user-1', - catId: null, - content: 'ordinary queued user work', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-browser-timeline', - deliveryStatus: 'queued', - queueCustody: makeCustody({ - entryId: 'entry-ordinary-user-work', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - createdAt: 1_000, - updatedAt: 1_000, - }), - }); - const steered = await store.append({ - userId: 'user-1', - catId: null, - content: 'steered user work is already published', - mentions: ['opus'], - timestamp: 1_075, - threadId: 'thread-browser-timeline', - deliveryStatus: 'queued', - queueCustody: makeCustody({ - entryId: 'entry-steered-user-work', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - steerRequestedByCatIds: ['opus'], - createdAt: 1_075, - updatedAt: 1_075, - }), - }); - await store.append({ - userId: 'system', - catId: 'system', - content: 'queued internal system work', - mentions: [], - timestamp: 1_050, - threadId: 'thread-browser-timeline', - deliveryStatus: 'queued', - }); - const seed = await store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'source-cat thread seed', - mentions: ['opus'], - timestamp: 1_100, - threadId: 'thread-browser-timeline', - deliveryStatus: 'queued', - queueCustody: makeCustody({ - entryId: 'entry-browser-timeline', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - createdAt: 1_100, - updatedAt: 1_100, - }), - }); - - assert.deepEqual(await store.getByThread('thread-browser-timeline', 20, 'user-1'), []); - assert.deepEqual( - (await store.getByThread('thread-browser-timeline', 20, 'user-1', { includeQueuedCatMessages: true })).map( - (message) => message.id, - ), - [seed.id], - ); - const options = { includeQueuedCatMessages: true, includeQueuedUserMessages: true }; - const latest = await store.getByThread('thread-browser-timeline', 20, 'user-1', options); - const before = await store.getByThreadBefore('thread-browser-timeline', 2_000, 20, undefined, 'user-1', options); - const bounded = await store.getByThreadBeforeBounded( - 'thread-browser-timeline', - 2_000, - 20, - undefined, - 'user-1', - 100, - options, - ); - - assert.deepEqual( - latest.map((message) => message.id), - [ordinary.id, steered.id, seed.id], - ); - assert.deepEqual( - before.map((message) => message.id), - [ordinary.id, steered.id, seed.id], - ); - assert.deepEqual( - bounded.messages.map((message) => message.id), - [ordinary.id, steered.id, seed.id], - ); - assert.equal(latest[0].catId, null); - assert.deepEqual(latest[1].queueCustody.steerRequestedByCatIds, ['opus']); - assert.equal(latest[2].catId, 'codex-sol'); - assert.equal(latest[2].queueCustody.status, 'queued'); - }); - - test('browser timeline publishes owner-bound queued connector intake at admission and preserves its position', async () => { - const intake = await store.append({ - userId: 'user-1', - catId: null, - content: '[F292 Host-authored meeting-intake envelope]', - mentions: ['codex-sol'], - timestamp: 1_200, - threadId: 'thread-browser-connector-timeline', - deliveryStatus: 'queued', - queueCustody: makeCustody({ - entryId: 'entry-browser-connector-timeline', - allTargetCats: ['codex-sol'], - pendingTargetCats: ['codex-sol'], - createdAt: 1_200, - updatedAt: 1_200, - }), - source: { - connector: 'feishu', - label: '飞书会议入站 / 录音豆', - icon: 'feishu', - }, - }); - const reply = await store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: '猫已经开始处理', - mentions: [], - timestamp: 1_300, - threadId: 'thread-browser-connector-timeline', - }); - const options = { includeQueuedCatMessages: true, includeQueuedUserMessages: true }; - - assert.deepEqual( - (await store.getByThread('thread-browser-connector-timeline', 20, 'user-1', options)).map( - (message) => message.id, - ), - [intake.id, reply.id], - ); - assert.deepEqual(await store.getByThread('thread-browser-connector-timeline', 20, 'user-foreign', options), []); - - const transition = await store.transitionQueueCustody(intake.id, { - expectedRevision: 1, - deliveredAt: 2_000, - next: makeCustody({ - ...intake.queueCustody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - seenByCatIds: ['codex-sol'], - bodyExposures: [ - { - targetCatId: 'codex-sol', - invocationId: 'inv-browser-connector-timeline', - seenAt: 1_250, - }, - ], - handledByCatIds: ['codex-sol'], - targetOutcomeByCatId: { - 'codex-sol': { - invocationId: 'inv-browser-connector-timeline', - disposition: 'completed_with_turn', - evidenceRef: { - kind: 'invocation_lineage', - invocationId: 'inv-browser-connector-timeline', - }, - handledAt: 2_000, - }, - }, - updatedAt: 2_000, - }), - }); - assert.equal(transition.kind, 'updated'); - assert.equal(transition.message.timelineOrderAt, 1_200); - assert.equal(await redis.zscore(`msg:thread:thread-browser-connector-timeline`, intake.id), '1200'); - assert.deepEqual( - (await store.getByThread('thread-browser-connector-timeline', 20, 'user-1', options)).map( - (message) => message.id, - ), - [intake.id, reply.id], - ); - }); - - test('managed-hold history binds scheduler publication to the durable owner across terminal delivery', async () => { - const threadId = 'thread-managed-hold-owner-visibility'; - const anchor = await store.append({ - userId: 'user-owner', - catId: null, - content: 'read anchor', - mentions: [], - timestamp: 900, - threadId, - }); - const source = { - connector: 'hold-ball', - label: '持球结果', - icon: '🏓', - meta: { taskId: 'task-managed-hold-owner', threadId, catId: 'opus', wakeWhen: true }, - }; - const ownerVisible = await store.append({ - userId: 'scheduler', - catId: null, - content: 'owner-visible command result', - mentions: [], - timestamp: 1_000, - threadId, - deliveryStatus: 'queued', - queueCustody: makeCustody({ ownerUserId: 'user-owner' }), - source, - }); - const hidden = await store.append({ - userId: 'scheduler', - catId: null, - content: 'hidden command trigger', - mentions: [], - timestamp: 1_001, - threadId, - deliveryStatus: 'queued', - queueCustody: makeCustody({ entryId: 'entry-hidden', ownerUserId: 'user-owner' }), - extra: { scheduler: { hiddenTrigger: true } }, - source, - }); - const ownerless = await store.append({ - userId: 'scheduler', - catId: null, - content: 'legacy ownerless result', - mentions: [], - timestamp: 1_002, - threadId, - deliveryStatus: 'queued', - queueCustody: makeCustody({ entryId: 'entry-ownerless' }), - source, - }); - - const options = { includeQueuedUserMessages: true }; - assert.deepEqual( - (await store.getByThreadAfter(threadId, anchor.id, 20, 'user-owner', options)).map((message) => message.id), - [ownerVisible.id], - ); - assert.deepEqual(await store.getByThreadAfter(threadId, anchor.id, 20, 'user-foreign', options), []); - - const terminalize = async (message, deliveredAt) => { - const result = await store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: makeCustody({ - ...message.queueCustody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - failedByCatIds: ['opus', 'codex'], - updatedAt: deliveredAt, - }), - deliveredAt, - }); - assert.equal(result.kind, 'updated'); - }; - await terminalize(ownerVisible, 1_100); - await terminalize(hidden, 1_101); - - // Legacy ownerless custody is invalid for new writes but remains a valid - // migration input. Simulate its historical terminal state directly. - await redis.hset(`msg:${ownerless.id}`, 'deliveryStatus', 'delivered', 'deliveredAt', '1102'); - - assert.deepEqual( - (await store.getByThreadAfter(threadId, anchor.id, 20, 'user-owner')).map((message) => message.id), - [ownerVisible.id], - ); - assert.deepEqual(await store.getByThreadAfter(threadId, anchor.id, 20, 'user-foreign'), []); - assert.deepEqual( - (await store.getByThreadIncludingQueued(threadId, 20, 'user-foreign')).filter( - (message) => message.id !== anchor.id, - ), - [], - ); - assert.deepEqual(await store.getUnreadSummaryProjection([{ threadId, afterId: anchor.id }], 'user-owner'), [ - { threadId, unreadCount: 1, hasUserMention: false }, - ]); - assert.deepEqual(await store.getUnreadSummaryProjection([{ threadId, afterId: anchor.id }], 'user-foreign'), [ - { threadId, unreadCount: 0, hasUserMention: false }, - ]); - assert.equal( - ( - await store.getLatestVisibleCursor(threadId, { - evidence: 'durable_owner_read', - viewerUserId: 'user-owner', - }) - ).messageId, - ownerVisible.id, - ); - assert.equal( - ( - await store.getLatestVisibleCursor(threadId, { - evidence: 'durable_owner_read', - viewerUserId: 'user-foreign', - }) - ).messageId, - anchor.id, - ); - }); - - test('forward cursor applies its limit after filtering unpublished queued work', async () => { - const threadId = 'thread-forward-published'; - const anchor = await store.append({ - userId: 'user-1', - catId: null, - content: 'delivered anchor', - mentions: [], - timestamp: 1_000, - threadId, - }); - await store.append({ - userId: 'user-1', - catId: null, - content: 'queued user work before speech', - mentions: ['opus'], - timestamp: 1_100, - threadId, - deliveryStatus: 'queued', - }); - const published = await store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'published source-cat speech', - mentions: ['opus'], - timestamp: 1_200, - threadId, - deliveryStatus: 'queued', - }); - - const messages = await store.getByThreadAfter(threadId, anchor.id, 1, 'user-1', { - includeQueuedCatMessages: true, - }); - - assert.deepEqual( - messages.map((message) => message.id), - [published.id], - ); - }); - - test('updates custody, delivery status, and timeline score in one terminal transition', async () => { - const message = await store.append({ - userId: 'user-1', - catId: null, - content: 'finish me', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-terminal', - deliveryStatus: 'queued', - queueCustody: makeCustody({ - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - }), - }); - const terminal = makeCustody({ - revision: 2, - status: 'terminal', - allTargetCats: ['opus'], - pendingTargetCats: [], - seenByCatIds: ['opus'], - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-terminal', seenAt: 1_100 }], - handledByCatIds: ['opus'], - targetOutcomeByCatId: { - opus: { - invocationId: 'inv-terminal', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-terminal' }, - handledAt: 1_200, - consumption: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - }, - }, - reminderAttempts: [ - { - id: 'reminder-terminal', - targetCatId: 'opus', - invocationId: 'inv-terminal', - state: 'seen', - requestedAt: 1_050, - deliveredAt: 1_075, - seenAt: 1_100, - }, - ], - updatedAt: 1_200, - }); - - const result = await store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: terminal, - deliveredAt: 1_200, - }); - - assert.equal(result.kind, 'updated'); - assert.equal(result.deliveryTransitioned, true); - const stored = await store.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(stored.deliveredAt, 1_200); - assert.deepEqual(stored.queueCustody, terminal); - assert.equal(stored.queueCustody.targetOutcomeByCatId.opus.disposition, 'completed_with_turn'); - assert.equal(stored.queueCustody.targetOutcomeByCatId.opus.consumption.kind, 'terminal_silent'); - assert.equal(stored.queueCustody.reminderAttempts[0].state, 'seen'); - assert.equal(await redis.ttl(`msg:${message.id}`), -1); - assert.equal(await redis.zscore('msg:thread:thread-terminal', message.id), '1000'); - assert.equal(await redis.zscore('msg:timeline', message.id), '1000'); - assert.equal(await redis.zscore('msg:user:user-1', message.id), '1000'); - }); - - test('rejects an unsafe custody deliveredAt before changing custody, status, or index scores', async () => { - const message = await store.append({ - userId: 'user-1', - catId: null, - content: 'reject unsafe custody delivery time', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-invalid-delivery-time', - deliveryStatus: 'queued', - queueCustody: makeCustody({ allTargetCats: ['opus'], pendingTargetCats: ['opus'] }), - }); - const terminal = makeCustody({ - revision: 2, - status: 'terminal', - allTargetCats: ['opus'], - pendingTargetCats: [], - failedByCatIds: ['opus'], - updatedAt: 1_200, - }); - const before = { - hash: await redis.hgetall(`msg:${message.id}`), - thread: await redis.zscore('msg:thread:thread-invalid-delivery-time', message.id), - timeline: await redis.zscore('msg:timeline', message.id), - user: await redis.zscore('msg:user:user-1', message.id), - }; - - await assert.rejects( - store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: terminal, - deliveredAt: 1_200.5, - }), - { name: 'RangeError', message: /non-negative integer ECMAScript Date/ }, - ); - assert.deepEqual(await redis.hgetall(`msg:${message.id}`), before.hash); - assert.equal(await redis.zscore('msg:thread:thread-invalid-delivery-time', message.id), before.thread); - assert.equal(await redis.zscore('msg:timeline', message.id), before.timeline); - assert.equal(await redis.zscore('msg:user:user-1', message.id), before.user); - }); - - test('refuses the legacy markDelivered escape hatch while custody is active', async () => { - const message = await store.append({ - userId: 'user-1', - catId: null, - content: 'do not expose before success', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-fenced', - deliveryStatus: 'queued', - queueCustody: makeCustody({ allTargetCats: ['opus'], pendingTargetCats: ['opus'] }), - }); - - const result = await store.markDelivered(message.id, 1_200); - - assert.equal(result.deliveryTransitioned, false); - const stored = await store.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued'); - assert.equal(stored.queueCustody.status, 'queued'); - assert.equal(await redis.zscore('msg:thread:thread-fenced', message.id), '1000'); - }); - - test('cancel atomically removes active custody fields from the canceled message', async () => { - const message = await store.append({ - userId: 'user-1', - catId: null, - content: 'cancel me', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-cancel', - deliveryStatus: 'queued', - queueCustody: makeCustody({ allTargetCats: ['opus'], pendingTargetCats: ['opus'] }), - }); - - const canceled = await store.markCanceled(message.id); - - assert.equal(canceled.deliveryStatus, 'canceled'); - assert.equal(canceled.queueCustody, undefined); - assert.equal(await redis.hget(`msg:${message.id}`, 'queueCustody'), null); - assert.equal(await redis.hget(`msg:${message.id}`, 'queueCustodyRevision'), null); - }); - - test('restart consumes only the witnessed target from one aggregate-succeeded invocation', async () => { - const { invocationId } = await invocationStore.create({ - threadId: 'thread-shared-parent', - userId: 'user-1', - targetCats: ['opus', 'codex'], - intent: 'execute', - idempotencyKey: 'f254-shared-parent-restart', - actionLeaseCarrier: { kind: 'none' }, - }); - await invocationStore.update(invocationId, { status: 'running' }); - await invocationStore.update(invocationId, { - status: 'succeeded', - successfulCatIds: ['opus'], - }); - const message = await store.append({ - userId: 'user-1', - catId: null, - content: 'only one target finished before the crash', - mentions: ['opus', 'codex'], - timestamp: 1_000, - threadId: 'thread-shared-parent', - deliveryStatus: 'queued', - queueCustody: makeCustody({ - ownerAuthProvenance: 'strict', - revision: 2, - status: 'processing', - seenByCatIds: ['opus', 'codex'], - seenInvocationIdByCatId: { opus: invocationId, codex: invocationId }, - processingStartedAt: 1_100, - updatedAt: 1_100, - }), - }); - - const freshQueue = new InvocationQueue(); - const reconciler = new QueuedMessageCustodyStartupReconciler({ - messageStore: store, - invocationRecordStore: invocationStore, - invocationQueue: freshQueue, - now: () => 2_000, - log: { info() {}, warn() {} }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.handledTargets, 1); - assert.equal(result.failedTargets, 1); - const restored = freshQueue.getEntrySnapshot('thread-shared-parent', 'user-1', 'entry-1'); - assert.equal(restored.ownerAuthProvenance, 'strict'); - assert.deepEqual(restored.targetCats, ['codex']); - assert.deepEqual(restored.queuedHandledByCatIds, ['opus']); - assert.deepEqual(restored.queuedFailedByCatIds, ['codex']); - const stored = await store.getById(message.id); - assert.equal(stored.queueCustody.ownerAuthProvenance, 'strict'); - assert.equal(stored.deliveryStatus, 'queued'); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['codex']); - assert.deepEqual(stored.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['codex']); - }); - - test('restart terminalizes a failed historical exposure with an exact durable source response', async () => { - const threadId = 'thread-managed-command-historical-response'; - const invocationId = 'inv-managed-command-historical-response'; - const message = await store.append({ - userId: 'scheduler', - catId: null, - content: 'managed command completed', - mentions: ['opus'], - timestamp: 1_000, - threadId, - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-managed-command', threadId, catId: 'opus', wakeWhen: true }, - }, - queueCustody: makeCustody({ - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - revision: 3, - status: 'queued', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: {}, - bodyExposures: [{ targetCatId: 'opus', invocationId, seenAt: 1_075 }], - failedByCatIds: ['opus'], - updatedAt: 1_600, - }), - }); - const response = await store.append({ - userId: 'default-user', - catId: 'opus', - content: 'the command result was consumed before the stop gate failed', - mentions: [], - timestamp: 1_500, - threadId, - extra: { - stream: { - invocationId: 'parent-managed-command-historical-response', - turnInvocationId: invocationId, - }, - causal: { kind: 'invocation_reply', triggerMessageId: message.id }, - }, - }); - const freshQueue = new InvocationQueue(); - const reconciler = new QueuedMessageCustodyStartupReconciler({ - messageStore: store, - invocationRecordStore: invocationStore, - invocationQueue: freshQueue, - now: () => 2_000, - log: { info() {}, warn() {} }, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.messagesTerminalized, 1); - assert.equal(result.handledTargets, 1); - assert.equal(freshQueue.getEntrySnapshot(threadId, 'scheduler', 'entry-1'), null); - const stored = await store.getById(message.id); - assert.equal(stored.deliveryStatus, 'delivered'); - assert.equal(await redis.ttl(`msg:${message.id}`), -1); - assert.deepEqual(stored.queueCustody.targetOutcomeByCatId.opus, { - invocationId, - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId }, - handledAt: 2_000, - consumption: { - kind: 'source_response', - outputMessageIds: [response.id], - }, - }); - }); -}); diff --git a/packages/api/test/f254-queued-message-custody-store.test.js b/packages/api/test/f254-queued-message-custody-store.test.js deleted file mode 100644 index a92c00da2b..0000000000 --- a/packages/api/test/f254-queued-message-custody-store.test.js +++ /dev/null @@ -1,156 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { makeQueuedMessageCustody as makeCustody } from './helpers/queued-message-custody.js'; - -function appendQueued(store, custody = makeCustody()) { - return store.append({ - userId: 'user-1', - catId: null, - content: 'durable queued work', - mentions: ['opus', 'codex'], - timestamp: 1_000, - threadId: 'thread-1', - deliveryStatus: 'queued', - queueCustody: custody, - }); -} - -describe('F254 queued message custody store', () => { - test('persists one revisioned custody projection on the exact queued message', () => { - const store = new MessageStore(); - const message = appendQueued(store); - - assert.deepEqual(store.getById(message.id)?.queueCustody, makeCustody()); - - const next = makeCustody({ - revision: 2, - status: 'processing', - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-opus-1' }, - processingStartedAt: 1_100, - updatedAt: 1_100, - }); - const result = store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next, - }); - - assert.equal(result.kind, 'updated'); - assert.equal(result.deliveryTransitioned, false); - assert.deepEqual(result.message.queueCustody, next); - assert.equal(result.message.deliveryStatus, 'queued'); - }); - - test('rejects stale custody writers without mutating the current projection', () => { - const store = new MessageStore(); - const message = appendQueued(store); - const revisionTwo = makeCustody({ revision: 2, updatedAt: 1_100 }); - - assert.equal(store.transitionQueueCustody(message.id, { expectedRevision: 1, next: revisionTwo }).kind, 'updated'); - - const stale = store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: makeCustody({ revision: 2, status: 'processing', updatedAt: 1_200 }), - }); - - assert.deepEqual(stale, { kind: 'revision_mismatch', actualRevision: 2 }); - assert.deepEqual(store.getById(message.id)?.queueCustody, revisionTwo); - }); - - test('atomically terminalizes custody and delivers only after every target is handled', () => { - const store = new MessageStore(); - const message = appendQueued(store); - const terminal = makeCustody({ - revision: 2, - status: 'terminal', - pendingTargetCats: [], - seenByCatIds: ['opus', 'codex'], - handledByCatIds: ['opus', 'codex'], - updatedAt: 1_200, - }); - - const result = store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: terminal, - deliveredAt: 1_200, - }); - - assert.equal(result.kind, 'updated'); - assert.equal(result.deliveryTransitioned, true); - assert.equal(result.message.deliveryStatus, 'delivered'); - assert.equal(result.message.deliveredAt, 1_200); - assert.deepEqual(result.message.queueCustody, terminal); - }); - - test('refuses the legacy markDelivered escape hatch while custody is active', () => { - const store = new MessageStore(); - const message = appendQueued(store); - - const result = store.markDelivered(message.id, 1_200); - - assert.equal(result.deliveryTransitioned, false); - assert.equal(store.getById(message.id).deliveryStatus, 'queued'); - assert.equal(store.getById(message.id).queueCustody.status, 'queued'); - }); - - test('forward queued-inclusive reads preserve raw thread order across an exposed queued cursor', () => { - const store = new MessageStore(); - const threadId = 'thread-queued-inclusive-forward-memory'; - const before = store.append({ - userId: 'user-1', - catId: null, - content: 'before', - mentions: [], - timestamp: 1_000, - threadId, - }); - const exposed = store.append({ - userId: 'user-1', - catId: null, - content: 'exposed queued body', - mentions: ['opus'], - timestamp: 2_000, - threadId, - deliveryStatus: 'queued', - queueCustody: makeCustody({ - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'sealed-child' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'sealed-child', seenAt: 2_100 }], - }), - }); - const after = store.append({ - userId: 'user-1', - catId: 'codex', - content: 'after', - mentions: [], - timestamp: 2_000, - threadId, - }); - const options = { - includeQueuedCatMessages: true, - includeExposedQueuedUserMessagesForCatId: 'opus', - }; - - assert.deepEqual( - store.getByThreadAfter(threadId, before.id, 20, 'user-1', options).map((message) => message.id), - [exposed.id, after.id], - ); - assert.deepEqual( - store.getByThreadAfter(threadId, exposed.id, 20, 'user-1', options).map((message) => message.id), - [after.id], - ); - assert.deepEqual( - store - .getByThreadAfter(threadId, before.id, 20, 'user-1', { - ...options, - includeExposedQueuedUserMessagesForCatId: 'codex-sol', - }) - .map((message) => message.id), - [after.id], - ); - }); -}); diff --git a/packages/api/test/f254-runtime-descriptor.test.js b/packages/api/test/f254-runtime-descriptor.test.js index e8b64d59fc..5d63156639 100644 --- a/packages/api/test/f254-runtime-descriptor.test.js +++ b/packages/api/test/f254-runtime-descriptor.test.js @@ -299,7 +299,7 @@ describe('checkFreshnessForPostMessage with descriptor (AC-C3)', () => { getByThreadAfter: async () => [], }; const queueChecker = { - getQueuedForThread: () => [{ source: 'user', content: 'new user message', callerCatId: undefined }], + getQueuedForThread: () => [{ from: { kind: 'user', userId: 'u1' }, content: 'new user message' }], }; const result = await checkFreshnessForPostMessage({ diff --git a/packages/api/test/f254-stream-output-freshness.test.js b/packages/api/test/f254-stream-output-freshness.test.js index 2e663e023b..4bf7403f3a 100644 --- a/packages/api/test/f254-stream-output-freshness.test.js +++ b/packages/api/test/f254-stream-output-freshness.test.js @@ -13,6 +13,11 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; +import { + adaptInvocationQueue, + canonicalTestMessageInput, + canonicalTestQueueInput, +} from './helpers/message-from-fixtures.js'; // --- Module under test (will be created) --- const { checkStreamOutputFreshness } = await import( @@ -39,12 +44,15 @@ function sortableMsgId(n, suffix = 'msg') { /** Minimal in-memory message store implementing FreshnessMessageReader */ function createMockMessageStore(messages = []) { + const canonicalMessages = messages.map((message) => + canonicalTestMessageInput({ userId, threadId, content: '', mentions: [], timestamp: 0, ...message }), + ); return { getById(id) { - return messages.find((m) => m.id === id) ?? null; + return canonicalMessages.find((m) => m.id === id) ?? null; }, getByThreadAfter(tid, afterId, limit, _uid) { - let filtered = messages.filter((m) => m.threadId === tid); + let filtered = canonicalMessages.filter((m) => m.threadId === tid); if (afterId) { filtered = filtered.filter((m) => m.id > afterId); } @@ -58,9 +66,12 @@ function createMockMessageStore(messages = []) { /** Minimal mock queue checker */ function createMockQueueChecker(queuedMessages = []) { + const canonicalEntries = queuedMessages.map((entry) => + canonicalTestQueueInput({ threadId, userId, targetCats: [catId], ...entry }), + ); return { getQueuedForThread(_tid, _uid) { - return queuedMessages; + return canonicalEntries; }, }; } @@ -621,19 +632,25 @@ describe('F254 Phase D — checkStreamOutputFreshness', () => { assert.deepEqual(result.unseenMessageIds, [msgId2]); }); - it('does not report queued stale after same cat has marked the queued entry seen', async () => { + it('does not report queued stale after History delivery removes the exact pending target', async () => { await cursorStore.ackSeenCursor(userId, catId, threadId, msgId1); - const queue = new InvocationQueue(); - const enqueued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId, - userId, - content: 'queued msg already read', - source: 'user', - targetCats: [catId], - intent: 'execute', - }); - assert.equal(queue.markQueuedSeen(threadId, userId, enqueued.entry.id, catId), true); + const queue = adaptInvocationQueue(new InvocationQueue()); + const enqueued = queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId, + userId, + content: 'queued msg already read', + source: 'user', + targetCats: [catId], + intent: 'execute', + }), + ); + assert.equal( + (await queue.reconcileQueuedMessageTargetsDurable(threadId, userId, enqueued.entry.id, [], [catId], {})).outcome, + 'updated', + ); const messageStore = createMockMessageStore([{ id: msgId1, catId: null, content: 'original', threadId }]); const result = await checkStreamOutputFreshness({ @@ -748,7 +765,7 @@ describe('F254 Phase D — checkStreamOutputFreshness', () => { { id: msgId2, catId: 'sonnet', content: 'sonnet stream output', threadId, origin: 'stream' }, ]); // Filter mimics play-mode: exclude other cats' origin:'stream' - const messageFilter = (msg) => !(msg.catId && msg.catId !== catId && msg.origin === 'stream'); + const messageFilter = (msg) => !(msg.from?.kind === 'agent' && msg.from.catId !== catId && msg.origin === 'stream'); const result = await checkStreamOutputFreshness({ userId, catId, @@ -768,7 +785,7 @@ describe('F254 Phase D — checkStreamOutputFreshness', () => { { id: msgId2, catId: 'sonnet', content: 'sonnet stream', threadId, origin: 'stream' }, { id: msgId3, catId: null, content: 'real user msg', threadId }, ]); - const messageFilter = (msg) => !(msg.catId && msg.catId !== catId && msg.origin === 'stream'); + const messageFilter = (msg) => !(msg.from?.kind === 'agent' && msg.from.catId !== catId && msg.origin === 'stream'); const result = await checkStreamOutputFreshness({ userId, catId, diff --git a/packages/api/test/f254-windowed-signal-report.test.js b/packages/api/test/f254-windowed-signal-report.test.js index 4229d9805b..9da00dfa3c 100644 --- a/packages/api/test/f254-windowed-signal-report.test.js +++ b/packages/api/test/f254-windowed-signal-report.test.js @@ -13,39 +13,23 @@ const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); const fixtureRoot = `${repoRoot}/docs/harness-feedback/fixtures/f254`; const window = { startMs: 1_000, endMs: 2_000 }; -function custody(overrides = {}) { +function queueRecord(overrides = {}) { return { - version: 1, entryId: 'entry-1', - revision: 1, - ownerUserId: 'user-1', - intent: 'respond', - status: 'processing', - allTargetCats: ['codex-sol'], - pendingTargetCats: ['codex-sol'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', + targetCatId: 'codex-sol', + threadId: 'thread-1', + messageId: 'message-1', createdAt: 900, - updatedAt: 1_500, + lastUpdatedAt: 1_500, + firstSeenAt: 1_050, + terminalState: false, + legacyUntimed: false, ...overrides, }; } -function queueRecord(queueCustody) { - return { - messageId: 'message-1', - threadId: 'thread-1', - userId: 'user-1', - custody: queueCustody, - }; -} - describe('F254 measurement-valid windowed signal projection', () => { - it('fails source maturity closed without discarding the mandatory structural fixtures', async () => { + it('fails source maturity closed without discarding mandatory structural fixtures', async () => { const provider = new FreshnessReplayProviderImpl({ store: { async listUpdatedBetween() { @@ -57,10 +41,15 @@ describe('F254 measurement-valid windowed signal projection', () => { }, fixtureRoot, queueLifecycleSource: { - async listOwnerQueueCustodyLifecycles() { + async listOwnerDurableEntries() { throw new Error('queue unavailable'); }, }, + messageLifecycleSource: { + async listOwnerMessagesInWindow() { + throw new Error('history unavailable'); + }, + }, attentionEventLog: { async queryWindowBetween() { throw new Error('attention unavailable'); @@ -72,7 +61,6 @@ describe('F254 measurement-valid windowed signal projection', () => { { kind: 'freshness-closure-replay', windowStartMs: 1_000, windowEndMs: 2_000 }, { ownerUserId: 'user-1' }, ); - assert.equal(replay.samples.length, 8); assert.equal(replay.measurementMaturity.status, 'blocked'); assert.deepEqual(replay.measurementMaturity.sources.legacy_closures, { status: 'complete' }); @@ -81,48 +69,13 @@ describe('F254 measurement-valid windowed signal projection', () => { assert.equal(replay.measurementMaturity.sources.attention_events.status, 'unavailable'); }); - it('does not mistake a historical failed attempt for the terminal time after retry success', () => { - const queue = custody({ - revision: 5, - status: 'terminal', - pendingTargetCats: [], - seenByCatIds: ['codex-sol'], - handledByCatIds: ['codex-sol'], - targetOutcomeByCatId: { - 'codex-sol': { invocationId: 'inv-2', handledAt: 1_600, disposition: 'completed_with_turn' }, - }, - bodyExposures: [ - { targetCatId: 'codex-sol', invocationId: 'inv-1', seenAt: 1_050 }, - { targetCatId: 'codex-sol', invocationId: 'inv-2', seenAt: 1_500 }, - ], - targetAttempts: [ - { - id: 'attempt-1', - targetCatId: 'codex-sol', - sequence: 1, - state: 'failed', - createdAt: 900, - updatedAt: 1_100, - }, - { - id: 'attempt-2', - targetCatId: 'codex-sol', - sequence: 2, - state: 'handled', - createdAt: 1_200, - updatedAt: 1_600, - }, - ], - updatedAt: 1_600, - }); - + it('does not mistake a historical failed attempt for terminal time after retry success', () => { const signals = buildFreshnessWindowedSignals({ window, - queueRecords: [queueRecord(queue)], + queueRecords: [queueRecord({ lastUpdatedAt: 1_600, firstSeenAt: 1_500, handledAt: 1_600, terminalState: true })], supplements: [], attentionEvents: [], }); - assert.equal(signals.queue.seenCount, 1); assert.equal(signals.queue.handledCount, 1); assert.equal(signals.queue.failedCount, 0); @@ -131,20 +84,10 @@ describe('F254 measurement-valid windowed signal projection', () => { assert.equal(signals.queue.lifecycles[0].failedAt, undefined); }); - it('does not let pre-window legacy terminals poison later mature windows', () => { + it('does not let pre-window terminals poison later mature windows', () => { const signals = buildFreshnessWindowedSignals({ window, - queueRecords: [ - queueRecord( - custody({ - status: 'terminal', - pendingTargetCats: [], - handledByCatIds: ['codex-sol'], - createdAt: 100, - updatedAt: 900, - }), - ), - ], + queueRecords: [queueRecord({ createdAt: 100, lastUpdatedAt: 900, handledAt: 900, terminalState: true })], supplements: [ { id: 'supplement-legacy-old', @@ -165,14 +108,13 @@ describe('F254 measurement-valid windowed signal projection', () => { ], attentionEvents: [], }); - assert.equal(signals.queue.entryTargetCount, 0); assert.equal(signals.queue.legacyUntimedCount, 0); assert.equal(signals.supplements.lifecycles.length, 0); assert.equal(signals.supplements.legacyUntimedCount, 0); }); - it('blocks publication when legacy Queue and Supplement activity overlaps the selected window without exact times', async () => { + it('blocks publication when untimed History dispatch and Supplement activity overlap the window', async () => { const supplement = { id: 'supplement-legacy-current', lineageId: 'lineage-current', @@ -200,25 +142,37 @@ describe('F254 measurement-valid windowed signal projection', () => { }, fixtureRoot, queueLifecycleSource: { - async listOwnerQueueCustodyLifecycles() { - return [queueRecord(custody({ seenByCatIds: ['codex-sol'] }))]; + async listOwnerDurableEntries() { + return []; + }, + }, + messageLifecycleSource: { + async listOwnerMessagesInWindow() { + return [ + { + id: 'message-current', + threadId: 'thread-1', + userId: 'user-1', + timestamp: 900, + lifecycle: { + kind: 'input', + orderKey: 'entry-current', + dispatchRefs: [{ targetId: 'codex-sol', statusMessageId: 'response-missing' }], + }, + }, + ]; }, }, attentionEventLog: { async queryWindowBetween() { - return { - events: [], - coverage: { status: 'complete', completeFromMs: 1_000, observedThroughMs: 2_000 }, - }; + return { events: [], coverage: { status: 'complete', completeFromMs: 1_000, observedThroughMs: 2_000 } }; }, }, }); - const replay = await provider.resolve( { kind: 'freshness-closure-replay', windowStartMs: 1_000, windowEndMs: 2_000 }, { ownerUserId: 'user-1' }, ); - assert.equal(replay.measurementMaturity.status, 'blocked'); assert.deepEqual(replay.measurementMaturity.reasons, [ 'queue_custody:legacy_untimed_lifecycles=1', diff --git a/packages/api/test/f254-withheld-message-recovery-redis.test.js b/packages/api/test/f254-withheld-message-recovery-redis.test.js index 3f8b525d5e..25fb0a56b6 100644 --- a/packages/api/test/f254-withheld-message-recovery-redis.test.js +++ b/packages/api/test/f254-withheld-message-recovery-redis.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { after, before, beforeEach, describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -50,22 +51,26 @@ describe('F254 withheld-message recovery Redis safety', { skip: redisIsolationSk const threadId = 'thread_recovery'; const userId = 'user-1'; const catId = 'fable-5'; - const question = await store.append({ - userId, - threadId, - catId: null, - content: '汉堡买到了吗?', - mentions: [catId], - timestamp: 100, - }); - const later = await store.append({ - userId, - threadId, - catId: null, - content: '谢谢宪宪', - mentions: [catId], - timestamp: 300, - }); + const question = await store.append( + canonicalTestMessageInput({ + userId, + threadId, + catId: null, + content: '汉堡买到了吗?', + mentions: [catId], + timestamp: 100, + }), + ); + const later = await store.append( + canonicalTestMessageInput({ + userId, + threadId, + catId: null, + content: '谢谢宪宪', + mentions: [catId], + timestamp: 300, + }), + ); const cursorKeys = [ `delivery-cursor:${userId}:${catId}:${threadId}`, `seen-cursor:${userId}:${catId}:${threadId}`, diff --git a/packages/api/test/f264-author-message-disposition.test.js b/packages/api/test/f264-author-message-disposition.test.js index c57ddf3269..8599db0fb5 100644 --- a/packages/api/test/f264-author-message-disposition.test.js +++ b/packages/api/test/f264-author-message-disposition.test.js @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, test } from 'node:test'; +import { adaptInvocationQueue } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { saveMessageDispositionPreference } = await import('../dist/config/user-preferences-store.js'); @@ -17,6 +18,7 @@ function entry(overrides = {}) { return { threadId: 'thread-1', userId: 'user-1', + kind: 'conversation_input', ownerAuthProvenance: 'strict', content: 'queued body', source: 'user', @@ -30,7 +32,7 @@ describe('F264 author-declared message disposition', () => { let queue; beforeEach(() => { - queue = new InvocationQueue(); + queue = adaptInvocationQueue(new InvocationQueue()); }); test('missing author intent is fail-closed next-work and never enters a live parent context', () => { @@ -118,7 +120,7 @@ describe('F264 author-declared message disposition', () => { ]; for (const row of cases) { - const isolated = new InvocationQueue(); + const isolated = adaptInvocationQueue(new InvocationQueue()); isolated.enqueue(row.entry); const body = isolated.getQueuedBodyMessagesForCat('thread-1', 'user-1', 'opus', 'parent-a'); const freshness = isolated.getQueuedFreshnessMessagesForCat('thread-1', 'user-1', 'opus', { @@ -129,7 +131,7 @@ describe('F264 author-declared message disposition', () => { } }); - test('an unread continue-current user message closes its parent window but stays eligible for successor work', () => { + test('an unread continue-current user message stays durable for successor work', () => { const result = queue.enqueue( entry({ authorIntentByCatId: { @@ -146,9 +148,6 @@ describe('F264 author-declared message disposition', () => { 'the active parent first owns the opportunity to read', ); - queue.fallbackAuthorIntentsForParentAcrossUsers('thread-1', 'opus', 'parent-a', 2_000); - - assert.deepEqual(queue.getQueuedBodyMessagesForCat('thread-1', 'user-1', 'opus', 'parent-a'), []); assert.equal(queue.peekNextQueued('thread-1', 'user-1')?.id, result.entry.id); assert.equal(queue.hasPendingForCat('thread-1', 'opus', { userId: 'user-1' }), true); }); @@ -182,6 +181,7 @@ describe('F264 author-declared message disposition', () => { resolveCarrierCapability: () => ({ provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', }), now: 1_000, @@ -194,6 +194,7 @@ describe('F264 author-declared message disposition', () => { carrierCapability: { provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', }, }, @@ -202,6 +203,7 @@ describe('F264 author-declared message disposition', () => { carrierCapability: { provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', }, fallbackAt: 1_000, @@ -210,6 +212,38 @@ describe('F264 author-declared message disposition', () => { }); }); + test('admission binds queued-internal carriers to the active invocation without claiming exact-turn reading', () => { + const authorIntentByCatId = resolveQueueAuthorIntentByCatId({ + targetCats: ['opus'], + requested: 'continue_current', + threadId: 'thread-1', + userId: 'user-1', + invocationTracker: { + has: () => true, + getUserId: () => 'user-1', + getExecutionId: () => 'parent-opus', + }, + resolveCarrierCapability: () => ({ + provider: 'anthropic', + carrier: 'claude_agent_sdk', + activeInvocationGuidance: 'supported', + deliverySemantics: 'queued_internal_turn', + }), + now: 1_000, + }); + + assert.deepEqual(authorIntentByCatId.opus, { + requested: 'continue_current', + boundParentInvocationId: 'parent-opus', + carrierCapability: { + provider: 'anthropic', + carrier: 'claude_agent_sdk', + activeInvocationGuidance: 'supported', + deliverySemantics: 'queued_internal_turn', + }, + }); + }); + test('admission never binds a stale or foreign-owned parent invocation', () => { const authorIntentByCatId = resolveQueueAuthorIntentByCatId({ targetCats: ['opus', 'codex-sol'], @@ -224,6 +258,7 @@ describe('F264 author-declared message disposition', () => { resolveCarrierCapability: () => ({ provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', }), now: 1_000, @@ -235,6 +270,7 @@ describe('F264 author-declared message disposition', () => { carrierCapability: { provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', }, fallbackAt: 1_000, @@ -245,6 +281,7 @@ describe('F264 author-declared message disposition', () => { carrierCapability: { provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', }, fallbackAt: 1_000, @@ -266,9 +303,10 @@ describe('F264 author-declared message disposition', () => { userId: 'user-1', invocationTracker, resolveCarrierCapability: () => ({ - provider: 'anthropic', - carrier: 'claude_print_sdk', - deliverySemantics: 'unsupported', + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'unsupported', + deliverySemantics: 'exact_active_turn', }), now: 1_000, }); @@ -284,9 +322,10 @@ describe('F264 author-declared message disposition', () => { assert.deepEqual(unsupported.opus, { requested: 'continue_current', carrierCapability: { - provider: 'anthropic', - carrier: 'claude_print_sdk', - deliverySemantics: 'unsupported', + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'unsupported', + deliverySemantics: 'exact_active_turn', }, fallbackAt: 1_000, fallbackReason: 'unsupported_carrier', @@ -296,6 +335,7 @@ describe('F264 author-declared message disposition', () => { carrierCapability: { provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }, fallbackAt: 1_000, @@ -307,6 +347,7 @@ describe('F264 author-declared message disposition', () => { assert.deepEqual(resolveFreshnessCarrierCapabilityOrUndeclared({}, 'opus'), { provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }); }); @@ -336,27 +377,6 @@ describe('F264 author-declared message disposition', () => { } }); - test('terminal parent appends a fallback fact and permanently closes the exposure window', () => { - const result = queue.enqueue( - entry({ - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: 'parent-a' }, - }, - }), - ); - - const changed = queue.fallbackAuthorIntentsForParentAcrossUsers('thread-1', 'opus', 'parent-a', 2_000); - - assert.deepEqual(changed, [{ entryId: result.entry.id, userId: 'user-1' }]); - assert.deepEqual(queue.getQueuedBodyMessagesForCat('thread-1', 'user-1', 'opus', 'parent-a'), []); - assert.deepEqual(queue.getEntrySnapshot('thread-1', 'user-1', result.entry.id).authorIntentByCatId.opus, { - requested: 'continue_current', - boundParentInvocationId: 'parent-a', - fallbackAt: 2_000, - fallbackReason: 'parent_terminal_before_exposure', - }); - }); - test('send schema accepts only the two typed dispositions', () => { assert.equal( sendMessageSchema.safeParse({ content: 'A 补充', messageDisposition: 'continue_current' }).success, diff --git a/packages/api/test/f264-queue-receipt.test.js b/packages/api/test/f264-queue-receipt.test.js deleted file mode 100644 index c101a7656d..0000000000 --- a/packages/api/test/f264-queue-receipt.test.js +++ /dev/null @@ -1,966 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { - assertQueueCustodyTransition, - assertQueuedMessageCustody, - parseQueuedMessageCustody, -} from '../dist/domains/cats/services/stores/ports/queued-message-custody.js'; -import { - markReminderAttemptDelivered, - markReminderAttemptMissed, - markReminderAttemptSeen, - projectQueueReceipt, - requestReminderAttempt, -} from '../dist/domains/cats/services/stores/ports/queued-message-receipt.js'; -import { enrichQueueEntries } from '../dist/utils/queue-enrichment.js'; - -function custody(overrides = {}) { - return { - version: 1, - entryId: 'entry-1', - revision: 1, - intent: 'execute', - status: 'queued', - allTargetCats: ['opus', 'codex', 'gpt52'], - pendingTargetCats: ['opus', 'codex'], - notifiedByCatIds: ['codex'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-current' }, - bodyExposures: [ - { targetCatId: 'opus', invocationId: 'inv-current', seenAt: 1_200 }, - { targetCatId: 'gpt52', invocationId: 'inv-done', seenAt: 1_250 }, - ], - failedByCatIds: [], - handledByCatIds: ['gpt52'], - targetOutcomeByCatId: { - gpt52: { - invocationId: 'inv-done', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-done' }, - handledAt: 1_500, - }, - }, - reminderAttempts: [], - priority: 'normal', - createdAt: 1_000, - updatedAt: 1_500, - ...overrides, - }; -} - -describe('F264 queue receipt projection', () => { - test('marks a failed cross-thread target without a carrier as non-retryable', () => { - const receipt = projectQueueReceipt( - custody({ - receiptScope: 'cross_thread_delivery', - allTargetCats: ['codex'], - pendingTargetCats: [], - failedByCatIds: ['codex'], - carrierByTargetCatId: {}, - targetAttempts: [ - { - id: 'cross-thread:message:codex:1', - targetCatId: 'codex', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - ); - - assert.deepEqual(receipt.targets, [ - { - catId: 'codex', - state: 'failed', - retryable: false, - attempts: [ - { - id: 'cross-thread:message:codex:1', - targetCatId: 'codex', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }, - ]); - }); - - test('distinguishes admitted, awakened, read, and invoked-but-unsettled target truth', () => { - const admitted = custody({ - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - bodyExposures: [], - failedByCatIds: [], - handledByCatIds: [], - targetOutcomeByCatId: undefined, - }); - assert.deepEqual(projectQueueReceipt(admitted).targets, [{ catId: 'opus', state: 'queued' }]); - - const awakened = custody({ - ...admitted, - awakenedInvocationIdByCatId: { opus: 'child-opus' }, - awakenedAtByCatId: { opus: 1_100 }, - }); - assert.deepEqual(projectQueueReceipt(awakened).targets, [ - { catId: 'opus', state: 'awakened', invocationId: 'child-opus', awakenedAt: 1_100 }, - ]); - - const unsettled = custody({ - ...awakened, - failedByCatIds: ['opus'], - }); - assert.deepEqual(projectQueueReceipt(unsettled).targets, [ - { catId: 'opus', state: 'failed', invocationId: 'child-opus', awakenedAt: 1_100 }, - ]); - }); - - test('projects independent target truth and preserves completed-with-turn evidence', () => { - assert.deepEqual(projectQueueReceipt(custody()), { - version: 1, - entryId: 'entry-1', - targets: [ - { catId: 'opus', state: 'seen', invocationId: 'inv-current', seenAt: 1_200 }, - { catId: 'codex', state: 'notified' }, - { - catId: 'gpt52', - state: 'handled', - invocationId: 'inv-done', - seenAt: 1_250, - outcome: { - invocationId: 'inv-done', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-done' }, - handledAt: 1_500, - }, - }, - ], - reminderAttempts: [], - }); - }); - - test('projects author withdrawal as terminal non-actionable truth without erasing prior exposure', () => { - const withdrawn = custody({ - status: 'terminal', - pendingTargetCats: [], - notifiedByCatIds: [], - failedByCatIds: [], - withdrawnByCatIds: ['opus', 'codex'], - withdrawnAtByCatId: { opus: 1_600, codex: 1_650 }, - }); - - assert.doesNotThrow(() => assertQueuedMessageCustody(withdrawn)); - const receipt = projectQueueReceipt(withdrawn); - assert.deepEqual(receipt.targets[0], { - catId: 'opus', - state: 'withdrawn', - withdrawnAt: 1_600, - invocationId: 'inv-current', - seenAt: 1_200, - }); - assert.deepEqual(receipt.targets[1], { catId: 'codex', state: 'withdrawn', withdrawnAt: 1_650 }); - assert.equal(receipt.targets[2].state, 'handled'); - }); - - test('projects a withdrawn action-successor carrier as a terminal non-retryable no-op', () => { - const withdrawn = custody({ - status: 'terminal', - allTargetCats: ['codex'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - bodyExposures: [], - failedByCatIds: [], - handledByCatIds: [], - targetOutcomeByCatId: undefined, - withdrawnByCatIds: ['codex'], - withdrawnAtByCatId: { codex: 1_650 }, - carrierByTargetCatId: { - codex: { - entryId: 'entry-1', - idempotencyKey: 'action:lease-1:4:codex', - actionSuccessorFence: { - leaseId: 'lease-1', - generation: 4, - dispatchId: 'dispatch-1', - terminalPredicateDigest: 'predicate-a', - }, - source: 'agent', - sourceCategory: 'a2a', - a2aTriggerMessageId: 'message-1', - autoExecute: true, - createdAt: 1_000, - }, - }, - actionSuccessorTerminalFenceByTargetCatId: { - codex: { - leaseId: 'lease-1', - generation: 4, - dispatchId: 'dispatch-1', - terminalPredicateDigest: 'predicate-a', - }, - }, - }); - - assert.deepEqual(projectQueueReceipt(withdrawn).targets, [ - { catId: 'codex', state: 'withdrawn', retryable: false, withdrawnAt: 1_650 }, - ]); - }); - - test('keeps a manually withdrawn action-successor carrier distinct from business terminal retirement', () => { - const withdrawn = custody({ - status: 'terminal', - allTargetCats: ['codex'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - bodyExposures: [], - failedByCatIds: [], - handledByCatIds: [], - targetOutcomeByCatId: undefined, - withdrawnByCatIds: ['codex'], - withdrawnAtByCatId: { codex: 1_650 }, - carrierByTargetCatId: { - codex: { - entryId: 'entry-1', - idempotencyKey: 'action:lease-1:4:codex', - actionSuccessorFence: { - leaseId: 'lease-1', - generation: 4, - dispatchId: 'dispatch-1', - terminalPredicateDigest: 'predicate-a', - }, - source: 'agent', - sourceCategory: 'a2a', - a2aTriggerMessageId: 'message-1', - autoExecute: true, - createdAt: 1_000, - }, - }, - }); - - assert.deepEqual(projectQueueReceipt(withdrawn).targets, [ - { catId: 'codex', state: 'withdrawn', withdrawnAt: 1_650 }, - ]); - }); - - test('validates typed terminal-silent and exact source-response consumption witnesses', () => { - const terminalSilent = custody({ - targetOutcomeByCatId: { - gpt52: { - invocationId: 'inv-done', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-done' }, - handledAt: 1_500, - consumption: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - }, - }, - }); - - assert.deepEqual(projectQueueReceipt(terminalSilent).targets[2].outcome.consumption, { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }); - - terminalSilent.targetOutcomeByCatId.gpt52.consumption.wake = 'ordinary'; - assert.throws(() => assertQueuedMessageCustody(terminalSilent), /invalid target terminal consumption witness/); - - const sourceResponse = custody({ - targetOutcomeByCatId: { - gpt52: { - invocationId: 'inv-done', - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-done' }, - handledAt: 1_500, - consumption: { kind: 'source_response', outputMessageIds: ['output-1'] }, - }, - }, - }); - assert.deepEqual(projectQueueReceipt(sourceResponse).targets[2].outcome.consumption, { - kind: 'source_response', - outputMessageIds: ['output-1'], - }); - sourceResponse.targetOutcomeByCatId.gpt52.consumption.outputMessageIds.push('output-1'); - assert.throws( - () => assertQueuedMessageCustody(sourceResponse), - /source response consumption requires unique output message ids/, - ); - }); - - test('does not invent response evidence for legacy handled custody', () => { - const receipt = projectQueueReceipt(custody({ targetOutcomeByCatId: undefined })); - assert.deepEqual(receipt.targets[2], { catId: 'gpt52', state: 'handled' }); - }); - - test('hydrates pre-exposure persisted outcomes as generic legacy handled truth', () => { - const legacy = custody({ bodyExposures: undefined }); - - const hydrated = parseQueuedMessageCustody(JSON.stringify(legacy)); - - assert.equal(hydrated.targetOutcomeByCatId, undefined); - assert.deepEqual(projectQueueReceipt(hydrated).targets[2], { catId: 'gpt52', state: 'handled' }); - }); - - test('does not let legacy compatibility hide malformed outcome evidence', () => { - const legacy = custody({ - bodyExposures: undefined, - targetOutcomeByCatId: { - gpt52: { - invocationId: 'inv-done', - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-other' }, - handledAt: 1_500, - }, - }, - }); - - assert.throws(() => parseQueuedMessageCustody(JSON.stringify(legacy)), /matching invocation evidence/); - }); - - test('keeps current-format outcomes strict when exact exposure evidence is absent', () => { - const current = custody({ bodyExposures: [] }); - - assert.throws(() => parseQueuedMessageCustody(JSON.stringify(current)), /matching exact body exposure/); - }); - - test('projects Steer separately from seen and failed states', () => { - const receipt = projectQueueReceipt( - custody({ - steerRequestedByCatIds: ['codex'], - steeredInvocationIdByCatId: { opus: 'inv-steered' }, - }), - ); - assert.deepEqual(receipt.targets[0], { catId: 'opus', state: 'steering', invocationId: 'inv-steered' }); - assert.deepEqual(receipt.targets[1], { catId: 'codex', state: 'steering' }); - }); - - test('keeps exact exposure visible after a failed invocation returns responsibility to Queue', () => { - const receipt = projectQueueReceipt( - custody({ - seenInvocationIdByCatId: {}, - failedByCatIds: ['codex'], - bodyExposures: [ - { targetCatId: 'codex', invocationId: 'inv-failed', seenAt: 1_350 }, - { targetCatId: 'opus', invocationId: 'inv-current', seenAt: 1_200 }, - { targetCatId: 'gpt52', invocationId: 'inv-done', seenAt: 1_250 }, - ], - }), - ); - - assert.deepEqual(receipt.targets[1], { - catId: 'codex', - state: 'failed', - invocationId: 'inv-failed', - seenAt: 1_350, - }); - }); - - test('same-millisecond retries project the later appended child exposure', () => { - const receipt = projectQueueReceipt( - custody({ - seenInvocationIdByCatId: {}, - failedByCatIds: ['codex'], - bodyExposures: [ - { targetCatId: 'opus', invocationId: 'inv-current', seenAt: 1_200 }, - { targetCatId: 'gpt52', invocationId: 'inv-done', seenAt: 1_250 }, - { targetCatId: 'codex', invocationId: 'inv-failed-old', seenAt: 1_350 }, - { targetCatId: 'codex', invocationId: 'inv-failed-new', seenAt: 1_350 }, - ], - }), - ); - - assert.deepEqual(receipt.targets[1], { - catId: 'codex', - state: 'failed', - invocationId: 'inv-failed-new', - seenAt: 1_350, - }); - }); - - test('hydrates mixed legacy and child-exact targets without inventing a seenAt for the legacy target', () => { - const mixed = custody({ - notifiedByCatIds: [], - seenByCatIds: ['opus', 'codex'], - seenInvocationIdByCatId: { opus: 'inv-current', codex: 'legacy-parent-invocation' }, - }); - - assert.doesNotThrow(() => assertQueuedMessageCustody(mixed)); - const receipt = projectQueueReceipt(mixed); - assert.deepEqual(receipt.targets[0], { - catId: 'opus', - state: 'seen', - invocationId: 'inv-current', - seenAt: 1_200, - }); - assert.deepEqual(receipt.targets[1], { - catId: 'codex', - state: 'seen', - invocationId: 'legacy-parent-invocation', - }); - }); - - test('rejects mismatched lineage evidence instead of hydrating a false reply pointer', () => { - assert.throws( - () => - assertQueuedMessageCustody( - custody({ - targetOutcomeByCatId: { - gpt52: { - invocationId: 'inv-done', - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-other' }, - handledAt: 1_500, - }, - }, - }), - ), - /matching invocation evidence/, - ); - }); - - test('rejects a child-exact handled outcome without the matching body exposure', () => { - assert.throws( - () => - assertQueuedMessageCustody( - custody({ - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-current', seenAt: 1_200 }], - }), - ), - /matching exact body exposure/, - ); - }); - - test('storage transition cannot erase an already-persisted handled outcome', () => { - const current = custody(); - const next = structuredClone(current); - next.revision += 1; - next.targetOutcomeByCatId = undefined; - assert.throws( - () => assertQueueCustodyTransition(current, { expectedRevision: current.revision, next }), - /target outcomes are append-only/, - ); - }); - - test('storage transition cannot rewrite the durable Queue owner principal', () => { - const current = custody({ ownerUserId: 'user-owner' }); - const next = structuredClone(current); - next.revision += 1; - next.ownerUserId = 'scheduler'; - assert.throws( - () => assertQueueCustodyTransition(current, { expectedRevision: current.revision, next }), - /ownerUserId is immutable/, - ); - }); - - test('storage transition cannot erase or rewrite an exact body exposure', () => { - const current = custody(); - const erased = structuredClone(current); - erased.revision += 1; - erased.bodyExposures = erased.bodyExposures.slice(1); - assert.throws( - () => assertQueueCustodyTransition(current, { expectedRevision: current.revision, next: erased }), - /body exposures are append-only/, - ); - - const rewritten = structuredClone(current); - rewritten.revision += 1; - rewritten.bodyExposures[0].seenAt += 1; - assert.throws( - () => assertQueueCustodyTransition(current, { expectedRevision: current.revision, next: rewritten }), - /body exposures are append-only/, - ); - }); -}); - -describe('F264 exact body exposure identity', () => { - test('is idempotent by target and child invocation while preserving independent targets', () => { - const queue = new InvocationQueue(); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-exposure', - userId: 'user-exposure', - content: 'one persisted body', - source: 'user', - targetCats: ['opus', 'codex'], - intent: 'execute', - priority: 'normal', - }); - const entry = result.entry; - - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'child-opus-1', 1_100); - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'child-opus-1', 9_999); - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'codex', 'child-codex-1', 1_200); - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'child-opus-2', 1_300); - - assert.deepEqual( - queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id).queuedBodyExposures, - [ - { targetCatId: 'opus', invocationId: 'child-opus-1', seenAt: 1_100 }, - { targetCatId: 'codex', invocationId: 'child-codex-1', seenAt: 1_200 }, - { targetCatId: 'opus', invocationId: 'child-opus-2', seenAt: 1_300 }, - ], - 'duplicate exposure preserves the first exact seenAt while targets and retries remain independent', - ); - }); -}); - -describe('F264 manual reminder attempt', () => { - test('is idempotent for one message/target/invocation and tells delivered, seen, missed apart', () => { - const initial = custody({ reminderAttempts: [] }); - const requested = requestReminderAttempt(initial, { - id: 'reminder-1', - targetCatId: 'codex', - invocationId: 'inv-current', - requestedAt: 1_600, - }); - const duplicate = requestReminderAttempt(requested, { - id: 'reminder-duplicate', - targetCatId: 'codex', - invocationId: 'inv-current', - requestedAt: 1_700, - }); - assert.deepEqual(duplicate.reminderAttempts, requested.reminderAttempts); - - const delivered = markReminderAttemptDelivered(requested, 'codex', 'inv-current', 1_800); - assert.equal(delivered.reminderAttempts[0].state, 'delivered'); - const seen = markReminderAttemptSeen(delivered, 'codex', 'inv-current', 1_900); - assert.deepEqual(seen.reminderAttempts[0], { - id: 'reminder-1', - targetCatId: 'codex', - invocationId: 'inv-current', - state: 'seen', - requestedAt: 1_600, - deliveredAt: 1_800, - seenAt: 1_900, - }); - - const missed = markReminderAttemptMissed(requested, 'inv-current', 2_000); - assert.equal(missed.reminderAttempts[0].state, 'missed'); - assert.equal(missed.reminderAttempts[0].missedReason, 'invocation_ended_before_delivery'); - assert.equal(markReminderAttemptMissed(seen, 'inv-current', 2_000), seen, 'terminal attempt is immutable'); - }); - - test('rejects reminder requests for a cat outside the immutable target set', () => { - assert.throws( - () => - requestReminderAttempt(custody(), { - id: 'reminder-invalid', - targetCatId: 'gemini', - invocationId: 'inv-current', - requestedAt: 1_600, - }), - /target cat is not part of queue custody/, - ); - }); - - test('rejects unknown persisted reminder states', () => { - assert.throws( - () => - assertQueuedMessageCustody( - custody({ - reminderAttempts: [ - { - id: 'reminder-invalid-state', - targetCatId: 'codex', - invocationId: 'inv-current', - state: 'pretended_seen', - requestedAt: 1_600, - }, - ], - }), - ), - /invalid reminder attempt state/, - ); - }); - - test('storage transition cannot regress a delivered reminder to requested', () => { - const current = custody({ - reminderAttempts: [ - { - id: 'reminder-monotonic', - targetCatId: 'codex', - invocationId: 'inv-current', - state: 'delivered', - requestedAt: 1_600, - deliveredAt: 1_700, - }, - ], - }); - const next = structuredClone(current); - next.revision += 1; - next.reminderAttempts[0] = { - id: 'reminder-monotonic', - targetCatId: 'codex', - invocationId: 'inv-current', - state: 'requested', - requestedAt: 1_600, - }; - assert.throws( - () => assertQueueCustodyTransition(current, { expectedRevision: current.revision, next }), - /reminder attempts are append-only and monotonic/, - ); - }); -}); - -function createCustodiedEntry() { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'please keep the receipt', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); - assert.equal(result.outcome, 'enqueued'); - const entry = result.entry; - const message = store.append({ - threadId: entry.threadId, - userId: entry.userId, - catId: null, - content: entry.content, - mentions: entry.targetCats, - timestamp: entry.createdAt, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(entry), - }); - queue.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - const persistedEntry = queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - assert.ok(persistedEntry); - return { queue, store, entry: persistedEntry, message }; -} - -function agentQueueEntry(overrides = {}) { - return { - id: 'agent-queue-entry', - threadId: 'thread-agent-receipt', - userId: 'system', - ownerAuthProvenance: 'unknown', - content: 'A2A body', - messageId: 'message-agent-receipt', - mergedMessageIds: [], - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex-sol'], - allTargetCats: ['codex-sol'], - intent: 'execute', - status: 'queued', - createdAt: 1_000, - autoExecute: true, - priority: 'normal', - ...overrides, - }; -} - -describe('F264 agent QueueEntry receipt fallback', () => { - test('projects an exact seen receipt from an agent entry with no stored queue custody', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-agent-receipt', - userId: 'system', - content: 'A2A body already read by the live turn', - messageId: 'message-agent-receipt', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex-sol'], - intent: 'execute', - autoExecute: true, - }); - assert.equal(result.outcome, 'enqueued'); - const entry = result.entry; - assert.ok(entry); - - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'codex-sol', 'turn-live', 1_200); - - const [enriched] = await enrichQueueEntries(queue.list(entry.threadId, entry.userId), store); - assert.deepEqual(enriched.targetStates, { 'codex-sol': 'seen' }); - assert.deepEqual(enriched.queueReceipt, { - version: 1, - entryId: entry.id, - targets: [{ catId: 'codex-sol', state: 'seen', invocationId: 'turn-live', seenAt: 1_200 }], - reminderAttempts: [], - }); - }); - - test('fails closed instead of borrowing a mismatched legacy exposure as the seen invocation', async () => { - const [enriched] = await enrichQueueEntries( - [ - agentQueueEntry({ - id: 'agent-missing-exact-id', - messageId: 'message-agent-legacy', - queuedSeenByCatIds: ['codex-sol'], - queuedBodyExposures: [{ targetCatId: 'codex-sol', invocationId: 'turn-other', seenAt: 1_100 }], - }), - ], - null, - ); - - assert.deepEqual(enriched.queueReceipt, { - version: 1, - entryId: 'agent-missing-exact-id', - targets: [{ catId: 'codex-sol', state: 'seen' }], - reminderAttempts: [], - }); - }); - - test('fails closed when the seen invocation id and body exposure disagree', async () => { - const [enriched] = await enrichQueueEntries( - [ - agentQueueEntry({ - id: 'agent-mismatched-exact-id', - messageId: 'message-agent-mismatched', - queuedSeenByCatIds: ['codex-sol'], - queuedSeenInvocationIdByCatId: { 'codex-sol': 'turn-live' }, - queuedBodyExposures: [{ targetCatId: 'codex-sol', invocationId: 'turn-other', seenAt: 1_100 }], - }), - ], - null, - ); - - assert.deepEqual(enriched.queueReceipt, { - version: 1, - entryId: 'agent-mismatched-exact-id', - targets: [{ catId: 'codex-sol', state: 'seen' }], - reminderAttempts: [], - }); - }); - - test('keeps an agent failure recoverable rather than promoting a read marker to terminal success', async () => { - const queue = new InvocationQueue(); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-agent-failure', - userId: 'system', - content: 'A2A body whose reading turn failed', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex-sol'], - intent: 'execute', - autoExecute: true, - }); - assert.equal(result.outcome, 'enqueued'); - const entry = result.entry; - assert.ok(entry); - - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'codex-sol', 'turn-failed', 1_200); - queue.markQueuedFailedForCatAcrossUsers( - entry.threadId, - 'codex-sol', - 'turn-failed', - new Set(), - 'invocation_failed', - 1_300, - ); - - const [enriched] = await enrichQueueEntries(queue.list(entry.threadId, entry.userId), null); - assert.deepEqual(enriched.targetStates, { 'codex-sol': 'failed' }); - assert.deepEqual(enriched.queueReceipt, { - version: 1, - entryId: entry.id, - targets: [{ catId: 'codex-sol', state: 'failed', invocationId: 'turn-failed', seenAt: 1_200 }], - reminderAttempts: [], - }); - }); -}); - -describe('F264 custody coordinator evidence', () => { - test('late exact success reclassifies an exposed recall without reviving its body or Queue custody', async () => { - const { queue, store, entry, message } = createCustodiedEntry(); - const seenAt = entry.createdAt + 100; - const handledAt = seenAt + 200; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => handledAt }); - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'inv-recalled', seenAt); - const exposedEntry = queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - await coordinator.persistEntry(exposedEntry); - - const recalled = store.recallMessageToComposerDraft(message.id, { - ownerUserId: entry.userId, - threadId: entry.threadId, - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: seenAt + 100, - }); - assert.equal(recalled.kind, 'recalled'); - assert.equal(recalled.verdict, 'exposed'); - assert.deepEqual(recalled.message.queueCustody.withdrawnByCatIds, ['opus']); - - const settlement = await coordinator.commitSuccessfulTargets(exposedEntry, ['opus'], 'inv-recalled', handledAt, { - opus: { - invocationId: 'inv-recalled', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-recalled' }, - handledAt, - }, - }); - - assert.deepEqual(settlement.perMessage, [ - { messageId: message.id, handledTargetCats: ['opus'], pendingTargetCats: [], fullyConsumed: true }, - ]); - const terminal = store.getById(message.id); - assert.equal(terminal.content, ''); - assert.equal(terminal.deliveryStatus, 'canceled'); - assert.equal(terminal.recall.exposure, 'seen'); - assert.equal(terminal.queueCustody.status, 'terminal'); - assert.deepEqual(terminal.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(terminal.queueCustody.withdrawnByCatIds ?? [], []); - assert.equal(terminal.queueCustody.targetOutcomeByCatId.opus.invocationId, 'inv-recalled'); - }); - - test('persists exact handled disposition and lineage without deleting it at terminal delivery', async () => { - const { queue, store, entry, message } = createCustodiedEntry(); - let now = entry.createdAt + 100; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => now }); - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'inv-1', now); - const seen = queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - await coordinator.persistEntry(seen); - - now += 100; - await coordinator.commitSuccessfulTargets(seen, ['opus'], 'inv-1', now, { - opus: { - invocationId: 'inv-1', - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-1' }, - handledAt: now, - }, - }); - - assert.deepEqual(store.getById(message.id).queueCustody.targetOutcomeByCatId, { - opus: { - invocationId: 'inv-1', - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-1' }, - handledAt: now, - }, - }); - const persistedCustody = store.getById(message.id).queueCustody; - assert.deepEqual(persistedCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'inv-1', seenAt: entry.createdAt + 100 }, - ]); - assert.equal( - persistedCustody.bodyExposures[0].seenAt < persistedCustody.targetOutcomeByCatId.opus.handledAt, - true, - 'read time must precede terminal handled time', - ); - }); - - test('failed invocation retains seenAt while clearing only its active handled fence', async () => { - const { queue, store, entry, message } = createCustodiedEntry(); - const seenAt = entry.createdAt + 100; - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, 'opus', 'inv-failed', seenAt); - await new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => seenAt }).persistEntry( - queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id), - ); - - queue.markQueuedFailedForCatAcrossUsers(entry.threadId, 'opus', 'inv-failed'); - await new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => seenAt + 10 }).persistEntry( - queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id), - ); - - const target = projectQueueReceipt(store.getById(message.id).queueCustody).targets[0]; - assert.equal(target.catId, 'opus'); - assert.equal(target.state, 'failed'); - assert.equal(target.invocationId, 'inv-failed'); - assert.equal(target.seenAt, seenAt); - assert.deepEqual( - target.attempts?.map((attempt) => ({ state: attempt.state, invocationId: attempt.invocationId })), - [{ state: 'failed', invocationId: 'inv-failed' }], - ); - }); - - test('persists requested, delivered, and seen reminder transitions through custody CAS', async () => { - const { store, entry, message } = createCustodiedEntry(); - let now = entry.createdAt + 100; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => now }); - - await coordinator.requestReminder(entry, 'opus', 'inv-active', 'reminder-1'); - now += 100; - await coordinator.markReminderDelivered(entry, 'opus', 'inv-active'); - now += 100; - await coordinator.markReminderSeen(entry, 'opus', 'inv-active'); - - assert.equal( - (await coordinator.findReminderAttempt(entry, 'opus', 'inv-active')).id, - 'reminder-1', - 'idempotent endpoint lookup returns the durable attempt', - ); - - assert.deepEqual(store.getById(message.id).queueCustody.reminderAttempts, [ - { - id: 'reminder-1', - targetCatId: 'opus', - invocationId: 'inv-active', - state: 'seen', - requestedAt: entry.createdAt + 100, - deliveredAt: entry.createdAt + 200, - seenAt: entry.createdAt + 300, - }, - ]); - }); - - test('hydrates active QueuePanel rows from the same durable receipt projection', async () => { - const { queue, store, entry } = createCustodiedEntry(); - await new QueuedMessageCustodyCoordinator({ messageStore: store }).requestReminder( - entry, - 'opus', - 'inv-active', - 'reminder-active', - ); - - const [enriched] = await enrichQueueEntries(queue.list('thread-1', 'user-1'), store); - assert.deepEqual(enriched.queueReceipt, { - version: 1, - entryId: entry.id, - targets: [ - { - catId: 'opus', - state: 'queued', - attempts: [ - { - id: `${entry.id}:opus:1`, - targetCatId: 'opus', - sequence: 1, - state: 'queued', - createdAt: entry.createdAt, - updatedAt: entry.createdAt, - }, - ], - }, - ], - reminderAttempts: [ - { - id: 'reminder-active', - targetCatId: 'opus', - invocationId: 'inv-active', - state: 'requested', - requestedAt: enriched.queueReceipt.reminderAttempts[0].requestedAt, - }, - ], - }); - }); -}); diff --git a/packages/api/test/f264-queue-withdrawal-startup.test.js b/packages/api/test/f264-queue-withdrawal-startup.test.js deleted file mode 100644 index b98203c089..0000000000 --- a/packages/api/test/f264-queue-withdrawal-startup.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { createInitialQueuedMessageCustody } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { QueuedMessageCustodyStartupReconciler } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; - -describe('F264 withdrawn Queue custody startup recovery', () => { - test('keeps terminal withdrawn author history queued without restoring actionable work', async () => { - const queue = new InvocationQueue(); - const admitted = queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - content: 'keep this in my timeline', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - ownerAuthProvenance: 'strict', - }); - assert.equal(admitted.outcome, 'enqueued'); - const custody = { - ...createInitialQueuedMessageCustody(admitted.entry), - revision: 2, - status: 'terminal', - pendingTargetCats: [], - withdrawnByCatIds: ['opus'], - withdrawnAtByCatId: { opus: admitted.entry.createdAt + 100 }, - updatedAt: admitted.entry.createdAt + 100, - }; - const store = new MessageStore(); - const message = store.append({ - threadId: admitted.entry.threadId, - userId: admitted.entry.userId, - catId: null, - content: admitted.entry.content, - mentions: admitted.entry.targetCats, - timestamp: admitted.entry.createdAt, - deliveryStatus: 'queued', - queueCustody: custody, - }); - store.scanByDeliveryStatus = async (status) => (status === 'queued' ? [message.id] : []); - const recoveredQueue = new InvocationQueue(); - const reconciler = new QueuedMessageCustodyStartupReconciler({ - messageStore: store, - invocationRecordStore: { get: async () => undefined }, - invocationQueue: recoveredQueue, - log: { info() {}, warn() {} }, - now: () => admitted.entry.createdAt + 1_000, - }); - - const result = await reconciler.reconcile(); - - assert.equal(result.entriesRestored, 0); - assert.equal(result.messagesTerminalized, 1); - assert.equal(result.messagesFailed, 0); - assert.deepEqual(recoveredQueue.list('thread-1', 'user-1'), []); - assert.equal(store.getById(message.id)?.deliveryStatus, 'queued'); - assert.equal(store.getById(message.id)?.queueCustody?.status, 'terminal'); - }); -}); diff --git a/packages/api/test/f264-true-recall-contract.test.js b/packages/api/test/f264-true-recall-contract.test.js index cc1d64138b..96bac1ff2b 100644 --- a/packages/api/test/f264-true-recall-contract.test.js +++ b/packages/api/test/f264-true-recall-contract.test.js @@ -2,44 +2,25 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; -function custody(overrides = {}) { - return { - version: 1, - entryId: 'entry-1', - revision: 1, - intent: 'user_message', - status: 'queued', - allTargetCats: ['codex', 'fable5'], - pendingTargetCats: ['codex', 'fable5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 100, - updatedAt: 100, - ...overrides, - }; -} - -function appendQueued(store, queueCustody = custody()) { - return store.append({ - threadId: 'thread-f264-gap-f', - userId: 'owner-1', - catId: null, - content: '修正后的正文', - contentBlocks: [ - { type: 'text', text: '修正后的正文' }, - { type: 'image', url: '/uploads/proof.png' }, - ], - mentions: ['codex', 'fable5'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody, - replyTo: 'parent-message', - }); +function appendQueued(store) { + return store.append( + canonicalTestMessageInput({ + threadId: 'thread-f264-gap-f', + userId: 'owner-1', + catId: null, + content: '修正后的正文', + contentBlocks: [ + { type: 'text', text: '修正后的正文' }, + { type: 'image', url: '/uploads/proof.png' }, + ], + mentions: ['codex', 'fable5'], + timestamp: 1_000, + deliveryStatus: 'queued', + replyTo: 'parent-message', + }), + ); } describe('F264 Gap F true recall contract (memory store)', () => { @@ -69,17 +50,20 @@ describe('F264 Gap F true recall contract (memory store)', () => { assert.equal(store.getOwnerComposerDraft('owner-1', 'thread-foreign'), null); }); - it('refuses a queued body without durable Queue custody', () => { + it('refuses a body that already left the Queue', () => { const store = new MessageStore(); - const message = store.append({ - threadId: 'thread-f264-gap-f', - userId: 'owner-1', - catId: null, - content: '没有 custody', - mentions: [], - timestamp: 1_000, - deliveryStatus: 'queued', - }); + const message = store.append( + canonicalTestMessageInput({ + threadId: 'thread-f264-gap-f', + userId: 'owner-1', + catId: null, + content: '没有 custody', + mentions: [], + timestamp: 1_000, + deliveryStatus: 'queued', + }), + ); + assert.equal(store.markDelivered(message.id, 1_500).deliveryTransitioned, true); assert.deepEqual( store.recallMessageToComposerDraft(message.id, { ownerUserId: 'owner-1', @@ -122,124 +106,12 @@ describe('F264 Gap F true recall contract (memory store)', () => { assert.equal(tombstone.deliveryStatus, 'canceled'); assert.equal(tombstone._tombstone, true); assert.equal(tombstone.recall.exposure, 'none'); - assert.deepEqual(tombstone.queueCustody.pendingTargetCats, []); - assert.equal(tombstone.queueCustody.status, 'terminal'); - assert.deepEqual(new Set(tombstone.queueCustody.withdrawnByCatIds), new Set(['codex', 'fable5'])); + assert.equal(tombstone.queueCustody, undefined); assert.equal(store.getByThread('thread-f264-gap-f', 20, 'owner-1', { includeQueuedUserMessages: true }).length, 0); assert.equal(store.getOwnerComposerDraft('owner-1', 'thread-f264-gap-f').text, '修正后的正文'); }); - it('derives terminal recall custody once and closes every active reminder attempt', () => { - const store = new MessageStore(); - const message = appendQueued( - store, - custody({ - reminderAttempts: [ - { - id: 'reminder-requested', - targetCatId: 'codex', - invocationId: 'child-codex', - state: 'requested', - requestedAt: 120, - }, - { - id: 'reminder-delivered', - targetCatId: 'fable5', - invocationId: 'child-fable5', - state: 'delivered', - requestedAt: 130, - deliveredAt: 140, - }, - ], - }), - ); - - const result = store.recallMessageToComposerDraft(message.id, { - ownerUserId: 'owner-1', - threadId: 'thread-f264-gap-f', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 2_000, - }); - - assert.equal(result.kind, 'recalled'); - assert.deepEqual(result.message.queueCustody.reminderAttempts, [ - { - id: 'reminder-requested', - targetCatId: 'codex', - invocationId: 'child-codex', - state: 'missed', - requestedAt: 120, - missedAt: 2_000, - missedReason: 'source_withdrawn', - }, - { - id: 'reminder-delivered', - targetCatId: 'fable5', - invocationId: 'child-fable5', - state: 'missed', - requestedAt: 130, - deliveredAt: 140, - missedAt: 2_000, - missedReason: 'source_withdrawn', - }, - ]); - }); - - it('preserves content-free exact exposure truth and withdraws only unread siblings', () => { - const store = new MessageStore(); - const message = appendQueued( - store, - custody({ - seenByCatIds: ['codex'], - seenInvocationIdByCatId: { codex: 'child-codex' }, - awakenedInvocationIdByCatId: { codex: 'child-codex' }, - awakenedAtByCatId: { codex: 1_400 }, - bodyExposures: [{ targetCatId: 'codex', invocationId: 'child-codex', seenAt: 1_500 }], - }), - ); - store.visibilitySeq.set(message.id, 1_400); - const [published] = store.getByThreadAfter('thread-f264-gap-f', undefined, 20, 'owner-1', { - includeQueuedUserMessages: true, - }); - assert.ok(published?.visibilitySeq); - - const result = store.recallMessageToComposerDraft(message.id, { - ownerUserId: 'owner-1', - threadId: 'thread-f264-gap-f', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 2_000, - }); - - assert.equal(result.kind, 'recalled'); - assert.equal(result.verdict, 'exposed'); - assert.deepEqual(result.message.recall.exposures, [ - { targetCatId: 'codex', invocationId: 'child-codex', seenAt: 1_500 }, - ]); - assert.deepEqual(result.message.queueCustody.seenByCatIds, ['codex']); - assert.deepEqual(result.message.queueCustody.pendingTargetCats, []); - assert.deepEqual(result.message.queueCustody.withdrawnByCatIds, ['codex', 'fable5']); - assert.equal(result.message.queueCustody.awakenedInvocationIdByCatId, undefined); - assert.equal(result.message.queueCustody.awakenedAtByCatId, undefined); - assert.equal(result.message.content, ''); - - const ownerHistory = store.getByThread('thread-f264-gap-f', 20, 'owner-1', { - includeQueuedUserMessages: true, - includeRecalledUserMessages: true, - }); - assert.equal(ownerHistory.length, 1); - assert.equal(ownerHistory[0].id, message.id); - assert.equal(ownerHistory[0].content, ''); - const [incrementalTombstone] = store.getByThreadAfter('thread-f264-gap-f', undefined, 20, 'owner-1', { - includeRecalledUserMessages: true, - }); - assert.equal(incrementalTombstone.id, message.id); - assert.equal(incrementalTombstone.visibilitySeq, published.visibilitySeq, 'recall retains the published cursor'); - assert.equal(store.getByThread('thread-f264-gap-f').length, 0, 'cat/default reads never publish recalled body'); - }); - it('keeps message, custody and both drafts unchanged on a stale draft revision', () => { const store = new MessageStore(); const message = appendQueued(store); @@ -265,35 +137,6 @@ describe('F264 Gap F true recall contract (memory store)', () => { assert.deepEqual(store.getOwnerComposerDraft('owner-1', 'thread-f264-gap-f'), beforeDraft); }); - it('recalls an already published handled source without erasing its exact handling lineage', () => { - const store = new MessageStore(); - const message = appendQueued( - store, - custody({ - status: 'terminal', - pendingTargetCats: [], - seenByCatIds: ['codex'], - seenInvocationIdByCatId: { codex: 'child-handled' }, - bodyExposures: [{ targetCatId: 'codex', invocationId: 'child-handled', seenAt: 1_500 }], - handledByCatIds: ['codex', 'fable5'], - }), - ); - assert.equal(store.markDelivered(message.id, 1_800).deliveryTransitioned, true); - - const result = store.recallMessageToComposerDraft(message.id, { - ownerUserId: 'owner-1', - threadId: 'thread-f264-gap-f', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 2_000, - }); - assert.equal(result.kind, 'recalled'); - assert.equal(result.verdict, 'exposed'); - assert.deepEqual(result.message.queueCustody.handledByCatIds, ['codex', 'fable5']); - assert.deepEqual(result.message.queueCustody.withdrawnByCatIds, []); - assert.equal(result.message.content, ''); - }); - it('blank-line appends once and repeat recall never duplicates the transferred body', () => { const store = new MessageStore(); const message = appendQueued(store); diff --git a/packages/api/test/f264-true-recall-redis.test.js b/packages/api/test/f264-true-recall-redis.test.js index 488e02d601..0bea2e6ff8 100644 --- a/packages/api/test/f264-true-recall-redis.test.js +++ b/packages/api/test/f264-true-recall-redis.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -8,41 +9,17 @@ import { const REDIS_URL = process.env.REDIS_URL; -function custody(overrides = {}) { - return { - version: 1, - entryId: 'entry-redis', - revision: 1, - intent: 'user_message', - status: 'queued', - allTargetCats: ['codex', 'fable5'], - pendingTargetCats: ['codex', 'fable5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 100, - updatedAt: 100, - ...overrides, - }; -} - describe('F264 Gap F true recall contract (Redis store)', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { let redis; let store; - let QueuedMessageCustodyCoordinator; let connected = false; before(async () => { assertRedisIsolationOrThrow(REDIS_URL, 'F264 true recall Redis'); - const [{ RedisMessageStore }, { createRedisClient }, custodyModule] = await Promise.all([ + const [{ RedisMessageStore }, { createRedisClient }] = await Promise.all([ import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'), import('@cat-cafe/shared/utils'), - import('../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'), ]); - QueuedMessageCustodyCoordinator = custodyModule.QueuedMessageCustodyCoordinator; redis = createRedisClient({ url: REDIS_URL }); try { await redis.ping(); @@ -65,19 +42,20 @@ describe('F264 Gap F true recall contract (Redis store)', { skip: redisIsolation await cleanupPrefixedRedisKeys(redis, ['msg:*']); }); - async function appendQueued(queueCustody = custody()) { - return store.append({ - threadId: 'thread-f264-gap-f-redis', - userId: 'owner-redis', - catId: null, - content: '修正后的正文', - contentBlocks: [{ type: 'image', url: '/uploads/proof.png' }], - mentions: ['codex', 'fable5'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody, - replyTo: 'parent-message', - }); + async function appendQueued() { + return store.append( + canonicalTestMessageInput({ + threadId: 'thread-f264-gap-f-redis', + userId: 'owner-redis', + catId: null, + content: '修正后的正文', + contentBlocks: [{ type: 'image', url: '/uploads/proof.png' }], + mentions: ['codex', 'fable5'], + timestamp: 1_000, + deliveryStatus: 'queued', + replyTo: 'parent-message', + }), + ); } it('rejects foreign owner and thread without mutating either Redis hash', async () => { @@ -129,57 +107,8 @@ describe('F264 Gap F true recall contract (Redis store)', { skip: redisIsolation ); }); - it('persists the same valid terminal reminder settlement as the memory store', async () => { - const message = await appendQueued( - custody({ - reminderAttempts: [ - { - id: 'reminder-redis-delivered', - targetCatId: 'codex', - invocationId: 'child-redis', - state: 'delivered', - requestedAt: 120, - deliveredAt: 140, - }, - ], - }), - ); - - const result = await store.recallMessageToComposerDraft(message.id, { - ownerUserId: 'owner-redis', - threadId: 'thread-f264-gap-f-redis', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 2_000, - }); - - assert.equal(result.kind, 'recalled'); - assert.deepEqual(result.message.queueCustody.reminderAttempts, [ - { - id: 'reminder-redis-delivered', - targetCatId: 'codex', - invocationId: 'child-redis', - state: 'missed', - requestedAt: 120, - deliveredAt: 140, - missedAt: 2_000, - missedReason: 'source_withdrawn', - }, - ]); - assert.deepEqual( - (await store.getById(message.id)).queueCustody.reminderAttempts, - result.message.queueCustody.reminderAttempts, - ); - }); - - it('preserves exact exposure lineage while hiding the body from default reads', async () => { - const message = await appendQueued( - custody({ - seenByCatIds: ['codex'], - seenInvocationIdByCatId: { codex: 'child-codex' }, - bodyExposures: [{ targetCatId: 'codex', invocationId: 'child-codex', seenAt: 1_500 }], - }), - ); + it('removes a stale legacy visibility member when recalling still-pending work', async () => { + const message = await appendQueued(); const visibilityKey = 'msg:visibility:thread-f264-gap-f-redis'; await redis .multi() @@ -197,75 +126,17 @@ describe('F264 Gap F true recall contract (Redis store)', { skip: redisIsolation }); assert.equal(result.kind, 'recalled'); - assert.equal(result.verdict, 'exposed'); - assert.deepEqual(result.message.recall.exposures, [ - { targetCatId: 'codex', invocationId: 'child-codex', seenAt: 1_500 }, - ]); - const indexed = await store.getByQueueExposure('thread-f264-gap-f-redis', 'codex', 'child-codex'); - assert.deepEqual( - indexed.map((candidate) => candidate.id), - [message.id], - ); + assert.equal(result.verdict, 'zero_exposure'); + assert.equal(result.message.recall.exposures, undefined); + const persisted = await store.getById(message.id); + assert.equal(persisted.recall.exposures, undefined); assert.equal((await store.getByThread('thread-f264-gap-f-redis')).length, 0); assert.equal( - ( - await store.getByThread('thread-f264-gap-f-redis', 20, 'owner-redis', { - includeRecalledUserMessages: true, - }) - ).length, - 1, + (await store.getByThread('thread-f264-gap-f-redis', 20, 'owner-redis', { includeRecalledUserMessages: true })) + .length, + 0, ); - assert.equal(await redis.zscore(visibilityKey, message.id), publishedScore, 'recall retains the published cursor'); - const [incrementalTombstone] = await store.getByThreadAfter( - 'thread-f264-gap-f-redis', - undefined, - 20, - 'owner-redis', - { includeRecalledUserMessages: true }, - ); - assert.equal(incrementalTombstone.id, message.id); - assert.equal(incrementalTombstone.content, ''); - }); - - it('appends a late exact handling witness to an exposed tombstone without delivering or restoring it', async () => { - const message = await appendQueued( - custody({ - seenByCatIds: ['codex'], - seenInvocationIdByCatId: { codex: 'child-codex-late' }, - bodyExposures: [{ targetCatId: 'codex', invocationId: 'child-codex-late', seenAt: 1_500 }], - }), - ); - const recalled = await store.recallMessageToComposerDraft(message.id, { - ownerUserId: 'owner-redis', - threadId: 'thread-f264-gap-f-redis', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 2_000, - }); - assert.equal(recalled.kind, 'recalled'); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store, now: () => 2_200 }); - - const settlement = await coordinator.commitSuccessfulTargetForMessage( - 'entry-redis', - message.id, - 'codex', - 'child-codex-late', - 2_200, - { - invocationId: 'child-codex-late', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'child-codex-late' }, - handledAt: 2_200, - }, - ); - - assert.deepEqual(settlement.handledTargetCats, ['codex']); - const terminal = await store.getById(message.id); - assert.equal(terminal.content, ''); - assert.equal(terminal.deliveryStatus, 'canceled'); - assert.deepEqual(terminal.queueCustody.handledByCatIds, ['codex']); - assert.deepEqual(terminal.queueCustody.withdrawnByCatIds, ['fable5']); - assert.equal(terminal.queueCustody.targetOutcomeByCatId.codex.invocationId, 'child-codex-late'); + assert.equal(await redis.zscore(visibilityKey, message.id), null); }); it('does not mutate either hash when the draft revision is stale', async () => { @@ -323,17 +194,8 @@ describe('F264 Gap F true recall contract (Redis store)', { skip: redisIsolation ); }); - it('matches MemoryStore by recalling an already delivered handled source', async () => { - const message = await appendQueued( - custody({ - status: 'terminal', - pendingTargetCats: [], - seenByCatIds: ['codex'], - seenInvocationIdByCatId: { codex: 'child-handled' }, - bodyExposures: [{ targetCatId: 'codex', invocationId: 'child-handled', seenAt: 1_500 }], - handledByCatIds: ['codex', 'fable5'], - }), - ); + it('refuses an already delivered source because dequeue is terminal', async () => { + const message = await appendQueued(); assert.equal((await store.markDelivered(message.id, 1_800)).deliveryTransitioned, true); const result = await store.recallMessageToComposerDraft(message.id, { @@ -343,11 +205,8 @@ describe('F264 Gap F true recall contract (Redis store)', { skip: redisIsolation merge: 'replace', recalledAt: 2_000, }); - assert.equal(result.kind, 'recalled'); - assert.equal(result.verdict, 'exposed'); - assert.deepEqual(result.message.queueCustody.handledByCatIds, ['codex', 'fable5']); - assert.deepEqual(result.message.queueCustody.withdrawnByCatIds, []); - assert.equal(result.message.content, ''); + assert.equal(result.kind, 'not_recallable'); + assert.equal((await store.getById(message.id)).content, '修正后的正文'); }); it('keeps a content-free revision tombstone after clear to prevent revision ABA', async () => { diff --git a/packages/api/test/f264-true-recall-route.test.js b/packages/api/test/f264-true-recall-route.test.js index 56b07e7c8e..edf551f4d9 100644 --- a/packages/api/test/f264-true-recall-route.test.js +++ b/packages/api/test/f264-true-recall-route.test.js @@ -4,86 +4,57 @@ import { afterEach, describe, it } from 'node:test'; import Fastify from 'fastify'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { QueuedMessageCustodyCoordinator } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; +import { InMemoryQueueLedgerStore } from '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; import { messageActionsRoutes } from '../dist/routes/message-actions.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const THREAD_ID = 'thread-f264-gap-f-route'; const OWNER_ID = 'owner-f264-gap-f'; const AUTH_HEADERS = { 'x-cat-cafe-user': OWNER_ID }; -function queueCustody(entryId, overrides = {}) { - return { - version: 1, - entryId, - revision: 1, - ownerAuthProvenance: 'strict', - intent: 'user_message', - status: 'queued', - allTargetCats: ['codex', 'fable5'], - pendingTargetCats: ['codex', 'fable5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 100, - updatedAt: 100, - ...overrides, - }; -} - -function queueEntry(entryId, messages, overrides = {}) { - return { - id: entryId, +async function appendQueued(harness, content, targetCats = ['codex', 'fable5']) { + const message = harness.messageStore.append( + canonicalTestMessageInput({ + threadId: THREAD_ID, + userId: OWNER_ID, + catId: null, + content, + mentions: ['codex', 'fable5'], + timestamp: 1_000 + content.length, + }), + ); + const admission = await harness.invocationQueue.enqueueExistingMessageDurable(harness.messageStore, message.id, { threadId: THREAD_ID, userId: OWNER_ID, + kind: 'conversation_input', ownerAuthProvenance: 'strict', - content: messages.map((message) => message.content).join('\n\n'), - messageId: messages[0]?.id ?? null, - mergedMessageIds: messages.slice(1).map((message) => message.id), - source: 'user', - targetCats: ['codex', 'fable5'], - allTargetCats: ['codex', 'fable5'], + content, + messageId: message.id, + from: { kind: 'user', userId: OWNER_ID }, + targetCats, intent: 'user_message', - status: 'queued', - createdAt: 100, autoExecute: false, priority: 'normal', - ...overrides, - }; -} - -function appendQueued(messageStore, entryId, content, overrides = {}) { - return messageStore.append({ - threadId: THREAD_ID, - userId: OWNER_ID, - catId: null, - content, - mentions: ['codex', 'fable5'], - timestamp: 1_000 + content.length, - deliveryStatus: 'queued', - queueCustody: queueCustody(entryId, overrides.custody), - ...overrides.message, }); + assert.equal(admission.outcome, 'enqueued'); + return { message: admission.message, entries: admission.entries }; } function createHarness(overrides = {}) { const messageStore = new MessageStore(); const threadStore = new ThreadStore(); threadStore.ensureThread(THREAD_ID, 'Gap F route test'); - const invocationQueue = new InvocationQueue(); - const queueCustodyCoordinator = - overrides.queueCustodyCoordinatorFactory?.({ messageStore, invocationQueue }) ?? - new QueuedMessageCustodyCoordinator({ messageStore }); + const ledgerStore = overrides.ledgerStore ?? new InMemoryQueueLedgerStore(); + const invocationQueue = new InvocationQueue(ledgerStore); const socketEvents = []; const finalizedEntries = []; const unregisteredEntries = []; const suppressedPassages = []; const releasedPassages = []; const finalizedPassages = []; + const withdrawalObservations = []; const suppressionStates = new Map(); const socketManager = { broadcastAgentMessage() {}, @@ -95,6 +66,9 @@ function createHarness(overrides = {}) { }, }; const queueProcessor = { + registerCallerDispatchQueueWithdrawal(carrier) { + withdrawalObservations.push(carrier); + }, unregisterEntryCompleteHook(entryId) { unregisteredEntries.push(entryId); }, @@ -132,7 +106,6 @@ function createHarness(overrides = {}) { messageStore, threadStore, invocationQueue, - queueCustodyCoordinator, queueProcessor, indexBuilder, socketManager, @@ -142,12 +115,14 @@ function createHarness(overrides = {}) { messageStore, threadStore, invocationQueue, + ledgerStore, socketEvents, finalizedEntries, unregisteredEntries, suppressedPassages, releasedPassages, finalizedPassages, + withdrawalObservations, }; } @@ -160,9 +135,7 @@ describe('F264 Gap F true recall API', () => { it('rejects foreign-owner and foreign-thread recall before suppression or queue mutation', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-authorization', '只能由原 owner 在原 thread 撤回'); - const entry = queueEntry('entry-authorization', [message]); - harness.invocationQueue.restoreDurableEntry(entry); + const { message, entries } = await appendQueued(harness, '只能由原 owner 在原 thread 撤回'); const foreignOwner = await harness.app.inject({ method: 'POST', @@ -182,7 +155,13 @@ describe('F264 Gap F true recall API', () => { assert.deepEqual(harness.suppressedPassages, []); assert.deepEqual(harness.releasedPassages, []); assert.deepEqual(harness.finalizedPassages, []); - assert.deepEqual(harness.invocationQueue.getEntrySnapshot(THREAD_ID, OWNER_ID, entry.id), entry); + assert.deepEqual( + harness.invocationQueue + .list(THREAD_ID, OWNER_ID) + .map((entry) => entry.id) + .sort(), + entries.map((entry) => entry.id).sort(), + ); assert.equal(harness.messageStore.getById(message.id).content, '只能由原 owner 在原 thread 撤回'); assert.equal(harness.messageStore.getOwnerComposerDraft(OWNER_ID, THREAD_ID), null); assert.equal(harness.messageStore.getOwnerComposerDraft('foreign-owner', THREAD_ID), null); @@ -192,8 +171,7 @@ describe('F264 Gap F true recall API', () => { it('atomically removes a zero-exposure carrier and returns the body only in the owner draft', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-one', '打错的正文'); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-one', [message])); + const { message, entries } = await appendQueued(harness, '打错的正文'); const response = await harness.app.inject({ method: 'POST', @@ -212,16 +190,26 @@ describe('F264 Gap F true recall API', () => { assert.equal(harness.messageStore.getById(message.id).content, ''); assert.deepEqual(harness.suppressedPassages, [{ threadId: THREAD_ID, messageId: message.id }]); assert.deepEqual(harness.releasedPassages, []); - assert.deepEqual(harness.unregisteredEntries, ['entry-one']); - assert.deepEqual(harness.finalizedEntries, ['entry-one']); + assert.deepEqual( + harness.unregisteredEntries, + entries.map((entry) => entry.id), + ); + assert.deepEqual( + harness.finalizedEntries, + entries.map((entry) => entry.id), + ); + assert.deepEqual( + harness.withdrawalObservations.map((entry) => entry.id), + entries.map((entry) => entry.id), + 'the successful Queue withdrawal must pass every committed carrier through the caller-view hook', + ); assert.ok(harness.socketEvents.some((event) => event.event === 'message_recalled')); }); it('replays an already committed recall without appending the body a second time', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-repeat', '只应回填一次'); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-repeat', [message])); + const { message } = await appendQueued(harness, '只应回填一次'); const first = await harness.app.inject({ method: 'POST', @@ -247,9 +235,7 @@ describe('F264 Gap F true recall API', () => { it('restores the exact Queue snapshot when the owner draft revision is stale', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-conflict', '不能丢的正文'); - const entry = queueEntry('entry-conflict', [message], { position: 3 }); - harness.invocationQueue.restoreDurableEntry(entry); + const { message, entries } = await appendQueued(harness, '不能丢的正文'); harness.messageStore.putOwnerComposerDraft(OWNER_ID, THREAD_ID, { expectedRevision: 0, text: '并发编辑的新草稿', @@ -265,7 +251,15 @@ describe('F264 Gap F true recall API', () => { assert.equal(response.statusCode, 409); assert.deepEqual(response.json(), { code: 'DRAFT_REVISION_MISMATCH', actualRevision: 1 }); - assert.deepEqual(harness.invocationQueue.getEntrySnapshot(THREAD_ID, OWNER_ID, entry.id), entry); + assert.deepEqual( + harness.invocationQueue + .list(THREAD_ID, OWNER_ID) + .map((entry) => ({ id: entry.id, status: entry.status })) + .sort((left, right) => left.id.localeCompare(right.id)), + entries + .map((entry) => ({ id: entry.id, status: 'queued' })) + .sort((left, right) => left.id.localeCompare(right.id)), + ); assert.equal(harness.messageStore.getById(message.id).content, '不能丢的正文'); assert.deepEqual(harness.suppressedPassages, [{ threadId: THREAD_ID, messageId: message.id }]); assert.deepEqual(harness.releasedPassages, [{ threadId: THREAD_ID, messageId: message.id, leaseId: 'lease-1' }]); @@ -274,9 +268,8 @@ describe('F264 Gap F true recall API', () => { it('scrubs an exact first-message-derived thread title when recall commits', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-derived-title', '打错且成为标题的正文'); + const { message } = await appendQueued(harness, '打错且成为标题的正文'); harness.threadStore.updateTitle(THREAD_ID, message.content); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-derived-title', [message])); const response = await harness.app.inject({ method: 'POST', @@ -292,9 +285,8 @@ describe('F264 Gap F true recall API', () => { it('preserves a custom thread title when recalling the first message', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-custom-title', '正文不是标题'); + const { message } = await appendQueued(harness, '正文不是标题'); harness.threadStore.updateTitle(THREAD_ID, '用户亲自改的标题'); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-custom-title', [message])); const response = await harness.app.inject({ method: 'POST', @@ -310,10 +302,8 @@ describe('F264 Gap F true recall API', () => { it('restores a prepared derived title when canonical recall is rejected', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-title-rollback', '失败后仍是原标题'); + const { message } = await appendQueued(harness, '失败后仍是原标题'); harness.threadStore.updateTitle(THREAD_ID, message.content); - const entry = queueEntry('entry-title-rollback', [message]); - harness.invocationQueue.restoreDurableEntry(entry); harness.messageStore.putOwnerComposerDraft(OWNER_ID, THREAD_ID, { expectedRevision: 0, text: '并发草稿', @@ -331,82 +321,17 @@ describe('F264 Gap F true recall API', () => { assert.equal(harness.threadStore.get(THREAD_ID).title, message.content); }); - it('does not let a losing concurrent recall restore a title after the winning recall commits', async () => { - let firstCommitEntered; - let releaseFirstCommit; - const firstCommitReady = new Promise((resolve) => { - firstCommitEntered = resolve; - }); - const firstCommitGate = new Promise((resolve) => { - releaseFirstCommit = resolve; - }); - let commitCalls = 0; - const harness = createHarness({ - queueCustodyCoordinatorFactory({ messageStore }) { - const canonical = new QueuedMessageCustodyCoordinator({ messageStore }); - return { - async recallMessageToComposerDraft(...args) { - commitCalls += 1; - if (commitCalls === 1) { - firstCommitEntered(); - await firstCommitGate; - } - return canonical.recallMessageToComposerDraft(...args); - }, - }; - }, - }); + it('rejects recall while the single pending Queue entry is claimed by another operation', async () => { + const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-title-concurrent', '并发撤回后也不能回露的标题正文'); - harness.threadStore.updateTitle(THREAD_ID, message.content); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-title-concurrent', [message])); - - const losingRequest = harness.app.inject({ - method: 'POST', - url: `/api/messages/${message.id}/recall`, - headers: AUTH_HEADERS, - payload: { threadId: THREAD_ID, expectedDraftRevision: 0, merge: 'replace' }, - }); - await firstCommitReady; - const winningResponse = await harness.app.inject({ - method: 'POST', - url: `/api/messages/${message.id}/recall`, - headers: AUTH_HEADERS, - payload: { threadId: THREAD_ID, expectedDraftRevision: 0, merge: 'replace' }, - }); - releaseFirstCommit(); - const losingResponse = await losingRequest; - - assert.equal(winningResponse.statusCode, 200, winningResponse.body); - assert.equal(winningResponse.json().verdict, 'zero_exposure'); - assert.equal(losingResponse.statusCode, 409, losingResponse.body); - assert.equal(losingResponse.json().code, 'QUEUE_CARRIER_CHANGED'); - assert.equal(harness.messageStore.getById(message.id).content, ''); - assert.equal( - harness.threadStore.get(THREAD_ID).title, - `Thread ${THREAD_ID.slice(0, 12)}`, - 'a committed lease must fence the losing request from restoring the recalled body', + const { message, entries } = await appendQueued(harness, '已经出队的正文'); + const claim = await harness.invocationQueue.claimMessageEntriesForWithdrawal( + THREAD_ID, + OWNER_ID, + message.id, + 1_500, ); - }); - - it('rejects a concurrently changed Queue carrier without rolling it back or moving the body', async () => { - let harness; - const changedEntry = { current: null }; - harness = createHarness({ - indexBuilder: { - async suppressMessagePassage(threadId, messageId) { - harness.suppressedPassages.push({ threadId, messageId }); - const current = harness.invocationQueue.getEntrySnapshot(THREAD_ID, OWNER_ID, 'entry-carrier-race'); - assert.ok(current); - changedEntry.current = { ...current, priority: 'urgent' }; - assert.equal(harness.invocationQueue.restoreEntrySnapshotIfUnchanged(current, changedEntry.current), true); - return { threadId, messageId, leaseId: 'lease-carrier-race' }; - }, - }, - }); - apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-carrier-race', '并发期间不能搬走'); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-carrier-race', [message])); + assert.equal(claim.outcome, 'claimed'); const response = await harness.app.inject({ method: 'POST', @@ -416,17 +341,10 @@ describe('F264 Gap F true recall API', () => { }); assert.equal(response.statusCode, 409, response.body); - assert.equal(response.json().code, 'QUEUE_CARRIER_CHANGED'); - assert.deepEqual( - harness.invocationQueue.getEntrySnapshot(THREAD_ID, OWNER_ID, 'entry-carrier-race'), - changedEntry.current, - 'the newer Queue snapshot must remain authoritative', - ); - assert.equal(harness.messageStore.getById(message.id).content, '并发期间不能搬走'); + assert.equal(response.json().code, 'ENTRY_PROCESSING'); + assert.equal(harness.messageStore.getById(message.id).content, '已经出队的正文'); assert.equal(harness.messageStore.getOwnerComposerDraft(OWNER_ID, THREAD_ID), null); - assert.deepEqual(harness.releasedPassages, [ - { threadId: THREAD_ID, messageId: message.id, leaseId: 'lease-carrier-race' }, - ]); + assert.equal(harness.invocationQueue.list(THREAD_ID, OWNER_ID).length, 1); }); it('fails before the canonical CAS and restores Queue when index suppression cannot be prepared', async () => { @@ -438,9 +356,7 @@ describe('F264 Gap F true recall API', () => { }, }); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-index-fail', '必须原样保留'); - const entry = queueEntry('entry-index-fail', [message]); - harness.invocationQueue.restoreDurableEntry(entry); + const { message, entries } = await appendQueued(harness, '必须原样保留'); const response = await harness.app.inject({ method: 'POST', @@ -453,7 +369,13 @@ describe('F264 Gap F true recall API', () => { assert.equal(response.json().code, 'RECALL_INDEX_PREPARE_FAILED'); assert.equal(harness.messageStore.getById(message.id).content, '必须原样保留'); assert.equal(harness.messageStore.getOwnerComposerDraft(OWNER_ID, THREAD_ID), null); - assert.deepEqual(harness.invocationQueue.getEntrySnapshot(THREAD_ID, OWNER_ID, entry.id), entry); + assert.deepEqual( + harness.invocationQueue + .list(THREAD_ID, OWNER_ID) + .map((entry) => entry.id) + .sort(), + entries.map((entry) => entry.id).sort(), + ); assert.deepEqual(harness.releasedPassages, []); }); @@ -466,8 +388,7 @@ describe('F264 Gap F true recall API', () => { }, }); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-finalize-fail', '仍应回填'); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-finalize-fail', [message])); + const { message } = await appendQueued(harness, '仍应回填'); const response = await harness.app.inject({ method: 'POST', @@ -482,48 +403,67 @@ describe('F264 Gap F true recall API', () => { assert.equal(harness.invocationQueue.list(THREAD_ID, OWNER_ID).length, 0); }); - it('removes only the recalled member from a coalesced carrier', async () => { + it('removes an interrupted recall claim from the recalled message on startup', async () => { + const harness = createHarness(); + apps.push(harness.app); + const { message, entries } = await appendQueued(harness, '消息已提交但进程还没提交 Queue'); + const claim = await harness.invocationQueue.claimMessageEntriesForWithdrawal( + THREAD_ID, + OWNER_ID, + message.id, + 1_500, + ); + assert.equal(claim.outcome, 'claimed'); + assert.equal( + harness.messageStore.recallMessageToComposerDraft(message.id, { + ownerUserId: OWNER_ID, + threadId: THREAD_ID, + expectedDraftRevision: 0, + merge: 'replace', + recalledAt: 1_600, + }).kind, + 'recalled', + ); + + const restarted = new InvocationQueue(harness.ledgerStore); + assert.equal(await restarted.hydrateFromLedger(harness.messageStore), 0); + assert.deepEqual(restarted.list(THREAD_ID, OWNER_ID), []); + for (const entry of entries) { + assert.equal(await harness.ledgerStore.get(THREAD_ID, entry.id), null); + } + }); + + it('recalls one message without changing a later independent Queue item', async () => { const harness = createHarness(); apps.push(harness.app); - const first = appendQueued(harness.messageStore, 'entry-merged', '第一条错字'); - const second = appendQueued(harness.messageStore, 'entry-merged', '第二条保留'); - harness.invocationQueue.restoreDurableEntry(queueEntry('entry-merged', [first, second])); + const first = await appendQueued(harness, '第一条错字'); + const second = await appendQueued(harness, '第二条保留'); const response = await harness.app.inject({ method: 'POST', - url: `/api/messages/${first.id}/recall`, + url: `/api/messages/${first.message.id}/recall`, headers: AUTH_HEADERS, payload: { threadId: THREAD_ID, expectedDraftRevision: 0, merge: 'replace' }, }); assert.equal(response.statusCode, 200, response.body); - const remaining = harness.invocationQueue.getEntrySnapshot(THREAD_ID, OWNER_ID, 'entry-merged'); - assert.ok(remaining); - assert.equal(remaining.messageId, second.id); - assert.deepEqual(remaining.mergedMessageIds, []); - assert.equal(remaining.content, '第二条保留'); - assert.deepEqual(harness.finalizedEntries, [], 'a surviving sibling still owns the carrier'); + const remaining = harness.invocationQueue.list(THREAD_ID, OWNER_ID); + assert.deepEqual(remaining.map((entry) => entry.id).sort(), second.entries.map((entry) => entry.id).sort()); + assert.ok( + remaining.every( + (entry) => entry.payload.messageId === second.message.id && entry.payload.content === '第二条保留', + ), + ); + assert.deepEqual( + harness.finalizedEntries, + first.entries.map((entry) => entry.id), + ); }); - it('keeps an exposed recall content-free while returning exact exposure truth', async () => { + it('keeps a pending recall content-free with zero exposure', async () => { const harness = createHarness(); apps.push(harness.app); - const message = appendQueued(harness.messageStore, 'entry-seen', '猫已经读过', { - custody: { - seenByCatIds: ['codex'], - seenInvocationIdByCatId: { codex: 'child-read' }, - bodyExposures: [{ targetCatId: 'codex', invocationId: 'child-read', seenAt: 1_500 }], - }, - }); - harness.invocationQueue.restoreDurableEntry( - queueEntry('entry-seen', [message], { - status: 'processing', - processingStartedAt: 1_400, - queuedSeenByCatIds: ['codex'], - queuedSeenInvocationIdByCatId: { codex: 'child-read' }, - queuedBodyExposures: [{ targetCatId: 'codex', invocationId: 'child-read', seenAt: 1_500 }], - }), - ); + const { message } = await appendQueued(harness, '仍在等待投递', ['codex']); const response = await harness.app.inject({ method: 'POST', @@ -534,10 +474,8 @@ describe('F264 Gap F true recall API', () => { assert.equal(response.statusCode, 200, response.body); const body = response.json(); - assert.equal(body.verdict, 'exposed'); - assert.deepEqual(body.message.recall.exposures, [ - { targetCatId: 'codex', invocationId: 'child-read', seenAt: 1_500 }, - ]); + assert.equal(body.verdict, 'zero_exposure'); + assert.equal(body.message.recall.exposures, undefined); assert.equal(body.message.content, undefined); assert.equal(harness.invocationQueue.list(THREAD_ID, OWNER_ID).length, 0); }); diff --git a/packages/api/test/f272-first-jump-e2e.test.js b/packages/api/test/f272-first-jump-e2e.test.js index ed52105693..b151ffca1e 100644 --- a/packages/api/test/f272-first-jump-e2e.test.js +++ b/packages/api/test/f272-first-jump-e2e.test.js @@ -4,6 +4,7 @@ import { AutoDreamStore } from '../dist/domains/auto-dream/AutoDreamStore.js'; import { PresentLoopService } from '../dist/domains/auto-dream/PresentLoopService.js'; import { ProactiveRelationshipService } from '../dist/domains/auto-dream/ProactiveRelationshipService.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const OWNER = 'owner-a'; const CAT = 'codex-sol'; @@ -146,14 +147,16 @@ describe('F272 Phase A first-jump acceptance story', () => { assert.equal((await messageStore.getByThread(IMPLEMENTATION_THREAD, 20, OWNER)).length, 0); now += 1_000; - const landyReply = await messageStore.append({ - threadId: HOME, - userId: OWNER, - catId: null, - content: '好呀,我看见你先画的草图了。', - mentions: [], - timestamp: now, - }); + const landyReply = await messageStore.append( + canonicalTestMessageInput({ + threadId: HOME, + userId: OWNER, + catId: null, + content: '好呀,我看见你先画的草图了。', + mentions: [], + timestamp: now, + }), + ); now += 1_000; const nextWake = await presentLoop.beginScheduledRun({ ownerUserId: OWNER, diff --git a/packages/api/test/f272-proactive-echo.test.js b/packages/api/test/f272-proactive-echo.test.js index 4a11cd69ff..e7fced861f 100644 --- a/packages/api/test/f272-proactive-echo.test.js +++ b/packages/api/test/f272-proactive-echo.test.js @@ -4,6 +4,7 @@ import { AutoDreamStore } from '../dist/domains/auto-dream/AutoDreamStore.js'; import { PresentLoopService } from '../dist/domains/auto-dream/PresentLoopService.js'; import { ProactiveRelationshipService } from '../dist/domains/auto-dream/ProactiveRelationshipService.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const OWNER = 'owner-a'; const CAT = 'codex-sol'; @@ -76,15 +77,17 @@ describe('F272 natural echo and next-wake memory', () => { } async function appendUserReply(content, replyTo) { - const message = await messageStore.append({ - threadId: HOME, - userId: OWNER, - catId: null, - content, - mentions: [], - timestamp: now, - ...(replyTo ? { replyTo } : {}), - }); + const message = await messageStore.append( + canonicalTestMessageInput({ + threadId: HOME, + userId: OWNER, + catId: null, + content, + mentions: [], + timestamp: now, + ...(replyTo ? { replyTo } : {}), + }), + ); now += 1_000; return message; } diff --git a/packages/api/test/f276-agent-router-authority-wiring.test.js b/packages/api/test/f276-agent-router-authority-wiring.test.js index 0173cdbdb1..56109ac60f 100644 --- a/packages/api/test/f276-agent-router-authority-wiring.test.js +++ b/packages/api/test/f276-agent-router-authority-wiring.test.js @@ -135,18 +135,33 @@ describe('F276/F292 AgentRouter authority wiring', () => { }); it('forwards queue-owned memory carriers through routeExecution', () => { - const source = readFileSync( + const routerSource = readFileSync( new URL('../src/domains/cats/services/agents/routing/AgentRouter.ts', import.meta.url), 'utf8', ); + const helpersSource = readFileSync( + new URL('../src/domains/cats/services/agents/routing/route-helpers.ts', import.meta.url), + 'utf8', + ); + // F117 new contract: routeExecution no longer hand-forwards each carrier key; + // RouteExecutionOptions is derived from RouteOptions (route-helpers.ts) and the + // whole options object crosses the boundary via the strategyInputOptions spread. + assert.ok( + helpersSource.includes('RouteExecutionOptions = Omit<') && helpersSource.includes('RouteOptions,'), + 'RouteExecutionOptions must stay derived from RouteOptions so carrier keys cannot be dropped silently', + ); + assert.ok( + /interface RouteOptions \{[\s\S]*memoryCueOpportunitySeeds\?:/.test(helpersSource), + 'RouteOptions must declare memoryCueOpportunitySeeds', + ); assert.ok( - source.includes('memoryCueOpportunitySeeds: options?.memoryCueOpportunitySeeds'), - 'memory cue seeds must survive routeExecution option assembly', + /interface RouteOptions \{[\s\S]*asrPersonMemoryScenes\?:/.test(helpersSource), + 'RouteOptions must declare asrPersonMemoryScenes', ); assert.ok( - source.includes('asrPersonMemoryScenes: options?.asrPersonMemoryScenes'), - 'ASR person-memory scenes must survive routeExecution option assembly', + routerSource.includes('...strategyInputOptions'), + 'routeExecution must forward the full options object (including memory carriers) into routeOptions', ); }); }); diff --git a/packages/api/test/f280-github-wait-lifecycle.test.js b/packages/api/test/f280-github-wait-lifecycle.test.js index 8654bf77c9..b3a31e0c6e 100644 --- a/packages/api/test/f280-github-wait-lifecycle.test.js +++ b/packages/api/test/f280-github-wait-lifecycle.test.js @@ -86,7 +86,7 @@ describe('F280 GitHub wait lifecycle integration', () => { }); assert.equal(result.kind, 'state_only'); - assert.equal(messageStore.getByThread('thread_1').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 0); assert.equal((await taskStore.get(task.id)).automationState.await.generation, 3); assert.equal((await taskStore.get(task.id)).automationState.review.lastDecisionCursor, 99); }); @@ -536,7 +536,7 @@ describe('F280 GitHub wait lifecycle integration', () => { { taskId: task.id }, ); assert.equal(commented.kind, 'skipped'); - assert.equal(messageStore.getByThread('thread_1').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 0); assert.equal((await taskStore.get(task.id)).automationState.review.lastDecisionCursor, 99); assert.equal((await taskStore.get(task.id)).automationState.ci.lastBucket, 'fail'); @@ -556,7 +556,7 @@ describe('F280 GitHub wait lifecycle integration', () => { assert.equal(newHead.kind, 'notified'); assert.match(newHead.content, /HEAD aaaa111 → bbbb222/); assert.doesNotMatch(newHead.content, /mindfn|UNTRUSTED_REVIEW_BODY/); - assert.equal(messageStore.getByThread('thread_1').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 1); }); it('consumes a generation once and publishes only compact baseline delta plus next step', async () => { @@ -572,7 +572,7 @@ describe('F280 GitHub wait lifecycle integration', () => { assert.equal(first.kind, 'notified'); assert.notEqual(replay.kind, 'notified'); - const messages = messageStore.getByThread('thread_1'); + const messages = messageStore.getByThreadIncludingQueued('thread_1'); assert.equal(messages.length, 1); assert.match(messages[0].content, /HEAD aaaa111 → bbbb222/); assert.match(messages[0].content, /Next: Re-lock the exact HEAD/); @@ -717,8 +717,8 @@ describe('F280 GitHub wait lifecycle integration', () => { assert.equal((await eventLog.read(silent.id)).length, 1); assert.equal((await eventLog.read(silent.id))[0].reason, 'superseded'); assert.equal((await eventLog.read(pending.id)).length, 1); - assert.equal(messageStore.getByThread('thread_silent').length, 0); - assert.equal(messageStore.getByThread('thread_pending').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_silent').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_pending').length, 1); assert.equal((await taskStore.get(pending.id)).automationState.waitOutcome.delivery, 'delivered'); }); @@ -790,9 +790,9 @@ describe('F280 GitHub wait lifecycle integration', () => { assert.deepEqual(await sweep.run(), { recovered: 2 }); - assert.equal(messageStore.getByThread(legacy.threadId).length, 0); + assert.equal(messageStore.getByThreadIncludingQueued(legacy.threadId).length, 0); assert.equal((await taskStore.get(legacy.id)).automationState.waitOutcome.delivery, 'legacy_unfenced'); - assert.equal(messageStore.getByThread(current.threadId).length, 1); + assert.equal(messageStore.getByThreadIncludingQueued(current.threadId).length, 1); assert.equal((await taskStore.get(current.id)).automationState.waitOutcome.delivery, 'delivered'); assert.ok(warnings.some((args) => args.some((value) => String(value).includes(legacy.id)))); }); diff --git a/packages/api/test/f280-issue-wait-contract.test.js b/packages/api/test/f280-issue-wait-contract.test.js index 1fb940471b..f0aaaa7e09 100644 --- a/packages/api/test/f280-issue-wait-contract.test.js +++ b/packages/api/test/f280-issue-wait-contract.test.js @@ -125,7 +125,7 @@ describe('F280 Phase C issue wait lifecycle', () => { }, }); assert.equal(unrelated.kind, 'state_only'); - assert.equal(messageStore.getByThread('thread_issue').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_issue').length, 0); assert.equal((await taskStore.get(task.id)).automationState.issue.lastCommentCursor, 41); const matched = await lifecycle.observe({ @@ -155,7 +155,7 @@ describe('F280 Phase C issue wait lifecycle', () => { assert.match(matched.content, /Issue wait satisfied/); assert.match(matched.content, /issue author issue-author commented \(#42\)/); assert.doesNotMatch(matched.content, /UNTRUSTED EXTERNAL CONTENT/); - assert.deepEqual(messageStore.getByThread('thread_issue')[0].source?.meta?.waitContinuationCarrier, { + assert.deepEqual(messageStore.getByThreadIncludingQueued('thread_issue')[0].source?.meta?.waitContinuationCarrier, { v: 1, waitId: task.id, outcomeId: 'wait:issue:owner/repo#17:g2:matched', @@ -200,7 +200,7 @@ describe('F280 Phase C issue wait lifecycle', () => { assert.deepEqual(result, { kind: 'state_only', reason: 'subject_terminal_without_active_wait' }); assert.equal(terminal.status, 'done'); assert.equal(terminal.automationState.issue.issueState, 'closed'); - assert.equal(messageStore.getByThread('thread_issue_collector').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_issue_collector').length, 0); }); }); diff --git a/packages/api/test/f280-legacy-pr-surface-guard.test.js b/packages/api/test/f280-legacy-pr-surface-guard.test.js index fef9b2be94..53b16860fb 100644 --- a/packages/api/test/f280-legacy-pr-surface-guard.test.js +++ b/packages/api/test/f280-legacy-pr-surface-guard.test.js @@ -25,7 +25,7 @@ const allowedRegions = [ path: 'packages/api/src/domains/cats/services/agents/routing/route-serial.ts', label: 'structured custody stop-gate vocabulary', start: 'export function buildTurnCustodyStopGateRemedialPrompt', - end: '/**\n * F233 Phase B (B2): persist ball.handed', + end: 'function emitBallVoidPass', }, ]; diff --git a/packages/api/test/f295-active-execution-projection.test.js b/packages/api/test/f295-active-execution-projection.test.js index a4ad73d4dd..6e970453df 100644 --- a/packages/api/test/f295-active-execution-projection.test.js +++ b/packages/api/test/f295-active-execution-projection.test.js @@ -9,8 +9,14 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); +const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); +const { InvocationRecordStore } = await import('../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'); +const { InMemoryTurnExecutionStore } = await import( + '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' +); const { queueRoutes } = await import('../dist/routes/queue.js'); const USER_ID = 'user-a'; @@ -104,6 +110,7 @@ function buildDeps() { const turnTerminalCalls = []; const processOwners = []; const managedTasks = [makeManagedCommandTask()]; + const activeRunDispatchers = new Map(); const invocationTracker = { has(threadId, catId) { return executions.has(`${threadId}:${catId}`); @@ -114,10 +121,23 @@ function buildDeps() { getExecutionId(threadId, catId) { return executions.get(`${threadId}:${catId}`)?.executionId; }, + getSlotState(threadId, catId) { + return executions.has(`${threadId}:${catId}`) ? 'active' : 'absent'; + }, + getCanceledSlotIdentity() { + return undefined; + }, getActiveSlots(threadId) { return [...executions.entries()] .filter(([key]) => key.startsWith(`${threadId}:`)) - .map(([key, value]) => ({ catId: key.slice(threadId.length + 1), startedAt: value.startedAt })); + .map(([key, value]) => ({ + catId: key.slice(threadId.length + 1), + startedAt: value.startedAt, + ...(value.activeRun ? { activeRun: value.activeRun } : {}), + })); + }, + getAgentClientActiveRunDispatcher(threadId, catId) { + return activeRunDispatchers.get(`${threadId}:${catId}`); }, cancel(threadId, catId, requestUserId, reason) { cancelCalls.push({ threadId, catId, requestUserId, reason }); @@ -157,6 +177,10 @@ function buildDeps() { }, queueProcessor: { canReleaseSlotForUser: mock.fn(() => true), + hasProcessingSlotReservation: mock.fn((threadId, catId) => + Boolean(invocationQueue.findProcessingByCat(threadId, catId)), + ), + retirePrestartProcessingGroup: mock.fn(async () => 'retired'), processNext: mock.fn(async () => ({ started: false })), isPaused: mock.fn(() => false), getPauseReason: mock.fn(() => undefined), @@ -164,6 +188,14 @@ function buildDeps() { releaseSlot: mock.fn(), releaseThread: mock.fn(), }, + messageStore: { + getByIdempotencyKey: mock.fn(async () => null), + }, + invocationRecordStore: { + get: mock.fn(async () => null), + update: mock.fn(async () => null), + listRunningByThread: mock.fn(async () => []), + }, socketManager: { broadcastAgentMessage: mock.fn(), broadcastToRoom: mock.fn(), @@ -190,6 +222,7 @@ function buildDeps() { _executions: executions, _cancelCalls: cancelCalls, _managedTasks: managedTasks, + _activeRunDispatchers: activeRunDispatchers, _processOwners: processOwners, _processOwnerCancelCalls: processOwnerCancelCalls, _turnTerminalCalls: turnTerminalCalls, @@ -281,7 +314,56 @@ describe('F295 active execution projection', () => { }); }); - it('rejects a stale live cancel target instead of killing its replacement', async () => { + it('projects Append only from the exact request-owned live dispatcher', async () => { + const activeRun = { + threadId: 'thread-a', + targetId: 'kimi', + invocationId: 'turn-a', + responseMessageId: 'response-a', + inputEntryIds: ['entry-a'], + inputMessageIds: ['message-a'], + privateInputEntryIds: [], + startedAt: 100, + }; + deps._executions.set('thread-a:kimi', { executionId: 'inv-a', startedAt: 100, activeRun }); + deps._activeRunDispatchers.set('thread-a:kimi', { + invocationId: 'turn-a', + capabilities: { append: true, steer: true }, + handle: {}, + dispatch: mock.fn(), + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/threads/thread-a/executions/active', + headers: { 'x-cat-cafe-user': USER_ID }, + }); + assert.equal(response.statusCode, 200, response.body); + const execution = response.json().executions.find((item) => item.executionId === 'inv-a'); + + assert.deepEqual(execution.inputCapabilities, { + append: { + kind: 'append', + expectedRun: { targetId: 'kimi', invocationId: 'turn-a', responseMessageId: 'response-a' }, + }, + }); + + deps._activeRunDispatchers.set('thread-a:kimi', { + invocationId: 'replacement-turn', + capabilities: { append: true, steer: true }, + handle: {}, + dispatch: mock.fn(), + }); + const stale = await app.inject({ + method: 'GET', + url: '/api/threads/thread-a/executions/active', + headers: { 'x-cat-cafe-user': USER_ID }, + }); + const staleExecution = stale.json().executions.find((item) => item.executionId === 'inv-a'); + assert.equal(staleExecution.inputCapabilities, undefined); + }); + + it('treats a stale exact Stop as the same per-cat intent and stops the current replacement', async () => { deps._executions.set('thread-a:kimi', { executionId: 'inv-new', startedAt: 400 }); const response = await app.inject({ @@ -291,10 +373,27 @@ describe('F295 active execution projection', () => { payload: { catId: 'kimi' }, }); - assert.equal(response.statusCode, 409); - assert.equal(response.json().code, 'EXECUTION_REPLACED'); + assert.equal(response.statusCode, 200); + assert.equal(response.json().replaced, true); + assert.equal(deps._cancelCalls.length, 1); + assert.equal(deps._executions.has('thread-a:kimi'), false); + }); + + it('treats Stop on a confirmed-dead exact execution as reconciliation, not an error', async () => { + deps._executions.delete('thread-a:kimi'); + + const response = await app.inject({ + method: 'POST', + url: '/api/threads/thread-a/executions/live/inv-a/cancel', + headers: { 'x-cat-cafe-user': USER_ID }, + payload: { catId: 'kimi' }, + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { ok: true, cancelled: false, reconciled: true }); assert.deepEqual(deps._cancelCalls, []); - assert.equal(deps._executions.get('thread-a:kimi').executionId, 'inv-new'); + assert.equal(deps.queueProcessor.releaseSlot.mock.callCount(), 1); + assert.deepEqual(deps.queueProcessor.releaseSlot.mock.calls[0].arguments, ['thread-a', 'kimi']); }); it('keeps a command visible and exactly cancelable after an ordinary message retires only its wake', async () => { @@ -340,6 +439,37 @@ describe('F295 active execution projection', () => { assert.equal(deps._executions.get('thread-b:kimi').executionId, 'inv-b'); }); + it('keeps a canceled Queue execution reservation until its completion drains the next row', async () => { + const queued = deps.invocationQueue.enqueueDurableNow( + canonicalTestQueueInput({ + threadId: 'thread-a', + userId: USER_ID, + kind: 'conversation_input', + content: 'currently executing', + source: 'user', + sourceId: 'f295-cancel-reservation', + targetCats: ['kimi'], + intent: 'execute', + }), + ); + assert.equal(queued.outcome, 'enqueued'); + const processing = await deps.invocationQueue.markProcessingDurable('thread-a', USER_ID, { + entryId: queued.entry.id, + targetCats: ['kimi'], + }); + assert.ok(processing); + + const response = await app.inject({ + method: 'POST', + url: '/api/threads/thread-a/executions/live/inv-a/cancel', + headers: { 'x-cat-cafe-user': USER_ID }, + payload: { catId: 'kimi' }, + }); + + assert.equal(response.statusCode, 200, response.body); + assert.equal(deps.queueProcessor.releaseSlot.mock.calls.length, 0); + }); + it('surfaces a scheduler-owned command as occupancy instead of hiding the busy cat slot', async () => { // Reported downstream: the console filters executions by the viewing user, so a // scheduler round holding the cat slot vanished and Queue looked idle while new @@ -553,7 +683,7 @@ describe('F295 active execution projection', () => { ); }); - it('shows viewer-owned liveness with an unavailable control source as honestly non-cancelable', async () => { + it('keeps Stop available when the server must reconcile an unavailable control source', async () => { deps.invocationTracker.getExecutionId = () => undefined; const projection = await app.inject({ @@ -565,10 +695,7 @@ describe('F295 active execution projection', () => { .json() .executions.find((item) => item.kind === 'live_invocation' && item.threadId === 'thread-a'); assert.match(execution.executionId, /^unresolved:/); - assert.deepEqual(execution.cancelability, { - state: 'not_cancelable', - reason: 'control_plane_unavailable', - }); + assert.equal(execution.cancelability.state, 'cancelable'); }); it('treats a process that exits between projection and signal as an idempotent successful cancel', async () => { @@ -644,7 +771,7 @@ describe('F295 active execution projection', () => { headers: { 'x-cat-cafe-user': USER_ID }, }); - assert.equal(projection.statusCode, 200); + assert.equal(projection.statusCode, 200, projection.body); assert.ok( projection.json().executions.some((execution) => execution.executionId === 'inv-sparse-process'), 'durable process ownership must nominate its thread after the tracker handle is gone', @@ -678,7 +805,7 @@ describe('F295 active execution projection', () => { headers: { 'x-cat-cafe-user': USER_ID }, }); - assert.equal(projection.statusCode, 200); + assert.equal(projection.statusCode, 200, projection.body); assert.deepEqual( projection .json() @@ -695,7 +822,208 @@ describe('F295 active execution projection', () => { ); }); - it('keeps known process-owner liveness visible but non-cancelable while owner truth is incomplete', async () => { + it('read-repairs a stale running record only after complete owner absence is proven', async () => { + await app.close(); + deps._executions.clear(); + const record = { + id: 'inv-stale-record', + threadId: 'thread-a', + userId: USER_ID, + userMessageId: null, + targetCats: ['kimi'], + intent: 'execute', + status: 'running', + idempotencyKey: 'stale-running-record', + actionLeaseCarrier: { kind: 'none' }, + createdAt: Date.now() - 60_000, + updatedAt: Date.now() - 60_000, + }; + deps.draftStore = { getByThread: mock.fn(async () => []) }; + deps.invocationRecordStore = { + listRunningByThread: mock.fn(async () => (record.status === 'running' ? [record] : [])), + get: mock.fn(async (id) => (id === record.id ? record : null)), + update: mock.fn(async (id, update) => { + if (id !== record.id || (update.expectedStatus && update.expectedStatus !== record.status)) return null; + Object.assign(record, update, { updatedAt: Date.now() }); + return record; + }), + }; + deps.cliExecutionOwnerService.listLive.mock.mockImplementation(async () => ({ owners: [], complete: true })); + app = Fastify(); + await app.register(queueRoutes, deps); + await app.ready(); + + const projection = await app.inject({ + method: 'GET', + url: '/api/threads/thread-a/executions/active', + headers: { 'x-cat-cafe-user': USER_ID }, + }); + + assert.equal(projection.statusCode, 200, projection.body); + assert.equal(record.status, 'failed'); + assert.equal(record.error, 'execution_owner_lost'); + assert.equal( + projection.json().executions.some((execution) => execution.executionId === record.id), + false, + 'a record terminalized by read-repair must not remain projected as running', + ); + }); + + it('does not rewrite a just-canceled tracker tombstone as execution_owner_lost', async () => { + await app.close(); + deps._executions.clear(); + const record = { + id: 'inv-cancel-teardown', + threadId: 'thread-a', + userId: USER_ID, + userMessageId: null, + targetCats: ['kimi'], + intent: 'execute', + status: 'running', + idempotencyKey: 'cancel-teardown-record', + actionLeaseCarrier: { kind: 'none' }, + createdAt: Date.now() - 60_000, + updatedAt: Date.now() - 60_000, + }; + deps.invocationTracker.getSlotState = mock.fn(() => 'canceled'); + deps.invocationTracker.getCanceledSlotIdentity = mock.fn(() => ({ + executionId: record.id, + userId: USER_ID, + })); + deps.draftStore = { getByThread: mock.fn(async () => []) }; + deps.invocationRecordStore = { + listRunningByThread: mock.fn(async () => [record]), + get: mock.fn(async (id) => (id === record.id ? record : null)), + update: mock.fn(async (_id, update) => { + Object.assign(record, update, { updatedAt: Date.now() }); + return record; + }), + }; + deps.cliExecutionOwnerService.listLive.mock.mockImplementation(async () => ({ owners: [], complete: true })); + app = Fastify(); + await app.register(queueRoutes, deps); + await app.ready(); + + const projection = await app.inject({ + method: 'GET', + url: '/api/threads/thread-a/executions/active', + headers: { 'x-cat-cafe-user': USER_ID }, + }); + + assert.equal(projection.statusCode, 200, projection.body); + assert.equal(record.status, 'running', 'provider teardown still owns the canonical canceled terminal'); + assert.equal(deps.invocationRecordStore.update.mock.calls.length, 0); + }); + + it('protects only the exact canceled execution while repairing an older orphan for the same cat', async () => { + await app.close(); + deps._executions.clear(); + const records = [ + { + id: 'inv-cancel-current', + threadId: 'thread-a', + userId: USER_ID, + userMessageId: null, + targetCats: ['kimi'], + intent: 'execute', + status: 'running', + idempotencyKey: 'cancel-current-record', + actionLeaseCarrier: { kind: 'none' }, + createdAt: Date.now() - 60_000, + updatedAt: Date.now() - 60_000, + }, + { + id: 'inv-orphan-older', + threadId: 'thread-a', + userId: USER_ID, + userMessageId: null, + targetCats: ['kimi'], + intent: 'execute', + status: 'running', + idempotencyKey: 'orphan-older-record', + actionLeaseCarrier: { kind: 'none' }, + createdAt: Date.now() - 120_000, + updatedAt: Date.now() - 120_000, + }, + ]; + deps.invocationTracker.getSlotState = mock.fn(() => 'canceled'); + deps.invocationTracker.getCanceledSlotIdentity = mock.fn(() => ({ + executionId: 'inv-cancel-current', + userId: USER_ID, + })); + deps.draftStore = { getByThread: mock.fn(async () => []) }; + deps.invocationRecordStore = { + listRunningByThread: mock.fn(async () => records.filter((record) => record.status === 'running')), + get: mock.fn(async (id) => records.find((record) => record.id === id) ?? null), + update: mock.fn(async (id, update) => { + const record = records.find((candidate) => candidate.id === id); + if (!record || (update.expectedStatus && update.expectedStatus !== record.status)) return null; + Object.assign(record, update, { updatedAt: Date.now() }); + return record; + }), + }; + deps.cliExecutionOwnerService.listLive.mock.mockImplementation(async () => ({ owners: [], complete: true })); + app = Fastify(); + await app.register(queueRoutes, deps); + await app.ready(); + + const projection = await app.inject({ + method: 'GET', + url: '/api/threads/thread-a/executions/active', + headers: { 'x-cat-cafe-user': USER_ID }, + }); + + assert.equal(projection.statusCode, 200, projection.body); + assert.equal(records[0].status, 'running', 'the exact canceled execution remains owned by provider teardown'); + assert.equal(records[1].status, 'failed', 'an unrelated orphan must not inherit the current cancel fence'); + assert.equal(records[1].error, 'execution_owner_lost'); + assert.equal(deps.invocationRecordStore.update.mock.calls.length, 1); + }); + + it('never mutates a stale running record while process-owner truth is incomplete', async () => { + await app.close(); + deps._executions.clear(); + const record = { + id: 'inv-stale-unverified', + threadId: 'thread-a', + userId: USER_ID, + userMessageId: null, + targetCats: ['kimi'], + intent: 'execute', + status: 'running', + idempotencyKey: 'stale-unverified-record', + actionLeaseCarrier: { kind: 'none' }, + createdAt: Date.now() - 60_000, + updatedAt: Date.now() - 60_000, + }; + const update = mock.fn(async () => null); + deps.draftStore = { getByThread: mock.fn(async () => []) }; + deps.invocationRecordStore = { + listRunningByThread: mock.fn(async () => [record]), + get: mock.fn(async (id) => (id === record.id ? record : null)), + update, + }; + deps.cliExecutionOwnerService.listLive.mock.mockImplementation(async () => ({ owners: [], complete: false })); + app = Fastify(); + await app.register(queueRoutes, deps); + await app.ready(); + + const projection = await app.inject({ + method: 'GET', + url: '/api/threads/thread-a/executions/active', + headers: { 'x-cat-cafe-user': USER_ID }, + }); + + assert.equal(projection.statusCode, 200, projection.body); + assert.equal(record.status, 'running'); + assert.equal(update.mock.callCount(), 0); + assert.ok( + projection.json().executions.some((execution) => execution.executionId === record.id), + 'unknown process truth must preserve the running projection until Stop performs bounded reconciliation', + ); + }); + + it('keeps known process-owner liveness visible and stoppable while owner truth is incomplete', async () => { const owner = { executionId: 'inv-owner-incomplete', invocationId: 'turn-owner-incomplete', @@ -717,13 +1045,20 @@ describe('F295 active execution projection', () => { const execution = projection.json().executions.find((candidate) => candidate.executionId === owner.executionId); assert.ok(execution, 'known liveness remains visible during control-plane degradation'); - assert.deepEqual(execution.cancelability, { - state: 'not_cancelable', - reason: 'control_plane_unavailable', + assert.equal(execution.cancelability.state, 'cancelable'); + + const cancel = await app.inject({ + method: 'POST', + url: `/api/threads/thread-a/executions/live/${owner.executionId}/cancel`, + headers: { 'x-cat-cafe-user': USER_ID }, + payload: { catId: owner.catId }, }); + assert.equal(cancel.statusCode, 200); + assert.equal(cancel.json().reconciled, true); + assert.deepEqual(deps._cancelCalls, []); }); - it('reports control-plane degradation instead of claiming an exact process execution is inactive', async () => { + it('terminalizes a hidden exact execution after bounded control-plane degradation', async () => { await app.close(); deps._executions.clear(); deps.cliExecutionOwnerService.listLive.mock.mockImplementation(async () => ({ owners: [], complete: false })); @@ -738,11 +1073,12 @@ describe('F295 active execution projection', () => { payload: { catId: 'opus5' }, }); - assert.equal(cancel.statusCode, 503); - assert.equal(cancel.json().code, 'EXECUTION_CONTROL_UNAVAILABLE'); + assert.equal(cancel.statusCode, 200); + assert.equal(cancel.json().reconciled, true); + assert.equal(deps.cliExecutionOwnerService.listLive.mock.callCount(), 3); }); - it('reports a degraded exact termination scan separately from process inactivity', async () => { + it('terminalizes failed after a degraded exact termination scan', async () => { deps._processOwners.push({ executionId: 'inv-terminate-degraded', invocationId: 'turn-terminate-degraded', @@ -751,7 +1087,7 @@ describe('F295 active execution projection', () => { userId: USER_ID, startedAt: 476, }); - deps.cliExecutionOwnerService.terminateExact.mock.mockImplementationOnce(async () => ({ + deps.cliExecutionOwnerService.terminateExact.mock.mockImplementation(async () => ({ matched: 0, signaled: 0, complete: false, @@ -764,9 +1100,236 @@ describe('F295 active execution projection', () => { payload: { catId: 'opus5' }, }); - assert.equal(cancel.statusCode, 503); - assert.equal(cancel.json().code, 'EXECUTION_CONTROL_UNAVAILABLE'); - assert.deepEqual(deps._turnTerminalCalls, []); + assert.equal(cancel.statusCode, 200); + assert.equal(cancel.json().reconciled, true); + assert.equal(deps.cliExecutionOwnerService.terminateExact.mock.callCount(), 3); + assert.equal(deps._turnTerminalCalls.at(-1)?.terminal.status, 'failed'); + assert.equal(deps._turnTerminalCalls.at(-1)?.terminal.terminalReason, 'control_plane_unavailable'); + }); + + it('persists control-plane failure through the ordinary response and input settlement chain', async () => { + await app.close(); + deps._executions.clear(); + const messageStore = new MessageStore(); + const recordStore = new InvocationRecordStore(); + const turnStore = new InMemoryTurnExecutionStore(); + const source = messageStore.append( + canonicalTestMessageInput({ + userId: USER_ID, + threadId: 'thread-a', + catId: 'opus5', + content: '@kimi continue', + mentions: ['kimi'], + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: '100:source', + invocationId: 'source-turn', + targetId: 'opus5', + inputEntryIds: ['source-entry'], + inputMessageIds: ['source-input'], + status: 'completed', + startedAt: 90, + completedAt: 100, + dispatchRefs: [], + }, + }), + ); + const parent = recordStore.create({ + threadId: 'thread-a', + userId: USER_ID, + targetCats: ['kimi'], + intent: 'execute', + idempotencyKey: 'control-plane-failure-parent', + actionLeaseCarrier: { kind: 'none' }, + }); + recordStore.update(parent.invocationId, { status: 'running' }); + const childInvocationId = 'turn-control-plane-failed'; + turnStore.createRunning({ + invocationId: childInvocationId, + parentInvocationId: parent.invocationId, + threadId: 'thread-a', + userId: USER_ID, + catId: 'kimi', + executionKind: 'ordinary', + startedAt: 110, + }); + const response = messageStore.append( + canonicalTestMessageInput({ + userId: USER_ID, + threadId: 'thread-a', + catId: 'kimi', + content: '已完成一部分分析。', + mentions: [], + timestamp: 110, + replyTo: source.id, + idempotencyKey: `message-lifecycle-response:${childInvocationId}`, + lifecycle: { + kind: 'response', + orderKey: '110:response', + invocationId: childInvocationId, + targetId: 'kimi', + inputEntryIds: ['child-entry'], + inputMessageIds: [source.id], + status: 'processing', + startedAt: 110, + }, + }), + ); + assert.equal( + messageStore.advanceLifecycleInputDispatch(source.id, { + orderKey: '100:source', + from: { kind: 'agent', catId: 'opus5' }, + targetId: 'kimi', + phase: 'dispatched', + statusMessageId: response.id, + }).kind, + 'applied', + ); + deps.messageStore = messageStore; + deps.invocationRecordStore = recordStore; + deps.turnExecutionStore = turnStore; + deps.cliExecutionOwnerService.listLive.mock.mockImplementation(async () => ({ owners: [], complete: false })); + app = Fastify(); + await app.register(queueRoutes, deps); + await app.ready(); + + const stopped = await app.inject({ + method: 'POST', + url: `/api/threads/thread-a/executions/live/${parent.invocationId}/cancel`, + headers: { 'x-cat-cafe-user': USER_ID }, + payload: { catId: 'kimi' }, + }); + + assert.equal(stopped.statusCode, 200); + assert.equal(stopped.json().reconciled, true); + assert.equal(messageStore.getById(response.id).lifecycle.status, 'failed'); + assert.equal(messageStore.getById(response.id).lifecycle.reason, 'control_plane_unavailable'); + assert.match(messageStore.getById(response.id).content, /已完成一部分分析/); + assert.match(messageStore.getById(response.id).content, /执行控制面不可用/); + assert.doesNotMatch(messageStore.getById(response.id).content, /@kimi 处理失败/); + assert.doesNotMatch(messageStore.getById(response.id).content, /control_plane_unavailable/); + assert.doesNotMatch(messageStore.getById(response.id).content, /来源消息:/); + assert.deepEqual(messageStore.getById(source.id).lifecycle.dispatchRefs, [ + { targetId: 'kimi', phase: 'settled', statusMessageId: response.id }, + ]); + assert.equal(recordStore.get(parent.invocationId).status, 'failed'); + assert.equal(turnStore.get(childInvocationId).status, 'failed'); + }); + + it('fails only the uncontrollable child while a sibling remains live and cancelable', async () => { + await app.close(); + deps._executions.clear(); + const messageStore = new MessageStore(); + const recordStore = new InvocationRecordStore(); + const turnStore = new InMemoryTurnExecutionStore(); + const source = messageStore.append( + canonicalTestMessageInput({ + userId: USER_ID, + threadId: 'thread-a', + catId: 'opus5', + content: '@opus5 @kimi compare', + mentions: ['opus5', 'kimi'], + timestamp: 200, + lifecycle: { + kind: 'input', + orderKey: '200:source', + dispatchRefs: [], + }, + }), + ); + const parent = recordStore.create({ + threadId: 'thread-a', + userId: USER_ID, + targetCats: ['opus5', 'kimi'], + intent: 'execute', + idempotencyKey: 'sibling-control-plane-parent', + actionLeaseCarrier: { kind: 'none' }, + }); + recordStore.update(parent.invocationId, { status: 'running' }); + const failedChildId = 'turn-sibling-uncontrollable'; + turnStore.createRunning({ + invocationId: failedChildId, + parentInvocationId: parent.invocationId, + threadId: 'thread-a', + userId: USER_ID, + catId: 'opus5', + executionKind: 'ordinary', + startedAt: 210, + }); + const response = messageStore.append( + canonicalTestMessageInput({ + userId: USER_ID, + threadId: 'thread-a', + catId: 'opus5', + content: '', + mentions: [], + timestamp: 210, + replyTo: source.id, + idempotencyKey: `message-lifecycle-response:${failedChildId}`, + lifecycle: { + kind: 'response', + orderKey: '210:response', + invocationId: failedChildId, + targetId: 'opus5', + inputEntryIds: ['entry-opus'], + inputMessageIds: [source.id], + status: 'processing', + startedAt: 210, + }, + }), + ); + messageStore.advanceLifecycleInputDispatch(source.id, { + orderKey: '200:source', + from: { kind: 'user', userId: USER_ID }, + targetId: 'opus5', + phase: 'dispatched', + statusMessageId: response.id, + }); + deps._executions.set('thread-a:kimi', { + executionId: parent.invocationId, + startedAt: 210, + }); + deps.messageStore = messageStore; + deps.invocationRecordStore = recordStore; + deps.turnExecutionStore = turnStore; + deps.cliExecutionOwnerService.listLive.mock.mockImplementation(async () => ({ owners: [], complete: false })); + app = Fastify(); + await app.register(queueRoutes, deps); + await app.ready(); + + const failed = await app.inject({ + method: 'POST', + url: `/api/threads/thread-a/executions/live/${parent.invocationId}/cancel`, + headers: { 'x-cat-cafe-user': USER_ID }, + payload: { catId: 'opus5' }, + }); + + assert.equal(failed.statusCode, 200); + assert.equal(failed.json().reconciled, true); + assert.equal(turnStore.get(failedChildId).status, 'failed'); + assert.equal(messageStore.getById(response.id).lifecycle.status, 'failed'); + assert.equal(recordStore.get(parent.invocationId).status, 'running'); + + const projection = await app.inject({ + method: 'GET', + url: '/api/threads/thread-a/executions/active', + headers: { 'x-cat-cafe-user': USER_ID }, + }); + assert.equal(projection.statusCode, 200, projection.body); + const sibling = projection + .json() + .executions.find((execution) => execution.catId === 'kimi' && execution.executionId === parent.invocationId); + assert.equal(sibling?.cancelability.state, 'cancelable'); + + const stoppedSibling = await app.inject({ + method: 'POST', + url: `/api/threads/thread-a/executions/live/${parent.invocationId}/cancel`, + headers: { 'x-cat-cafe-user': USER_ID }, + payload: { catId: 'kimi' }, + }); + assert.equal(stoppedSibling.statusCode, 200); + assert.equal(stoppedSibling.json().cancelled, true); }); it('shows but never leaks or cancels a scheduler process on an indexed system thread', async () => { diff --git a/packages/api/test/f296-a1-recall-pointer.test.js b/packages/api/test/f296-a1-recall-pointer.test.js index 12fd1c0cd3..9229b0f92e 100644 --- a/packages/api/test/f296-a1-recall-pointer.test.js +++ b/packages/api/test/f296-a1-recall-pointer.test.js @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { assembleIncrementalContext: assembleRaw } = await import( '../dist/domains/cats/services/agents/routing/route-helpers.js' @@ -20,14 +21,14 @@ const CANDIDATE_TITLE = 'F091 Secret Sauce Distillation'; const CANDIDATE_SNIPPET = 'Phase C shipped the distillation queue with a nightly rebuild job'; function mockMsg(overrides) { - return { + return canonicalTestMessageInput({ threadId: overrides.threadId ?? 'thread-1', userId: overrides.userId ?? 'user-1', catId: overrides.catId ?? null, content: overrides.content ?? 'test message', mentions: [], timestamp: overrides.timestamp ?? Date.now(), - }; + }); } function seedMessages(messageStore, count) { diff --git a/packages/api/test/f296-a2-open-questions.test.js b/packages/api/test/f296-a2-open-questions.test.js index 7c3d075e6b..199a7530fb 100644 --- a/packages/api/test/f296-a2-open-questions.test.js +++ b/packages/api/test/f296-a2-open-questions.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { assembleIncrementalContext: assembleRaw } = await import( '../dist/domains/cats/services/agents/routing/route-helpers.js' @@ -22,14 +23,14 @@ const CLOSED_QUESTION = '要不要把 delivery cursor 拆成两个 store?(PR # const OPEN_LOOKING_QUESTION = '谁来接 Phase C 的 provider handshake?'; function mockMsg(overrides) { - return { + return canonicalTestMessageInput({ threadId: 'thread-1', userId: 'user-1', catId: null, content: overrides.content ?? 'test message', mentions: [], timestamp: overrides.timestamp ?? Date.now(), - }; + }); } function seedMessages(messageStore, count) { diff --git a/packages/api/test/f296-a4-sparse-prompt-acceptance.test.js b/packages/api/test/f296-a4-sparse-prompt-acceptance.test.js index 5af125ac57..738fddb6e4 100644 --- a/packages/api/test/f296-a4-sparse-prompt-acceptance.test.js +++ b/packages/api/test/f296-a4-sparse-prompt-acceptance.test.js @@ -5,6 +5,7 @@ // asserts that nothing new filled the space back in. import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { DeliveryCursorStore } = await import('../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'); @@ -127,25 +128,29 @@ function createStaleThenRecoveringService(catId, captured, capability = CODEX_EX function seedColdThread(messageStore, count = 60) { const baseTs = Date.now() - (count + 1) * 60_000; for (let i = 0; i < count; i++) { - messageStore.append({ - threadId: 'thread-a4', - userId: 'user-1', - catId: null, - content: `message ${i} about the delivery cursor contract`, - mentions: [], - timestamp: baseTs + i * 60_000, - }); + messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-a4', + userId: 'user-1', + catId: null, + content: `message ${i} about the delivery cursor contract`, + mentions: [], + timestamp: baseTs + i * 60_000, + }), + ); } // The trigger message must exist in the store: incremental (cold-window) mode // only engages when the route knows which message is the current one. - return messageStore.append({ - threadId: 'thread-a4', - userId: 'user-1', - catId: null, - content: '@opus 看看这个', - mentions: ['opus'], - timestamp: baseTs + count * 60_000, - }); + return messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-a4', + userId: 'user-1', + catId: null, + content: '@opus 看看这个', + mentions: ['opus'], + timestamp: baseTs + count * 60_000, + }), + ); } function createRouteDeps(catIds, captured, capability = CODEX_EXEC, messageCount = 60) { diff --git a/packages/api/test/f296-b3b1-context-projection.test.js b/packages/api/test/f296-b3b1-context-projection.test.js index 6ca927ad86..de93d13fb3 100644 --- a/packages/api/test/f296-b3b1-context-projection.test.js +++ b/packages/api/test/f296-b3b1-context-projection.test.js @@ -1,6 +1,7 @@ // @ts-check import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { assembleIncrementalContext, shouldPersistContextBriefing } = await import( '../dist/domains/cats/services/agents/routing/route-helpers.js' @@ -13,14 +14,16 @@ const HISTORY_BUDGET = 500_000; function seedMessages(messageStore, count) { const baseTs = Date.now() - count * 60_000; for (let index = 0; index < count; index += 1) { - messageStore.append({ - threadId: 'thread-b3b1', - userId: 'user-1', - catId: null, - content: `message ${index} about redis deployment continuity`, - mentions: [], - timestamp: baseTs + index * 60_000, - }); + messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-b3b1', + userId: 'user-1', + catId: null, + content: `message ${index} about redis deployment continuity`, + mentions: [], + timestamp: baseTs + index * 60_000, + }), + ); } } diff --git a/packages/api/test/f296-b3b3-post-compact-projector.test.js b/packages/api/test/f296-b3b3-post-compact-projector.test.js index 97d6b6f4f6..4c7d9be00e 100644 --- a/packages/api/test/f296-b3b3-post-compact-projector.test.js +++ b/packages/api/test/f296-b3b3-post-compact-projector.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { createPostCompactContextProjector } = await import( '../dist/domains/cats/services/agents/routing/post-compact-context-projector.js' @@ -12,22 +13,26 @@ describe('F296 B3b-3: post-compact projection reuses the cold packet without rep test('only the unread tail is projected and both durable cursors remain unchanged', async () => { const messageStore = new MessageStore(); const deliveryCursorStore = new DeliveryCursorStore(); - const old = messageStore.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: null, - content: 'READ-HISTORY-MUST-NOT-REPLAY', - mentions: [], - timestamp: 1_000, - }); - const unread = messageStore.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: null, - content: 'UNREAD-TAIL-MUST-RETURN', - mentions: [], - timestamp: 2_000, - }); + const old = messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-1', + userId: 'user-1', + catId: null, + content: 'READ-HISTORY-MUST-NOT-REPLAY', + mentions: [], + timestamp: 1_000, + }), + ); + const unread = messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-1', + userId: 'user-1', + catId: null, + content: 'UNREAD-TAIL-MUST-RETURN', + mentions: [], + timestamp: 2_000, + }), + ); const boundary = cursorFor(old); await deliveryCursorStore.ackCursor('user-1', 'opus', 'thread-1', boundary); await deliveryCursorStore.ackSeenCursor('user-1', 'opus', 'thread-1', boundary); diff --git a/packages/api/test/f296-b3b3-session-hook-integration.test.js b/packages/api/test/f296-b3b3-session-hook-integration.test.js index d64a39a6e2..0b1a6be661 100644 --- a/packages/api/test/f296-b3b3-session-hook-integration.test.js +++ b/packages/api/test/f296-b3b3-session-hook-integration.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { ContextEpochOwner, contextEpochScopeKey } = await import( '../dist/domains/cats/services/session/ContextEpochOwner.js' @@ -97,22 +98,26 @@ describe('F296 B3b-3: authenticated PreCompact → epoch → post-compact packet const messageStore = new MessageStore(); const deliveryCursorStore = new DeliveryCursorStore(); - const alreadyRead = messageStore.append({ - threadId: SCOPE.threadId, - userId: SCOPE.userId, - catId: null, - content: 'READ-HISTORY-MUST-NOT-REPLAY', - mentions: [], - timestamp: 1_000, - }); - messageStore.append({ - threadId: SCOPE.threadId, - userId: SCOPE.userId, - catId: null, - content: 'UNREAD-TAIL-MUST-RETURN', - mentions: [], - timestamp: 2_000, - }); + const alreadyRead = messageStore.append( + canonicalTestMessageInput({ + threadId: SCOPE.threadId, + userId: SCOPE.userId, + catId: null, + content: 'READ-HISTORY-MUST-NOT-REPLAY', + mentions: [], + timestamp: 1_000, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + threadId: SCOPE.threadId, + userId: SCOPE.userId, + catId: null, + content: 'UNREAD-TAIL-MUST-RETURN', + mentions: [], + timestamp: 2_000, + }), + ); const deliveryBoundary = cursorFor(alreadyRead); await deliveryCursorStore.ackCursor(SCOPE.userId, SCOPE.catId, SCOPE.threadId, deliveryBoundary); await deliveryCursorStore.ackSeenCursor(SCOPE.userId, SCOPE.catId, SCOPE.threadId, deliveryBoundary); diff --git a/packages/api/test/f296-b3b4-bootstrap-briefing-convergence.test.js b/packages/api/test/f296-b3b4-bootstrap-briefing-convergence.test.js index 986db197e0..cb88a2d5fb 100644 --- a/packages/api/test/f296-b3b4-bootstrap-briefing-convergence.test.js +++ b/packages/api/test/f296-b3b4-bootstrap-briefing-convergence.test.js @@ -1,6 +1,7 @@ // @ts-check import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { buildSessionBootstrap } = await import('../dist/domains/cats/services/session/SessionBootstrap.js'); const { buildBriefingMessage, formatContextBriefing } = await import( @@ -215,23 +216,27 @@ const CODEX_EXEC = { function seedRouteMessages(messageStore) { const baseTs = Date.now() - 40 * 60_000; for (let index = 0; index < 30; index += 1) { - messageStore.append({ + messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-b3b4', + userId: 'user-1', + catId: null, + content: `message ${index} about presentation convergence`, + mentions: [], + timestamp: baseTs + index * 60_000, + }), + ); + } + return messageStore.append( + canonicalTestMessageInput({ threadId: 'thread-b3b4', userId: 'user-1', catId: null, - content: `message ${index} about presentation convergence`, - mentions: [], - timestamp: baseTs + index * 60_000, - }); - } - return messageStore.append({ - threadId: 'thread-b3b4', - userId: 'user-1', - catId: null, - content: '@opus continue', - mentions: ['opus'], - timestamp: baseTs + 31 * 60_000, - }); + content: '@opus continue', + mentions: ['opus'], + timestamp: baseTs + 31 * 60_000, + }), + ); } function routeThreadStore() { diff --git a/packages/api/test/f296-b4c-alpha-uat-dynamic-classification.test.js b/packages/api/test/f296-b4c-alpha-uat-dynamic-classification.test.js index 255e6107dc..e2408e1d84 100644 --- a/packages/api/test/f296-b4c-alpha-uat-dynamic-classification.test.js +++ b/packages/api/test/f296-b4c-alpha-uat-dynamic-classification.test.js @@ -22,7 +22,7 @@ test('keeps dynamic presentation unsupported when the cold producer trace is una if (url.pathname === '/ready') return Response.json({ status: 'ready' }); if (url.pathname === '/api/cats') { return Response.json({ - cats: [{ id: 'codex', clientId: 'openai', codexCarrier: { effective: 'app_server' } }], + cats: [{ id: 'codex', clientId: 'openai', carrier: 'app_server' }], }); } if (url.pathname === '/api/threads') return Response.json({ id: 'canary-thread' }); diff --git a/packages/api/test/f296-b4c-alpha-uat-runner-guards.test.js b/packages/api/test/f296-b4c-alpha-uat-runner-guards.test.js index 814e7627ed..7b237b7696 100644 --- a/packages/api/test/f296-b4c-alpha-uat-runner-guards.test.js +++ b/packages/api/test/f296-b4c-alpha-uat-runner-guards.test.js @@ -36,7 +36,7 @@ describe('F296 B4c Alpha UAT runner child and session guards', () => { if (path === '/ready') return Response.json({ status: 'ready' }); if (path === '/api/cats') { return Response.json({ - cats: [{ id: 'codex', clientId: 'openai', codexCarrier: { effective: 'app_server' } }], + cats: [{ id: 'codex', clientId: 'openai', carrier: 'app_server' }], }); } if (path === '/api/telemetry/metrics') { diff --git a/packages/api/test/f296-b4c-alpha-uat-runner.test.js b/packages/api/test/f296-b4c-alpha-uat-runner.test.js index 1f61b9eb13..8ef1326681 100644 --- a/packages/api/test/f296-b4c-alpha-uat-runner.test.js +++ b/packages/api/test/f296-b4c-alpha-uat-runner.test.js @@ -135,7 +135,7 @@ describe('F296 B4c Alpha UAT runner red contracts', () => { expectedRevision: revision, health: { deploymentRevision: revision }, readiness: { status: 'ready' }, - cats: { cats: [{ id: 'codex', clientId: 'openai', codexCarrier: { effective: 'app_server' } }] }, + cats: { cats: [{ id: 'codex', clientId: 'openai', carrier: 'app_server' }] }, catId: 'codex', }; assert.doesNotThrow(() => assertAlphaSnapshot(base)); @@ -148,7 +148,7 @@ describe('F296 B4c Alpha UAT runner red contracts', () => { () => assertAlphaSnapshot({ ...base, - cats: { cats: [{ id: 'codex', clientId: 'openai', codexCarrier: { effective: 'exec_json' } }] }, + cats: { cats: [{ id: 'codex', clientId: 'openai', carrier: 'cli' }] }, }), 'canary_not_app_server', ); @@ -198,7 +198,7 @@ describe('F296 B4c Alpha UAT runner red contracts', () => { if (url.pathname === '/ready') return Response.json({ status: 'ready' }); if (url.pathname === '/api/cats') { return Response.json({ - cats: [{ id: 'codex', clientId: 'openai', codexCarrier: { effective: 'app_server' } }], + cats: [{ id: 'codex', clientId: 'openai', carrier: 'app_server' }], }); } if (url.pathname === '/api/threads') return Response.json({ id: 'canary-thread' }); diff --git a/packages/api/test/f296-oversized-rollout-notice-persistence-redis.test.js b/packages/api/test/f296-oversized-rollout-notice-persistence-redis.test.js index 52cb66ffa7..9d9ad172cf 100644 --- a/packages/api/test/f296-oversized-rollout-notice-persistence-redis.test.js +++ b/packages/api/test/f296-oversized-rollout-notice-persistence-redis.test.js @@ -22,22 +22,19 @@ describe('F296 oversized rollover Redis projection', { skip: redisIsolationSkipR let redis; let firstStore; let secondStore; - let MessageKeys; let persistUserFacingSystemInfoNotices; before(async () => { assertRedisIsolationOrThrow(REDIS_URL, 'F296 oversized rollover Redis projection'); - const [{ createRedisClient }, storeModule, keysModule, persistenceModule] = await Promise.all([ + const [{ createRedisClient }, storeModule, persistenceModule] = await Promise.all([ import('@cat-cafe/shared/utils'), import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'), - import('../dist/domains/cats/services/stores/redis-keys/message-keys.js'), import('../dist/domains/cats/services/agents/routing/persist-system-info-warnings.js'), ]); redis = createRedisClient({ url: REDIS_URL }); await redis.ping(); firstStore = new storeModule.RedisMessageStore(redis, { ttlSeconds: 0 }); secondStore = new storeModule.RedisMessageStore(redis, { ttlSeconds: 0 }); - MessageKeys = keysModule.MessageKeys; persistUserFacingSystemInfoNotices = persistenceModule.persistUserFacingSystemInfoNotices; }); @@ -51,7 +48,7 @@ describe('F296 oversized rollover Redis projection', { skip: redisIsolationSkipR await redis.quit(); }); - it('deduplicates across store instances and keeps lifecycle messages plus claims at TTL=0', async () => { + it('keeps rollover diagnostics out of History across store instances', async () => { const pending = lifecycle('pending'); await Promise.all([ persistUserFacingSystemInfoNotices({ @@ -77,25 +74,6 @@ describe('F296 oversized rollover Redis projection', { skip: redisIsolationSkipR const messages = (await firstStore.getByThread('thread-redis-owner')).filter( (message) => message.source?.connector === 'session-rollover-lifecycle', ); - assert.equal(messages.length, 2); - assert.deepEqual( - messages.map((message) => message.source.meta.sessionRollover.status), - ['pending', 'succeeded'], - ); - for (const message of messages) { - assert.equal(await redis.ttl(MessageKeys.detail(message.id)), -1); - } - for (const status of ['pending', 'succeeded']) { - assert.equal( - await redis.ttl( - MessageKeys.idempotency( - 'system', - 'thread-redis-owner', - `session-rollover:inv-redis-oversized:codex-native-resume:${status}`, - ), - ), - -1, - ); - } + assert.equal(messages.length, 0); }); }); diff --git a/packages/api/test/f296-oversized-rollout-notice-persistence.test.js b/packages/api/test/f296-oversized-rollout-notice-persistence.test.js index 18867716df..e1160f3512 100644 --- a/packages/api/test/f296-oversized-rollout-notice-persistence.test.js +++ b/packages/api/test/f296-oversized-rollout-notice-persistence.test.js @@ -28,8 +28,8 @@ async function persistedLifecycleMessages(store, contents) { ); } -describe('F296 oversized native rollover durable notices', () => { - it('persists each lifecycle stage exactly once with no native-session payload leakage', async () => { +describe('F296 oversized native rollover response-owned diagnostics', () => { + it('keeps lifecycle stages out of History because the response bubble owns execution status', async () => { const store = new MessageStore(); const pending = lifecycle('pending'); const succeeded = lifecycle('succeeded'); @@ -37,21 +37,10 @@ describe('F296 oversized native rollover durable notices', () => { assert.equal(isUserFacingSystemInfoContent(pending), true); const messages = await persistedLifecycleMessages(store, [pending, pending, succeeded, succeeded]); - assert.equal(messages.length, 2); - assert.deepEqual( - messages.map((message) => message.source.meta.sessionRollover.status), - ['pending', 'succeeded'], - ); - assert.deepEqual( - messages.map((message) => message.source.meta.noticeTone), - ['info', 'info'], - ); - for (const message of messages) { - assert.doesNotMatch(message.content, /native-oversized|rollout|prompt|inv-oversized-1/i); - } + assert.equal(messages.length, 0); }); - it('persists an honest failed stage and rejects malformed lifecycle payloads', async () => { + it('does not append a second system row for a failed response-owned rollover', async () => { const store = new MessageStore(); const failed = lifecycle('failed', { failureStage: 'seal_finalize' }); const messages = await persistedLifecycleMessages(store, [ @@ -63,11 +52,46 @@ describe('F296 oversized native rollover durable notices', () => { failed, ]); - assert.equal(messages.length, 1); - assert.equal(messages[0].source.meta.noticeTone, 'warning'); - assert.equal(messages[0].source.meta.sessionRollover.status, 'failed'); - assert.equal(messages[0].source.meta.sessionRollover.failureStage, 'seal_finalize'); - assert.match(messages[0].content, /失败/); - assert.match(messages[0].content, /发送新 prompt 前停止/); + assert.equal(messages.length, 0); + }); + + it('does not duplicate an exact provider failure already owned by the response body', async () => { + const store = new MessageStore(); + const failureText = 'Codex CLI: CLI 异常退出 (code=1)'; + + await persistUserFacingSystemInfoNotices({ + messageStore: store, + threadId: 'thread-owner', + catId: 'codex-sol', + terminalFailureText: failureText, + contents: [ + JSON.stringify({ type: 'warning', presentation: 'user_action_required', message: failureText }), + JSON.stringify({ type: 'warning', presentation: 'user_action_required', message: '另一条独立警告' }), + ], + }); + + const warnings = (await store.getByThread('thread-owner')).filter( + (message) => message.source?.connector === 'system-warning', + ); + assert.deepEqual( + warnings.map((message) => message.content), + ['⚠️ 另一条独立警告'], + ); + }); + + it('does not persist an unclassified provider diagnostic as a conversation notice', async () => { + const store = new MessageStore(); + + await persistUserFacingSystemInfoNotices({ + messageStore: store, + threadId: 'thread-owner', + catId: 'codex-sol', + contents: [JSON.stringify({ type: 'warning', message: 'Model metadata not found; using fallback metadata' })], + }); + + assert.equal( + (await store.getByThread('thread-owner')).some((message) => message.source?.connector === 'system-warning'), + false, + ); }); }); diff --git a/packages/api/test/f298-phase-b-reconciler-structure.test.js b/packages/api/test/f298-phase-b-reconciler-structure.test.js index 55814e1a47..72b3c2b9e1 100644 --- a/packages/api/test/f298-phase-b-reconciler-structure.test.js +++ b/packages/api/test/f298-phase-b-reconciler-structure.test.js @@ -1,37 +1,27 @@ import assert from 'node:assert/strict'; -import { readdir, readFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; const testDir = path.dirname(fileURLToPath(import.meta.url)); -const invocationDir = path.resolve(testDir, '../src/domains/cats/services/agents/invocation'); -const receiptDock = path.resolve(testDir, '../../web/src/components/MessageReceiptDock.tsx'); +const ledgerDir = path.resolve(testDir, '../src/domains/cats/services/agents/invocation/queue-ledger'); async function lineCount(file) { return (await readFile(file, 'utf8')).split('\n').length; } -describe('F298 Phase B restart reconciler structure', () => { - it('keeps every startup custody implementation module within the 350-line hard limit', async () => { - const files = (await readdir(invocationDir)).filter( - (name) => - name.endsWith('.ts') && - (name.startsWith('QueuedMessageCustodyStartup') || - name.startsWith('QueuedMessageCustodyRestart') || - name === 'QueuedMessageCustodyCarrierProjection.ts' || - name === 'QueuedMessageCustodyRuntimeRestartAttempts.ts' || - name === 'QueuedMessageCustodyA2ARestartPreflight.ts'), - ); - assert.ok(files.length > 1, 'restart custody responsibilities must be split across focused modules'); +describe('ADR-043 durable Queue ledger structure', () => { + it('keeps every persistence implementation module within the 350-line hard limit', async () => { + const files = [ + 'QueueLedger.ts', + 'InMemoryQueueLedgerStore.ts', + 'RedisQueueLedgerStore.ts', + 'queue-ledger-redis-scripts.ts', + ]; for (const file of files) { - const lines = await lineCount(path.join(invocationDir, file)); + const lines = await lineCount(path.join(ledgerDir, file)); assert.ok(lines <= 350, `${file} has ${lines} lines; expected <= 350`); } }); - - it('keeps the touched receipt implementation within the 350-line hard limit', async () => { - const lines = await lineCount(receiptDock); - assert.ok(lines <= 350, `MessageReceiptDock.tsx has ${lines} lines; expected <= 350`); - }); }); diff --git a/packages/api/test/f299-invocation-trajectory.test.js b/packages/api/test/f299-invocation-trajectory.test.js index e0a3504dce..ad6803f57c 100644 --- a/packages/api/test/f299-invocation-trajectory.test.js +++ b/packages/api/test/f299-invocation-trajectory.test.js @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function event(invocationId, eventNo, event, t = 1_000 + eventNo) { return { @@ -337,20 +338,24 @@ describe('F299 active and sealed invocation routes', () => { userId: 'user-f299', cliSessionId: 'cli-generation-2', }); - const availableSource = await messageStore.append({ - threadId: 'thread-f299', - userId: 'user-f299', - catId: null, - content: 'available source', - timestamp: 900, - }); - const hardDeletedSource = await messageStore.append({ - threadId: 'thread-f299', - userId: 'user-f299', - catId: null, - content: 'hard-deleted source', - timestamp: 901, - }); + const availableSource = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'user-f299', + catId: null, + content: 'available source', + timestamp: 900, + }), + ); + const hardDeletedSource = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'user-f299', + catId: null, + content: 'hard-deleted source', + timestamp: 901, + }), + ); await messageStore.hardDelete(hardDeletedSource.id, 'user-f299'); const sourceIds = [availableSource.id, hardDeletedSource.id]; const digest = `hmac-sha256:${'a'.repeat(64)}`; @@ -822,35 +827,43 @@ describe('F299 active and sealed invocation routes', () => { cliSessionId: record.cliSessionId, seq: record.seq, }; - const available = await messageStore.append({ - threadId: 'thread-f299', - userId: 'user-f299', - catId: null, - content: 'canonical user request', - timestamp: 900, - }); - const deleted = await messageStore.append({ - threadId: 'thread-f299', - userId: 'user-f299', - catId: null, - content: 'must not survive deletion', - timestamp: 901, - }); + const available = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'user-f299', + catId: null, + content: 'canonical user request', + timestamp: 900, + }), + ); + const deleted = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'user-f299', + catId: null, + content: 'must not survive deletion', + timestamp: 901, + }), + ); await messageStore.softDelete(deleted.id, 'user-f299'); - const invisible = await messageStore.append({ - threadId: 'thread-f299', - userId: 'other-user', - catId: null, - content: 'foreign body must not leak', - timestamp: 902, - }); - const context = await messageStore.append({ - threadId: 'thread-f299', - userId: 'user-f299', - catId: 'opus', - content: 'covered prompt context must not be presented as the trigger', - timestamp: 903, - }); + const invisible = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'other-user', + catId: null, + content: 'foreign body must not leak', + timestamp: 902, + }), + ); + const context = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'user-f299', + catId: 'opus', + content: 'covered prompt context must not be presented as the trigger', + timestamp: 903, + }), + ); const cases = [ { invocationId: 'inv-prompt-available', @@ -928,20 +941,24 @@ describe('F299 active and sealed invocation routes', () => { userId: 'user-f299', cliSessionId: 'cli-prompt-scope', }); - const trigger = await messageStore.append({ - threadId: 'thread-f299', - userId: 'user-f299', - catId: null, - content: 'canonical trigger', - timestamp: 910, - }); - const context = await messageStore.append({ - threadId: 'thread-f299', - userId: 'user-f299', - catId: 'opus', - content: 'context only', - timestamp: 911, - }); + const trigger = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'user-f299', + catId: null, + content: 'canonical trigger', + timestamp: 910, + }), + ); + const context = await messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-f299', + userId: 'user-f299', + catId: 'opus', + content: 'context only', + timestamp: 911, + }), + ); const info = { sessionId: record.id, threadId: record.threadId, diff --git a/packages/api/test/f306-native-review-route.test.js b/packages/api/test/f306-native-review-route.test.js index 6130b8fb3b..5745e049a3 100644 --- a/packages/api/test/f306-native-review-route.test.js +++ b/packages/api/test/f306-native-review-route.test.js @@ -2,6 +2,7 @@ import './helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; async function fixture({ providerError, providerResult } = {}) { const [{ nativeThreadReviewRoutes }, { ThreadStore }, { MessageStore }, { SessionChainStore }, { AgentRegistry }] = @@ -103,8 +104,10 @@ test('native review route persists structured mode, items, and result', async (t assert.equal( reviewMessages.every( (message) => + // F117: sender identity is MessageFrom truth; review receipts persist + // from = { kind: 'system', service: 'native-thread-review' }. message.userId === 'system' && - message.catId === 'system' && + message.from?.kind === 'system' && message.content.includes('Codex 原生 Review') && message.content.includes('不能作为合入批准'), ), @@ -159,31 +162,34 @@ test('native review route validates target and keeps an unavailable receipt afte test('a restarted route projects an orphaned durable review as unverifiable without writing a false terminal', async () => { const { app, agentRegistry, messageStore, sessionChainStore, thread, threadStore } = await fixture(); - await messageStore.append({ - userId: 'system', - catId: 'system', - threadId: thread.id, - content: 'Codex 原生 Review 已启动', - mentions: [], - timestamp: 100, - idempotencyKey: 'native-review:orphaned-review:started', - extra: { - semanticEvent: { - v: 1, - id: 'native-review:orphaned-review:started', - kind: 'review', - reviewId: 'orphaned-review', - stage: 'started', - summary: 'Codex 原生 Review 已启动', - actorCatId: 'codex', - occurredAt: 100, - target: { kind: 'base_branch', branch: 'origin/main' }, - targetLabel: '相对 origin/main', - delivery: 'detached', - provenance: { provider: 'openai_codex', carrier: 'app_server' }, + // F117: append requires an explicit MessageFrom sender identity. + await messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: 'system', + threadId: thread.id, + content: 'Codex 原生 Review 已启动', + mentions: [], + timestamp: 100, + idempotencyKey: 'native-review:orphaned-review:started', + extra: { + semanticEvent: { + v: 1, + id: 'native-review:orphaned-review:started', + kind: 'review', + reviewId: 'orphaned-review', + stage: 'started', + summary: 'Codex 原生 Review 已启动', + actorCatId: 'codex', + occurredAt: 100, + target: { kind: 'base_branch', branch: 'origin/main' }, + targetLabel: '相对 origin/main', + delivery: 'detached', + provenance: { provider: 'openai_codex', carrier: 'app_server' }, + }, }, - }, - }); + }), + ); await app.close(); const { nativeThreadReviewRoutes } = await import('../dist/routes/native-thread-review-routes.js'); @@ -360,13 +366,16 @@ test('active review remains discoverable after its durable start leaves the boun const reviewId = started.json().review.id; for (let index = 0; index < 201; index += 1) { - await messageStore.append({ - userId: 'owner-1', - threadId: thread.id, - content: `active-filler-${index}`, - mentions: [], - timestamp: 1_000 + index, - }); + // F117: append requires an explicit MessageFrom sender identity. + await messageStore.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: thread.id, + content: `active-filler-${index}`, + mentions: [], + timestamp: 1_000 + index, + }), + ); } const listed = await app.inject({ @@ -447,44 +456,51 @@ test('review listing preserves a completed review after its start rolls beyond 5 provenance: { provider: 'openai_codex', carrier: 'app_server' }, ...extra, }); - await messageStore.append({ - userId: 'system', - catId: 'system', - threadId: thread.id, - content: 'Review started', - mentions: [], - timestamp: 1, - extra: { - semanticEvent: semanticEvent('long-review-started', 'started', 1, { - target: { kind: 'uncommitted_changes' }, - delivery: 'inline', - }), - }, - }); - for (let index = 0; index < 501; index += 1) { - await messageStore.append({ - userId: 'owner-1', + // F117: append requires an explicit MessageFrom sender identity. + await messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: 'system', threadId: thread.id, - content: `filler-${index}`, + content: 'Review started', mentions: [], - timestamp: index + 2, - }); - } - await messageStore.append({ - userId: 'system', - catId: 'system', - threadId: thread.id, - content: 'No findings', - mentions: [], - timestamp: 503, - extra: { - semanticEvent: semanticEvent('long-review-result', 'result', 503, { - target: { kind: 'uncommitted_changes' }, - delivery: 'inline', - requestedAt: 1, + timestamp: 1, + extra: { + semanticEvent: semanticEvent('long-review-started', 'started', 1, { + target: { kind: 'uncommitted_changes' }, + delivery: 'inline', + }), + }, + }), + ); + for (let index = 0; index < 501; index += 1) { + await messageStore.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: thread.id, + content: `filler-${index}`, + mentions: [], + timestamp: index + 2, }), - }, - }); + ); + } + await messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: 'system', + threadId: thread.id, + content: 'No findings', + mentions: [], + timestamp: 503, + extra: { + semanticEvent: semanticEvent('long-review-result', 'result', 503, { + target: { kind: 'uncommitted_changes' }, + delivery: 'inline', + requestedAt: 1, + }), + }, + }), + ); const listed = await app.inject({ method: 'GET', diff --git a/packages/api/test/force-reset-thread.test.js b/packages/api/test/force-reset-thread.test.js index edaa09f1c0..7a01132e2c 100644 --- a/packages/api/test/force-reset-thread.test.js +++ b/packages/api/test/force-reset-thread.test.js @@ -18,6 +18,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import Fastify from 'fastify'; +import { adaptInvocationQueue, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { queueRoutes } = await import('../dist/routes/queue.js'); const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); @@ -52,9 +53,9 @@ function makeQueueProcessor({ canReleaseSlotForUser = true } = {}) { const actions = []; return { canReleaseSlotForUser: () => canReleaseSlotForUser, + hasProcessingSlotReservation: () => false, suppressAutoResume: (tid, cid, executionIds = []) => actions.push({ op: 'suppressAutoResume', tid, cid, executionIds }), - clearPause: (tid, cid) => actions.push({ op: 'clearPause', tid, cid }), releaseSlot: (tid, cid) => actions.push({ op: 'releaseSlot', tid, cid }), releaseThread: (tid) => actions.push({ op: 'releaseThread', tid }), retireThreadPrestartProcessingGroups: async () => ({ outcome: 'none', retiredCatIds: [] }), @@ -95,7 +96,7 @@ function makeTracker({ async function buildApp(opts = {}) { const app = Fastify({ logger: false }); - const invocationQueue = new InvocationQueue(); + const invocationQueue = adaptInvocationQueue(new InvocationQueue()); const qp = opts.queueProcessor ?? makeQueueProcessor(); const rs = opts.recordStore ?? makeRecordStore([]); const tracker = opts.tracker ?? makeTracker(); @@ -118,7 +119,6 @@ async function buildApp(opts = {}) { }, invocationRecordStore: rs, ...(opts.messageStore ? { messageStore: opts.messageStore } : {}), - ...(opts.queueCustodyCoordinator ? { queueCustodyCoordinator: opts.queueCustodyCoordinator } : {}), ...(opts.getManagedCommandWakeRecovery ? { getManagedCommandWakeRecovery: opts.getManagedCommandWakeRecovery } : {}), @@ -245,7 +245,6 @@ describe('force-reset: releases all stuck state for a thread (escape hatch)', () assert.equal(res.statusCode, 200); assert.ok(broadcasts.some(({ m }) => m.type === 'done' && m.catId === 'codex-sol')); - assert.ok(queueProcessor.actions.some((action) => action.op === 'clearPause' && action.cid === 'codex-sol')); assert.ok(queueProcessor.actions.some((action) => action.op === 'releaseSlot' && action.cid === 'codex-sol')); }); @@ -260,28 +259,24 @@ describe('force-reset: releases all stuck state for a thread (escape hatch)', () throw new Error('force-reset must use the thread-wide producer fence'); }, }; - const queueCustodyCoordinator = { - async withdrawEntry(entry) { - events.push(`withdraw:${entry.id}`); - return true; - }, - }; const { app, invocationQueue } = await buildApp({ getManagedCommandWakeRecovery: () => managedCommandWakeRecovery, - queueCustodyCoordinator, - }); - const { entry } = invocationQueue.enqueue({ - threadId: THREAD_ID, - userId: USER_ID, - ownerAuthProvenance: 'strict', - content: 'managed wake result', - messageId: 'message-managed-force-reset', - source: 'agent', - sourceCategory: 'scheduled', - targetCats: ['codex-sol'], - intent: 'execute', - autoExecute: true, }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + kind: 'message_wake', + threadId: THREAD_ID, + userId: USER_ID, + ownerAuthProvenance: 'strict', + content: 'managed wake result', + messageId: 'message-managed-force-reset', + source: 'agent', + sourceCategory: 'scheduled', + targetCats: ['codex-sol'], + intent: 'execute', + autoExecute: true, + }), + ); const res = await app.inject({ method: 'POST', @@ -290,7 +285,7 @@ describe('force-reset: releases all stuck state for a thread (escape hatch)', () }); assert.equal(res.statusCode, 200, res.body); - assert.deepEqual(events, [`retire:${THREAD_ID}:${USER_ID}:force_reset`, `withdraw:${entry.id}`]); + assert.deepEqual(events, [`retire:${THREAD_ID}:${USER_ID}:force_reset`]); assert.equal(invocationQueue.list(THREAD_ID, USER_ID).length, 0); }); @@ -361,10 +356,6 @@ describe('force-reset: releases all stuck state for a thread (escape hatch)', () broadcasts.some(({ m }) => m.type === 'done' && m.catId === 'codex-sol'), false, ); - assert.equal( - queueProcessor.actions.some((action) => action.op === 'clearPause' && action.cid === 'codex-sol'), - false, - ); assert.equal( queueProcessor.actions.some((action) => action.op === 'releaseSlot' && action.cid === 'codex-sol'), false, @@ -594,14 +585,9 @@ describe('force-reset: releases all stuck state for a thread (escape hatch)', () assert.equal(releaseSlotOps.length, 1, 'stale record targetCat slot must be released even when cancelAll=[]'); assert.equal(recordStore.updates.filter((u) => u.input.status === 'canceled').length, 1); - // P2 (opus-4.6 cross-cat review): the stale cat must ALSO get a cancel broadcast + clearPause — - // else the frontend "正在回复中" never clears after force-reset (cancelAll=[] so the cat isn't in - // cancelledCatIds). All three (broadcast/clearPause/releaseSlot) must fire over slotsToRelease. + // The stale cat must also get a cancel broadcast so the frontend clears even when + // cancelAll=[]; the canonical lifecycle has no parallel pause state to clear. assert.ok(broadcasts.length > 0, 'stale record cat must get a cancel broadcast so frontend clears'); - assert.ok( - qp.actions.some((a) => a.op === 'clearPause' && a.cid === 'codex'), - 'clearPause must fire for the stale cat (aligned with orphan/normal cancel paths)', - ); const suppressStale = qp.actions.find((a) => a.op === 'suppressAutoResume' && a.cid === 'codex'); assert.ok(suppressStale, 'stale record targetCat must also be fenced from delayed auto-resume'); assert.deepEqual( @@ -613,7 +599,7 @@ describe('force-reset: releases all stuck state for a thread (escape hatch)', () it('returns 404 when thread does not exist', async () => { const app = Fastify({ logger: false }); - const invocationQueue = new InvocationQueue(); + const invocationQueue = adaptInvocationQueue(new InvocationQueue()); await app.register(queueRoutes, { threadStore: { diff --git a/packages/api/test/game-narrator-driver.test.js b/packages/api/test/game-narrator-driver.test.js index 0d3998beae..31c77f7326 100644 --- a/packages/api/test/game-narrator-driver.test.js +++ b/packages/api/test/game-narrator-driver.test.js @@ -467,7 +467,10 @@ describe('GameNarratorDriver', () => { assert.ok(messageStore.appended.length > 0, 'should persist narrative into thread history'); assert.equal(messageStore.appended[0].userId, 'system'); - assert.equal(messageStore.appended[0].catId, 'system'); + assert.deepEqual(messageStore.appended[0].from, { + kind: 'system', + service: 'game-orchestrator', + }); assert.ok( socketManager.broadcasts.some((b) => b.event === 'game:narrative'), 'should emit game:narrative', diff --git a/packages/api/test/game-phase-h-fixes.test.js b/packages/api/test/game-phase-h-fixes.test.js index c3dbb9bf43..a701963a55 100644 --- a/packages/api/test/game-phase-h-fixes.test.js +++ b/packages/api/test/game-phase-h-fixes.test.js @@ -205,7 +205,7 @@ describe('Phase H P1 Fixes — definition-level regression guards', () => { if (rt.currentPhase === 'night_resolve') await forceTick(); await new Promise((r) => setTimeout(r, 50)); - const announceMessages = msgStore.messages.filter((msg) => msg.catId === 'system'); + const announceMessages = msgStore.messages.filter((msg) => msg.from?.kind === 'system'); assert.ok(announceMessages.length > 0, 'Should have at least one canonical system announce message'); for (const msg of announceMessages) { assert.equal(msg.userId, 'system', 'game announce must use canonical system userId'); @@ -289,7 +289,10 @@ describe('Phase H P1 Fixes — definition-level regression guards', () => { socketManager: { broadcastToRoom() {}, emitToUser() {}, broadcastAgentMessage() {} }, router: { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] } }; + return { + targetCats: ['opus'], + intent: { intent: 'execute', explicit: false, promptTags: [] }, + }; }, async *routeExecution() { yield { type: 'done', catId: 'opus', timestamp: Date.now() }; @@ -479,13 +482,13 @@ describe('Phase H P1 Fixes', () => { assert.ok(lw.payload.text.length > 0, 'last_words should have text content'); // Verify messageStore got the speech - const speechMessages = msgStore.messages.filter((m) => m.catId === 'kimi'); + const speechMessages = msgStore.messages.filter((m) => m.from?.kind === 'agent' && m.from.catId === 'kimi'); assert.ok(speechMessages.length > 0, 'messageStore should have speech message from exiled player (kimi)'); }); }); describe('P1-2: messageStore uses canonical system identity for game announces', () => { - it('player mode: announce messages use userId=system + catId=system', async () => { + it('player mode: announce messages use the canonical system identity', async () => { const game = await orchestrator.startGame({ threadId: 'thread-p1-2', definition: makeWerewolfDefinition(), @@ -544,12 +547,15 @@ describe('Phase H P1 Fixes', () => { // Wait for fire-and-forget promises await new Promise((r) => setTimeout(r, 50)); - const announceMessages = msgStore.messages.filter((m) => m.catId === 'system'); + const announceMessages = msgStore.messages.filter((m) => m.from?.kind === 'system'); assert.ok(announceMessages.length > 0, 'Should have canonical system announce messages in messageStore'); for (const msg of announceMessages) { assert.equal(msg.userId, 'system', 'announce userId should be canonical system'); - assert.equal(msg.catId, 'system', 'announce catId should be canonical system'); + assert.deepEqual(msg.from, { + kind: 'system', + service: 'game-orchestrator', + }); } }); }); diff --git a/packages/api/test/get-message-visibility.test.js b/packages/api/test/get-message-visibility.test.js index e4ecaa6df0..70b6b0f85d 100644 --- a/packages/api/test/get-message-visibility.test.js +++ b/packages/api/test/get-message-visibility.test.js @@ -7,8 +7,8 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { return { @@ -23,6 +23,7 @@ describe('GET /api/callbacks/get-message visibility', () => { let registry; let messageStore; let threadStore; + let invocationQueue; beforeEach(async () => { const { InvocationRegistry } = await import( @@ -30,10 +31,12 @@ describe('GET /api/callbacks/get-message visibility', () => { ); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); registry = new InvocationRegistry(); messageStore = new MessageStore(); threadStore = new ThreadStore(); + invocationQueue = new InvocationQueue(); }); async function createApp() { @@ -44,6 +47,7 @@ describe('GET /api/callbacks/get-message visibility', () => { messageStore, socketManager: createMockSocketManager(), threadStore, + invocationQueue, evidenceStore: { search: async () => [], health: async () => true, @@ -71,16 +75,18 @@ describe('GET /api/callbacks/get-message visibility', () => { threadStore.updateThinkingMode(thread.id, 'play'); // Create a whisper visible only to 'codex', not 'opus' - const whisperMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'secret whisper', - mentions: [], - timestamp: 1000, - threadId: thread.id, - visibility: 'whisper', - whisperTo: ['codex'], - }); + const whisperMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'secret whisper', + mentions: [], + timestamp: 1000, + threadId: thread.id, + visibility: 'whisper', + whisperTo: ['codex'], + }), + ); const res = await app.inject({ method: 'GET', @@ -100,14 +106,16 @@ describe('GET /api/callbacks/get-message visibility', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const thread = threadStore.create('user-1', 'system test'); - const sysMsg = messageStore.append({ - userId: 'system', - catId: null, - content: 'SYSTEM BADGE — internal', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); + const sysMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'SYSTEM BADGE — internal', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); const res = await app.inject({ method: 'GET', url: `/api/callbacks/get-message?messageId=${sysMsg.id}`, @@ -120,15 +128,17 @@ describe('GET /api/callbacks/get-message visibility', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const thread = threadStore.create('user-1', 'briefing test'); - const briefingMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'TOP SECRET BRIEFING', - mentions: [], - timestamp: 1000, - threadId: thread.id, - origin: 'briefing', - }); + const briefingMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'TOP SECRET BRIEFING', + mentions: [], + timestamp: 1000, + threadId: thread.id, + origin: 'briefing', + }), + ); const res = await app.inject({ method: 'GET', url: `/api/callbacks/get-message?messageId=${briefingMsg.id}`, @@ -141,31 +151,37 @@ describe('GET /api/callbacks/get-message visibility', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const thread = threadStore.create('user-1', 'context test'); - const target = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'normal target', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); - messageStore.append({ - userId: 'system', - catId: null, - content: 'SYSTEM BADGE neighbor', - mentions: [], - timestamp: 1001, - threadId: thread.id, - }); - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'TOP SECRET BRIEFING neighbor', - mentions: [], - timestamp: 1002, - threadId: thread.id, - origin: 'briefing', - }); + const target = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'normal target', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'SYSTEM BADGE neighbor', + mentions: [], + timestamp: 1001, + threadId: thread.id, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'TOP SECRET BRIEFING neighbor', + mentions: [], + timestamp: 1002, + threadId: thread.id, + origin: 'briefing', + }), + ); const res = await app.inject({ method: 'GET', url: `/api/callbacks/get-message?messageId=${target.id}&contextCount=5`, @@ -182,16 +198,18 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); // Debug mode (default) — cats see everything like the user - const whisperMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'whisper for codex', - mentions: [], - timestamp: 1000, - threadId: 'thread-debug', - visibility: 'whisper', - whisperTo: ['codex'], - }); + const whisperMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'whisper for codex', + mentions: [], + timestamp: 1000, + threadId: 'thread-debug', + visibility: 'whisper', + whisperTo: ['codex'], + }), + ); const res = await app.inject({ method: 'GET', @@ -211,14 +229,16 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); // Message belongs to user-2 - const otherUserMsg = messageStore.append({ - userId: 'user-2', - catId: null, - content: 'other user message', - mentions: [], - timestamp: 1000, - threadId: 'thread-other', - }); + const otherUserMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + content: 'other user message', + mentions: [], + timestamp: 1000, + threadId: 'thread-other', + }), + ); const res = await app.inject({ method: 'GET', @@ -236,14 +256,16 @@ describe('GET /api/callbacks/get-message visibility', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - const msg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'hello opus', - mentions: ['opus'], - timestamp: 1000, - threadId: 'thread-1', - }); + const msg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'hello opus', + mentions: ['opus'], + timestamp: 1000, + threadId: 'thread-1', + }), + ); const res = await app.inject({ method: 'GET', @@ -263,15 +285,17 @@ describe('GET /api/callbacks/get-message visibility', () => { test('returns queued cat speech already published to the timeline', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - const published = messageStore.append({ - userId: 'user-1', - catId: 'codex', - content: 'published source-cat seed', - mentions: ['opus'], - timestamp: 1000, - threadId: 'thread-published', - deliveryStatus: 'queued', - }); + const published = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'published source-cat seed', + mentions: ['opus'], + timestamp: 1000, + threadId: 'thread-published', + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'GET', @@ -283,26 +307,20 @@ describe('GET /api/callbacks/get-message visibility', () => { assert.equal(JSON.parse(res.body).message.content, 'published source-cat seed'); }); - test('does not expose browser-published queued user work through cat get-message cognition', async () => { + test('does not expose undelivered queued user work through cat get-message cognition', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - const queued = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'visible to the owner timeline, not generally delivered to cats', - mentions: ['opus'], - timestamp: 1100, - threadId: 'thread-browser-only-queued-user', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-browser-only-queued-user', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - steerRequestedByCatIds: ['opus'], - createdAt: 1100, - updatedAt: 1150, + const queued = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'not published to History and not generally delivered to cats', + mentions: ['opus'], + timestamp: 1100, + threadId: 'thread-browser-only-queued-user', + deliveryStatus: 'queued', }), - }); + ); const res = await app.inject({ method: 'GET', @@ -313,27 +331,34 @@ describe('GET /api/callbacks/get-message visibility', () => { assert.equal(res.statusCode, 404); }); - test('returns durable queued user work only to a cat with an exact body-exposure witness', async () => { + test('never treats a queued receipt field as History publication authority', async () => { const app = await createApp(); const exposedCaller = await registry.create('user-1', 'opus'); const unexposedCaller = await registry.create('user-1', 'codex'); - const queued = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'survives the child session that first read it', - mentions: ['opus'], - timestamp: 1200, - threadId: 'thread-exposed-queued-user', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-exposed-queued-user', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'sealed-child-opus' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'sealed-child-opus', seenAt: 1150 }], + const queued = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'survives the child session that first read it', + mentions: ['opus'], + timestamp: 1200, + threadId: 'thread-exposed-queued-user', + deliveryStatus: 'queued', }), + ); + const admission = invocationQueue.enqueueDurableNow({ + from: { kind: 'user', userId: 'user-1' }, + threadId: queued.threadId, + userId: 'user-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + content: queued.content, + messageId: queued.id, + targetCats: ['opus', 'codex'], + intent: 'execute', }); + const opusEntry = admission.entry; + assert.ok(opusEntry); const exposed = await app.inject({ method: 'GET', @@ -343,10 +368,7 @@ describe('GET /api/callbacks/get-message visibility', () => { 'x-callback-token': exposedCaller.callbackToken, }, }); - assert.equal(exposed.statusCode, 200, exposed.body); - assert.equal(JSON.parse(exposed.body).message.speaker, 'co-creator'); - assert.equal(JSON.parse(exposed.body).message.catId, null); - assert.equal(JSON.parse(exposed.body).message.threadId, queued.threadId); + assert.equal(exposed.statusCode, 404, exposed.body); const unexposed = await app.inject({ method: 'GET', @@ -356,15 +378,15 @@ describe('GET /api/callbacks/get-message visibility', () => { 'x-callback-token': unexposedCaller.callbackToken, }, }); - assert.equal(unexposed.statusCode, 404, "another target must not inherit the first cat's exposure"); + assert.equal(unexposed.statusCode, 404); }); test('managed-hold exact reads preserve owner and hidden-trigger boundaries after exposure', async () => { const app = await createApp(); const owner = await registry.create('user-1', 'opus', 'thread-managed-owner'); const visible = messageStore.append({ - userId: 'scheduler', - catId: null, + userId: 'user-1', + from: { kind: 'system', service: 'managed-command-wake' }, content: 'owner-visible managed wake', mentions: ['opus'], timestamp: 1300, @@ -373,21 +395,19 @@ describe('GET /api/callbacks/get-message visibility', () => { source: { connector: 'hold-ball', label: '持球通知', - meta: { taskId: 'task-owner-visible', threadId: 'thread-managed-owner', catId: 'opus', wakeWhen: true }, + meta: { + managedHold: true, + phase: 'wake', + taskId: 'task-owner-visible', + threadId: 'thread-managed-owner', + catId: 'opus', + }, }, - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-owner-visible', - ownerUserId: 'user-1', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'sealed-owner-child' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'sealed-owner-child', seenAt: 1350 }], - }), }); + messageStore.markDelivered(visible.id, 1350); const hidden = messageStore.append({ - userId: 'scheduler', - catId: null, + userId: 'user-1', + from: { kind: 'system', service: 'managed-command-wake' }, content: 'hidden managed trigger', mentions: ['opus'], timestamp: 1400, @@ -397,18 +417,16 @@ describe('GET /api/callbacks/get-message visibility', () => { source: { connector: 'hold-ball', label: '持球通知', - meta: { taskId: 'task-hidden', threadId: 'thread-managed-owner', catId: 'opus', wakeWhen: true }, + meta: { + managedHold: true, + phase: 'wake', + taskId: 'task-hidden', + threadId: 'thread-managed-owner', + catId: 'opus', + }, }, - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-hidden', - ownerUserId: 'user-1', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'sealed-owner-child' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'sealed-owner-child', seenAt: 1450 }], - }), }); + messageStore.markDelivered(hidden.id, 1450); const ownerRead = await app.inject({ method: 'GET', @@ -440,14 +458,16 @@ describe('GET /api/callbacks/get-message visibility', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const longContent = 'X'.repeat(500); - const msg = messageStore.append({ - userId: 'user-1', - catId: null, - content: longContent, - mentions: ['opus'], - timestamp: 1000, - threadId: 'thread-1', - }); + const msg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: longContent, + mentions: ['opus'], + timestamp: 1000, + threadId: 'thread-1', + }), + ); // default = preview: bounded excerpt + honest flags const previewRes = await app.inject({ @@ -482,16 +502,18 @@ describe('GET /api/callbacks/get-message visibility', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - const whisperMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'whisper for opus', - mentions: [], - timestamp: 1000, - threadId: 'thread-1', - visibility: 'whisper', - whisperTo: ['opus'], - }); + const whisperMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'whisper for opus', + mentions: [], + timestamp: 1000, + threadId: 'thread-1', + visibility: 'whisper', + whisperTo: ['opus'], + }), + ); const res = await app.inject({ method: 'GET', @@ -516,36 +538,42 @@ describe('GET /api/callbacks/get-message visibility', () => { threadStore.updateThinkingMode(thread.id, 'play'); // Public message (the target) - const target = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'public target', - mentions: [], - timestamp: 2000, - threadId: thread.id, - }); + const target = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'public target', + mentions: [], + timestamp: 2000, + threadId: thread.id, + }), + ); // Whisper before target — addressed to codex, NOT opus - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'secret for codex only', - mentions: [], - timestamp: 1000, - threadId: thread.id, - visibility: 'whisper', - whisperTo: ['codex'], - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'secret for codex only', + mentions: [], + timestamp: 1000, + threadId: thread.id, + visibility: 'whisper', + whisperTo: ['codex'], + }), + ); // Public message after target — should appear in context - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'public after', - mentions: [], - timestamp: 3000, - threadId: thread.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'public after', + mentions: [], + timestamp: 3000, + threadId: thread.id, + }), + ); const res = await app.inject({ method: 'GET', @@ -573,15 +601,17 @@ describe('GET /api/callbacks/get-message visibility', () => { threadStore.updateThinkingMode(thread.id, 'play'); // codex's stream message in that thread - const streamMsg = messageStore.append({ - userId: 'user-1', - catId: 'codex', - content: 'codex persisted answer', - mentions: [], - timestamp: 1000, - threadId: thread.id, - origin: 'stream', - }); + const streamMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'codex persisted answer', + mentions: [], + timestamp: 1000, + threadId: thread.id, + origin: 'stream', + }), + ); const res = await app.inject({ method: 'GET', @@ -604,15 +634,17 @@ describe('GET /api/callbacks/get-message visibility', () => { threadStore.updateThinkingMode(thread.id, 'play'); // opus's own stream message - const ownStream = messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'opus stream thinking', - mentions: [], - timestamp: 1000, - threadId: thread.id, - origin: 'stream', - }); + const ownStream = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'opus stream thinking', + mentions: [], + timestamp: 1000, + threadId: thread.id, + origin: 'stream', + }), + ); const res = await app.inject({ method: 'GET', @@ -634,36 +666,42 @@ describe('GET /api/callbacks/get-message visibility', () => { threadStore.updateThinkingMode(thread.id, 'play'); // Target: user message (visible) - const target = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'user question', - mentions: [], - timestamp: 2000, - threadId: thread.id, - }); + const target = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'user question', + mentions: [], + timestamp: 2000, + threadId: thread.id, + }), + ); // codex persisted answer in the same thread — visible to opus - messageStore.append({ - userId: 'user-1', - catId: 'codex', - content: 'codex persisted answer', - mentions: [], - timestamp: 1000, - threadId: thread.id, - origin: 'stream', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'codex persisted answer', + mentions: [], + timestamp: 1000, + threadId: thread.id, + origin: 'stream', + }), + ); // opus's own stream — should be visible - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'opus own stream', - mentions: [], - timestamp: 3000, - threadId: thread.id, - origin: 'stream', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'opus own stream', + mentions: [], + timestamp: 3000, + threadId: thread.id, + origin: 'stream', + }), + ); const res = await app.inject({ method: 'GET', diff --git a/packages/api/test/growing/custody-offer-routes.test.js b/packages/api/test/growing/custody-offer-routes.test.js index aae5aca4b6..db9e1a58db 100644 --- a/packages/api/test/growing/custody-offer-routes.test.js +++ b/packages/api/test/growing/custody-offer-routes.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import Fastify from 'fastify'; import '../helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const { InvocationRegistry } = await import('../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); const { MessageStore, deriveGrowingSourceMessageRevision } = await import( @@ -11,16 +12,19 @@ const { TaskStore } = await import('../../dist/domains/cats/services/stores/port const { registerCustodyOfferRoutes } = await import('../../dist/routes/custody-offer-routes.js'); function appendSource(store, overrides = {}) { - return store.append({ - userId: 'owner-1', - catId: null, - content: 'Please prepare a reviewable presentation next week.', - contentBlocks: [{ type: 'text', text: 'Please prepare a reviewable presentation next week.' }], - mentions: ['codex-sol'], - timestamp: 1_788_190_000_000, - threadId: 'thread-f310', - ...overrides, - }); + // F117: append requires MessageFrom sender identity + return store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content: 'Please prepare a reviewable presentation next week.', + contentBlocks: [{ type: 'text', text: 'Please prepare a reviewable presentation next week.' }], + mentions: ['codex-sol'], + timestamp: 1_788_190_000_000, + threadId: 'thread-f310', + ...overrides, + }), + ); } async function harness() { diff --git a/packages/api/test/growing/custody-offer-service.test.js b/packages/api/test/growing/custody-offer-service.test.js index 7fb960b247..08d250cc63 100644 --- a/packages/api/test/growing/custody-offer-service.test.js +++ b/packages/api/test/growing/custody-offer-service.test.js @@ -5,18 +5,22 @@ import { MessageStore, } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; import { CustodyOfferService } from '../../dist/domains/growing/CustodyOfferService.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; function appendSource(store, overrides = {}) { - return store.append({ - userId: 'owner-1', - catId: null, - content: 'Please hold tomorrow presentation.', - contentBlocks: [{ type: 'text', text: 'Please hold tomorrow presentation.' }], - mentions: [], - timestamp: 1_788_168_300_000, - threadId: 'thread-source', - ...overrides, - }); + // F117: append requires MessageFrom sender identity + return store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content: 'Please hold tomorrow presentation.', + contentBlocks: [{ type: 'text', text: 'Please hold tomorrow presentation.' }], + mentions: [], + timestamp: 1_788_168_300_000, + threadId: 'thread-source', + ...overrides, + }), + ); } function pendingInput(message) { diff --git a/packages/api/test/growing/custody-opportunity-runtime.test.js b/packages/api/test/growing/custody-opportunity-runtime.test.js index af8d5fe515..dac30f11e6 100644 --- a/packages/api/test/growing/custody-opportunity-runtime.test.js +++ b/packages/api/test/growing/custody-opportunity-runtime.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import Database from 'better-sqlite3'; import '../helpers/setup-cat-registry.js'; +import { adaptMessageStore } from '../helpers/message-from-fixtures.js'; const { MessageStore, deriveGrowingSourceMessageRevision } = await import( '../../dist/domains/cats/services/stores/ports/MessageStore.js' @@ -25,7 +26,7 @@ function fixture(t) { const db = new Database(':memory:'); databases.push(db); const cohorts = new CustodyOpportunityCohortStore(db); - const messages = new MessageStore(); + const messages = adaptMessageStore(new MessageStore()); const tasks = new TaskStore(); const deps = { cohorts, messages, tasks, policyVersion: 'frozen-policy-v1', now: () => now }; const runtime = new CustodyOpportunityRuntime(deps); @@ -83,7 +84,7 @@ function fixture(t) { } test('source-owned recognition time is recorded once and preserved by offer replay', async () => { - const messages = new MessageStore(); + const messages = adaptMessageStore(new MessageStore()); const source = messages.append({ userId: 'owner', threadId: 'thread', diff --git a/packages/api/test/growing/entrusted-work-custody-guards.test.js b/packages/api/test/growing/entrusted-work-custody-guards.test.js index 421462737d..28e676c2e0 100644 --- a/packages/api/test/growing/entrusted-work-custody-guards.test.js +++ b/packages/api/test/growing/entrusted-work-custody-guards.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import Fastify from 'fastify'; import '../helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const { InvocationRegistry } = await import('../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); const { MessageStore, deriveGrowingSourceMessageRevision } = await import( @@ -45,14 +46,17 @@ function admissionCommand(overrides = {}) { } function appendUserMessage(messageStore, { content, threadId = 'thread-f310', timestamp = now }) { - return messageStore.append({ - userId: 'owner-1', - catId: null, - content, - mentions: ['codex-sol'], - timestamp, - threadId, - }); + // F117: append requires MessageFrom sender identity + return messageStore.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content, + mentions: ['codex-sol'], + timestamp, + threadId, + }), + ); } function admissionPayload(admission, overrides = {}) { diff --git a/packages/api/test/growing/entrusted-work-lifecycle.test.js b/packages/api/test/growing/entrusted-work-lifecycle.test.js index a2afd4b2dc..d35b66b331 100644 --- a/packages/api/test/growing/entrusted-work-lifecycle.test.js +++ b/packages/api/test/growing/entrusted-work-lifecycle.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import Fastify from 'fastify'; import '../helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const { InvocationRegistry } = await import('../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); const { MessageStore } = await import('../../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -161,14 +162,17 @@ describe('F310 entrusted-work Task owner lifecycle', () => { const messageStore = new MessageStore(); const taskStore = new TaskStore(); const lifecycle = new EntrustedWorkLifecycleService(taskStore, { now: () => now }); - const source = messageStore.append({ - userId: 'owner-1', - catId: null, - content: 'Please prepare tomorrow presentation', - mentions: [], - timestamp: now, - threadId: 'thread-f310', - }); + // F117: append requires MessageFrom sender identity + const source = messageStore.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content: 'Please prepare tomorrow presentation', + mentions: [], + timestamp: now, + threadId: 'thread-f310', + }), + ); const { deriveGrowingSourceMessageRevision } = await import( '../../dist/domains/cats/services/stores/ports/MessageStore.js' ); diff --git a/packages/api/test/harness-eval/design-gate-threshold-trigger.test.js b/packages/api/test/harness-eval/design-gate-threshold-trigger.test.js index 42eaa07c97..1b5231cd88 100644 --- a/packages/api/test/harness-eval/design-gate-threshold-trigger.test.js +++ b/packages/api/test/harness-eval/design-gate-threshold-trigger.test.js @@ -74,7 +74,7 @@ function setup(value, overrides = {}) { return { store, deliver: mock.fn(async () => 'message-design-gate'), - invokeTrigger: { trigger: mock.fn(async () => 'dispatched') }, + invokeTrigger: { trigger: mock.fn(async () => 'enqueued') }, input: { provider: { resolveLatestTransition: async () => value }, domain, diff --git a/packages/api/test/harness-eval/eval-domain-daily.test.js b/packages/api/test/harness-eval/eval-domain-daily.test.js index 0d90226872..aa666fec20 100644 --- a/packages/api/test/harness-eval/eval-domain-daily.test.js +++ b/packages/api/test/harness-eval/eval-domain-daily.test.js @@ -125,7 +125,7 @@ describe('eval-domain-daily task spec', () => { assert.equal(deliverMock.mock.callCount(), 1); const deliverCall = deliverMock.mock.calls[0].arguments[0]; assert.equal(deliverCall.threadId, 'thread_eval_a2a'); - assert.equal(deliverCall.userId, 'scheduler'); + assert.equal(deliverCall.userId, 'default-user'); assert.ok(deliverCall.content.includes('eval:a2a'), 'content should mention domain'); assert.match(deliverCall.content, /Trigger channel: time/); assert.match(deliverCall.content, /Invocation is only a wake attempt/); @@ -334,7 +334,7 @@ describe('eval-domain-weekly task spec (AC-E19, AC-E20)', () => { assert.equal(deliverMock.mock.callCount(), 1); const deliverCall = deliverMock.mock.calls[0].arguments[0]; assert.equal(deliverCall.threadId, 'thread_eval_capability_wakeup'); - assert.equal(deliverCall.userId, 'scheduler'); + assert.equal(deliverCall.userId, 'default-user'); assert.ok(deliverCall.content.includes('eval:capability-wakeup'), 'content should mention domain'); assert.equal(triggerMock.mock.callCount(), 1); diff --git a/packages/api/test/harness-eval/eval-domain-trigger-dispatch.test.js b/packages/api/test/harness-eval/eval-domain-trigger-dispatch.test.js index 0cb9f98a3a..d398977433 100644 --- a/packages/api/test/harness-eval/eval-domain-trigger-dispatch.test.js +++ b/packages/api/test/harness-eval/eval-domain-trigger-dispatch.test.js @@ -90,7 +90,7 @@ function deps(store, nowMs, overrides = {}) { return () => `token-${++token}`; })(), deliver: mock.fn(async () => 'message-1'), - invokeTrigger: { trigger: mock.fn(async () => 'dispatched') }, + invokeTrigger: { trigger: mock.fn(async () => 'enqueued') }, defaultUserId: 'owner-user', ...overrides, }; diff --git a/packages/api/test/harness-eval/paw-feel-adapter.test.js b/packages/api/test/harness-eval/paw-feel-adapter.test.js index 0e191ce17b..b535af641e 100644 --- a/packages/api/test/harness-eval/paw-feel-adapter.test.js +++ b/packages/api/test/harness-eval/paw-feel-adapter.test.js @@ -4,6 +4,7 @@ import { createRedisClient } from '@cat-cafe/shared/utils'; import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; import { RedisMessageStore } from '../../dist/domains/cats/services/stores/redis/RedisMessageStore.js'; import { PawFeelAdapter } from '../../dist/infrastructure/harness-eval/friction/paw-feel-adapter.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -51,7 +52,16 @@ describe('PawFeelAdapter — Redis-backed pull', { skip: redisIsolationSkipReaso }); function seed({ thread, cat, ts, content }) { - return store.append({ userId: 'u1', catId: cat, content, mentions: [], timestamp: ts, threadId: thread }); + return store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: cat, + content, + mentions: [], + timestamp: ts, + threadId: thread, + }), + ); } it('采集时间窗内 marker → 结构化 signal(跨 thread/cat,字段正确)', async () => { @@ -146,15 +156,17 @@ describe('PawFeelAdapter — Redis-backed pull', { skip: redisIsolationSkipReaso it('P1-1: queued cat speech stays in its authoring-time eval window after delivery', async () => { const created = T0 - 5000; // 窗口前(raw timestamp) const delivered = T0 + 1000; // 窗口内(effective time) - const m = await store.append({ - userId: 'u1', - catId: 'opus-48', - content: '[爪感差: rg 噪音]', - mentions: [], - timestamp: created, - deliveryStatus: 'queued', - threadId: 'th-1', - }); + const m = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus-48', + content: '[爪感差: rg 噪音]', + mentions: [], + timestamp: created, + deliveryStatus: 'queued', + threadId: 'th-1', + }), + ); await store.markDelivered(m.id, delivered); const adapter = new PawFeelAdapter(store); @@ -166,22 +178,26 @@ describe('PawFeelAdapter — Redis-backed pull', { skip: redisIsolationSkipReaso // P1-2 (gpt52 review): 爪感差是猫的摩擦上报约定(L0 staging)。user-authored 消息引用 marker // 格式(讨论时)不算真信号——author guard 跳过 catId===null。 it('P1-2: user-authored 引用 marker 格式不采集(author guard)', async () => { - await store.append({ - userId: 'u1', - catId: null, - content: '讨论格式:比如猫会写 [爪感差: rg 噪音太多]', - mentions: [], - timestamp: T0 + 1000, - threadId: 'th-1', - }); - const catMsg = await store.append({ - userId: 'u1', - catId: 'opus-48', - content: '[爪感差: hold_ball 卡]', - mentions: [], - timestamp: T0 + 2000, - threadId: 'th-1', - }); + await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: '讨论格式:比如猫会写 [爪感差: rg 噪音太多]', + mentions: [], + timestamp: T0 + 1000, + threadId: 'th-1', + }), + ); + const catMsg = await store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus-48', + content: '[爪感差: hold_ball 卡]', + mentions: [], + timestamp: T0 + 2000, + threadId: 'th-1', + }), + ); const adapter = new PawFeelAdapter(store); const signals = await adapter.pull(T0, T0 + 10000); @@ -199,23 +215,27 @@ describe('PawFeelAdapter — in-memory store path (cloud R3 P2)', () => { it('cross-post marker becomes one routing-misuse signal instead of duplicating the source symptom', async () => { const store = new MessageStore(); - const source = store.append({ - userId: 'u1', - catId: 'codex', - content: '[爪感差: rg 输出太吵]', - mentions: [], - timestamp: M0 + 1000, - threadId: 'thread-source', - }); - const relay = store.append({ - userId: 'u1', - catId: 'codex', - content: 'FYI [爪感差: rg 输出太吵] repeated [爪感差: rg 输出太吵]', - mentions: [], - timestamp: M0 + 2000, - threadId: 'thread-wrong-inbox', - extra: { crossPost: { sourceThreadId: 'thread-source', sourceInvocationId: 'inv-source' } }, - }); + const source = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'codex', + content: '[爪感差: rg 输出太吵]', + mentions: [], + timestamp: M0 + 1000, + threadId: 'thread-source', + }), + ); + const relay = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'codex', + content: 'FYI [爪感差: rg 输出太吵] repeated [爪感差: rg 输出太吵]', + mentions: [], + timestamp: M0 + 2000, + threadId: 'thread-wrong-inbox', + extra: { crossPost: { sourceThreadId: 'thread-source', sourceInvocationId: 'inv-source' } }, + }), + ); const adapter = new PawFeelAdapter(store); const signals = await adapter.pull(M0, M0 + 10000); @@ -234,15 +254,17 @@ describe('PawFeelAdapter — in-memory store path (cloud R3 P2)', () => { it('legacy self-referential crossPost metadata remains a local incident signal', async () => { const store = new MessageStore(); - const local = store.append({ - userId: 'u1', - catId: 'codex', - content: '[爪感差: same-thread route guard misfired]', - mentions: [], - timestamp: M0 + 1000, - threadId: 'thread-same', - extra: { crossPost: { sourceThreadId: 'thread-same' } }, - }); + const local = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'codex', + content: '[爪感差: same-thread route guard misfired]', + mentions: [], + timestamp: M0 + 1000, + threadId: 'thread-same', + extra: { crossPost: { sourceThreadId: 'thread-same' } }, + }), + ); const adapter = new PawFeelAdapter(store); const signals = await adapter.pull(M0, M0 + 10000); @@ -254,25 +276,29 @@ describe('PawFeelAdapter — in-memory store path (cloud R3 P2)', () => { it('queued-delivered message 不重复不死循环(pageSize=1)', { timeout: 8000 }, async () => { const store = new MessageStore(); - const m1 = store.append({ - userId: 'u1', - catId: 'opus-48', - content: '[爪感差: rg 噪音]', - mentions: [], - timestamp: M0 - 5000, - deliveryStatus: 'queued', - threadId: 'th-1', - }); + const m1 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus-48', + content: '[爪感差: rg 噪音]', + mentions: [], + timestamp: M0 - 5000, + deliveryStatus: 'queued', + threadId: 'th-1', + }), + ); store.markDelivered(m1.id, M0 + 1000); - const m2 = store.append({ - userId: 'u1', - catId: 'codex', - content: '[爪感差: grep 慢]', - mentions: [], - timestamp: M0 - 4000, - deliveryStatus: 'queued', - threadId: 'th-1', - }); + const m2 = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'codex', + content: '[爪感差: grep 慢]', + mentions: [], + timestamp: M0 - 4000, + deliveryStatus: 'queued', + threadId: 'th-1', + }), + ); store.markDelivered(m2.id, M0 + 2000); const adapter = new PawFeelAdapter(store, { pageSize: 1 }); diff --git a/packages/api/test/harness-eval/paw-feel-disposition-capacity.test.js b/packages/api/test/harness-eval/paw-feel-disposition-capacity.test.js index 5a9ce9de47..8c3c860efc 100644 --- a/packages/api/test/harness-eval/paw-feel-disposition-capacity.test.js +++ b/packages/api/test/harness-eval/paw-feel-disposition-capacity.test.js @@ -136,8 +136,8 @@ function appendSevenDayCorpus(messageStore) { const ageMs = 5 * MINUTE + Math.floor((index * span) / (SIGNAL_COUNT - 1)); messages.push( messageStore.append({ + from: { kind: 'agent', catId: 'codex-sol' }, userId: 'user-1', - catId: 'codex-sol', threadId: `thread-${index % 8}`, content: `[爪感差: tool-${index % 12}+capacity-signal-${index}]`, mentions: [], diff --git a/packages/api/test/harness-eval/paw-feel-disposition-reconciler.test.js b/packages/api/test/harness-eval/paw-feel-disposition-reconciler.test.js index 6155c2fe9b..a325aee491 100644 --- a/packages/api/test/harness-eval/paw-feel-disposition-reconciler.test.js +++ b/packages/api/test/harness-eval/paw-feel-disposition-reconciler.test.js @@ -5,6 +5,7 @@ import { derivePawFeelCoverageHealth, PawFeelDispositionReconciler, } from '../../dist/infrastructure/harness-eval/paw-feel-disposition/reconciler.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const DAY = 86_400_000; const MINUTE = 60_000; @@ -76,15 +77,18 @@ class RecordingDiscoveryService { } function append(store, ageMs, content = '[爪感差: tool+phenomenon]', overrides = {}) { - return store.append({ - userId: 'user-1', - catId: 'codex-sol', - threadId: 'thread-source', - content, - mentions: [], - timestamp: NOW_MS - ageMs, - ...overrides, - }); + const catId = overrides.catId === undefined ? 'codex-sol' : overrides.catId; + return store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId, + threadId: 'thread-source', + content, + mentions: [], + timestamp: NOW_MS - ageMs, + ...overrides, + }), + ); } function makeReconciler(overrides = {}) { diff --git a/packages/api/test/harness-eval/paw-feel-source.test.js b/packages/api/test/harness-eval/paw-feel-source.test.js index e458a88f09..32d28a88d5 100644 --- a/packages/api/test/harness-eval/paw-feel-source.test.js +++ b/packages/api/test/harness-eval/paw-feel-source.test.js @@ -8,6 +8,7 @@ import { inspectDeclaredPawFeelMessage, inspectPawFeelMessage, } from '../../dist/infrastructure/harness-eval/friction/paw-feel-source.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const T0 = 1_700_000_000_000; @@ -150,14 +151,16 @@ describe('F278 canonical paw-feel source', () => { it('paginates the complete timeline window without duplicate messages', async () => { const store = new MessageStore(); for (let index = 0; index < 5; index += 1) { - store.append({ - userId: 'u1', - catId: 'codex-sol', - content: `[爪感差: tool${index}+现象${index}]`, - mentions: [], - timestamp: T0 + index, - threadId: 'thread-source', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'codex-sol', + content: `[爪感差: tool${index}+现象${index}]`, + mentions: [], + timestamp: T0 + index, + threadId: 'thread-source', + }), + ); } const messages = await collectPawFeelMessages(store, T0, T0 + 100, { pageSize: 2 }); diff --git a/packages/api/test/harness-eval/publish-verdict-freshness-maturity.test.js b/packages/api/test/harness-eval/publish-verdict-freshness-maturity.test.js index 7ba5ff46cb..b99580263a 100644 --- a/packages/api/test/harness-eval/publish-verdict-freshness-maturity.test.js +++ b/packages/api/test/harness-eval/publish-verdict-freshness-maturity.test.js @@ -132,35 +132,26 @@ async function providerWithWindowedActivity(coverage) { store, fixtureRoot, queueLifecycleSource: { - async listOwnerQueueCustodyLifecycles() { + async listOwnerDurableEntries() { return [ { - messageId: 'message-queue', + id: 'entry-queue', threadId: 'thread-live', userId: 'user-1', - custody: { - version: 1, - entryId: 'entry-queue', - revision: 2, - ownerUserId: 'user-1', - intent: 'respond', - status: 'processing', - allTargetCats: ['codex-sol'], - pendingTargetCats: ['codex-sol'], - notifiedByCatIds: ['codex-sol'], - seenByCatIds: ['codex-sol'], - seenInvocationIdByCatId: { 'codex-sol': 'inv-queue' }, - bodyExposures: [{ targetCatId: 'codex-sol', invocationId: 'inv-queue', seenAt: 1_250 }], - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 1_050, - updatedAt: 1_250, - }, + targets: ['codex-sol'], + enqueuedAt: 1_050, + claimedAt: 1_250, + processingStartedAt: 1_250, + payload: { messageId: 'message-queue' }, }, ]; }, }, + messageLifecycleSource: { + async listOwnerMessagesInWindow() { + return []; + }, + }, attentionEventLog: { async queryWindowBetween() { return { diff --git a/packages/api/test/harness-eval/publish-verdict-freshness.test.js b/packages/api/test/harness-eval/publish-verdict-freshness.test.js index eeaee1f6d6..3825cf7744 100644 --- a/packages/api/test/harness-eval/publish-verdict-freshness.test.js +++ b/packages/api/test/harness-eval/publish-verdict-freshness.test.js @@ -72,7 +72,12 @@ function matureProvider(store = new InMemoryFreshnessClosureStore()) { store, fixtureRoot, queueLifecycleSource: { - async listOwnerQueueCustodyLifecycles() { + async listOwnerDurableEntries() { + return []; + }, + }, + messageLifecycleSource: { + async listOwnerMessagesInWindow() { return []; }, }, diff --git a/packages/api/test/harness-eval/reeval-case-task-dispatch.test.js b/packages/api/test/harness-eval/reeval-case-task-dispatch.test.js index 4389599830..966999c02b 100644 --- a/packages/api/test/harness-eval/reeval-case-task-dispatch.test.js +++ b/packages/api/test/harness-eval/reeval-case-task-dispatch.test.js @@ -5,6 +5,7 @@ import { createReevalCaseTaskQueueDelivery, ReevalCaseTaskDispatcher, } from '../../dist/infrastructure/harness-eval/reeval-case-task-dispatch.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const task = { id: 'task-reeval-1', @@ -165,15 +166,18 @@ describe('F266 durable stable-case task carrier', () => { return { accepted: true }; }); const messageStore = new MessageStore(); - const message = await messageStore.append({ - userId: task.userId, - catId: 'gpt52', - content: 'execute stable-case task', - mentions: ['gpt52'], - timestamp: 100, - threadId: task.threadId, - deliveryStatus: 'queued', - }); + // F117: append requires MessageFrom sender identity + const message = await messageStore.append( + canonicalTestMessageInput({ + userId: task.userId, + catId: 'gpt52', + content: 'execute stable-case task', + mentions: ['gpt52'], + timestamp: 100, + threadId: task.threadId, + deliveryStatus: 'queued', + }), + ); assert.deepEqual( await deliver({ diff --git a/packages/api/test/heartbeat.test.js b/packages/api/test/heartbeat.test.js index 6d65ffe7fc..c318353b73 100644 --- a/packages/api/test/heartbeat.test.js +++ b/packages/api/test/heartbeat.test.js @@ -35,10 +35,13 @@ describe('Heartbeat emission', () => { globalThis.clearInterval = originalClearInterval; }); - it('messages.ts sets up 30s heartbeat interval', async () => { + it('QueueProcessor sets up a 30s heartbeat interval for canonical execution', async () => { // Read the source to verify the constant const fs = await import('node:fs/promises'); - const source = await fs.readFile(new URL('../src/routes/messages.ts', import.meta.url), 'utf8'); + const source = await fs.readFile( + new URL('../src/domains/cats/services/agents/invocation/QueueProcessor.ts', import.meta.url), + 'utf8', + ); // Verify HEARTBEAT_INTERVAL_MS is defined as 30000 assert.ok( @@ -50,9 +53,12 @@ describe('Heartbeat emission', () => { assert.ok(source.includes("'heartbeat'"), 'Should broadcast heartbeat event'); }); - it('heartbeat interval is cleared in finally block', async () => { + it('canonical execution clears its heartbeat interval in finally', async () => { const fs = await import('node:fs/promises'); - const source = await fs.readFile(new URL('../src/routes/messages.ts', import.meta.url), 'utf8'); + const source = await fs.readFile( + new URL('../src/domains/cats/services/agents/invocation/QueueProcessor.ts', import.meta.url), + 'utf8', + ); // Verify clearInterval is called in finally assert.ok(source.includes('clearInterval(heartbeatInterval)'), 'Should clear heartbeat interval in finally block'); @@ -60,6 +66,16 @@ describe('Heartbeat emission', () => { // Verify finally block exists assert.ok(source.includes('} finally {'), 'Should have finally block'); }); + + it('canonical heartbeat does not keep an abandoned test or shutdown process alive', async () => { + const fs = await import('node:fs/promises'); + const source = await fs.readFile( + new URL('../src/domains/cats/services/agents/invocation/QueueProcessor.ts', import.meta.url), + 'utf8', + ); + + assert.ok(source.includes('heartbeatInterval.unref()'), 'Heartbeat timer should not own process liveness'); + }); }); describe('SocketManager heartbeat listener', () => { diff --git a/packages/api/test/helpers/a2a-dispatch-disposition-harness.js b/packages/api/test/helpers/a2a-dispatch-disposition-harness.js deleted file mode 100644 index 1000f235fe..0000000000 --- a/packages/api/test/helpers/a2a-dispatch-disposition-harness.js +++ /dev/null @@ -1,175 +0,0 @@ -import { createCatId } from '@cat-cafe/shared'; - -import { A2ADispatchDispositionService } from '../../dist/domains/ball-custody/A2ADispatchDispositionService.js'; -import { BallCustodyIngest } from '../../dist/domains/ball-custody/BallCustodyIngest.js'; -import { BallCustodyProjector } from '../../dist/domains/ball-custody/BallCustodyProjector.js'; -import { buildHandedEvent } from '../../dist/domains/ball-custody/ball-custody-events.js'; -import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; - -class MemoryEventLog { - events = []; - - async append(event) { - if (this.events.some((candidate) => candidate.sourceEventId === event.sourceEventId)) { - return { appended: false, sequence: -1 }; - } - this.events.push(structuredClone(event)); - return { appended: true, sequence: this.events.length - 1 }; - } - - async appendFenced(event, expectedSequence) { - if (this.events.some((candidate) => candidate.sourceEventId === event.sourceEventId)) { - return { outcome: 'duplicate' }; - } - if (this.events.filter((candidate) => candidate.subjectKey === event.subjectKey).length !== expectedSequence) { - return { - outcome: 'conflict', - actualSequence: this.events.filter((candidate) => candidate.subjectKey === event.subjectKey).length, - }; - } - this.events.push(structuredClone(event)); - return { outcome: 'appended', sequence: expectedSequence }; - } - - async read(subjectKey, fromSequence = 0) { - return this.events.filter((event) => event.subjectKey === subjectKey).slice(fromSequence); - } - - async listSubjects() { - return [...new Set(this.events.map((event) => event.subjectKey))]; - } -} - -class MemoryProjectionStore { - projections = new Map(); - - async get(subjectKey) { - return structuredClone(this.projections.get(subjectKey) ?? null); - } - - async save(projection) { - this.projections.set(projection.subjectKey, structuredClone(projection)); - } - - async listSubjectKeys() { - return [...this.projections.keys()]; - } - - async delete(subjectKey) { - this.projections.delete(subjectKey); - } -} - -export async function createA2ADispositionHarness({ - beforeDispositionRecord, - sourceCatId = 'fable5', - crossPostSourceThreadId, - deliveryStatus, - registry, - sourceExtra, - log, -} = {}) { - const eventLog = new MemoryEventLog(); - const projectionStore = new MemoryProjectionStore(); - const projector = new BallCustodyProjector(eventLog, projectionStore); - const ingest = new BallCustodyIngest(eventLog, projector); - const messageStore = new MessageStore(); - const source = messageStore.append({ - userId: 'user-1', - catId: createCatId(sourceCatId), - content: '@codex-sol review complete', - mentions: [createCatId('codex-sol')], - timestamp: 1_000, - threadId: 'thread-1', - origin: 'stream', - ...(deliveryStatus ? { deliveryStatus } : {}), - ...(crossPostSourceThreadId || sourceExtra - ? { - extra: { - ...(crossPostSourceThreadId - ? { - crossPost: { - sourceThreadId: crossPostSourceThreadId, - sourceInvocationId: 'source-invocation', - }, - } - : {}), - ...sourceExtra, - }, - } - : {}), - }); - await ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: sourceCatId, - toCatId: 'codex-sol', - messageId: source.id, - at: 1_000, - }), - ); - let latest = true; - const fencedIngest = beforeDispositionRecord - ? { - record: (event) => ingest.record(event), - async recordFenced(event, expectedSequence) { - if (event.kind === 'ball.dispatch_dispositioned') { - await beforeDispositionRecord({ event, ingest }); - } - return ingest.recordFenced(event, expectedSequence); - }, - } - : ingest; - const service = new A2ADispatchDispositionService({ - registry: registry ?? { isLatest: async (invocationId) => latest && invocationId === 'inv-1' }, - messageStore, - ballCustodyEventLog: eventLog, - ballCustodyProjectionStore: projectionStore, - ballCustody: fencedIngest, - ...(log ? { log } : {}), - repairProjection: (subjectKey) => projector.rebuild(subjectKey), - now: () => 2_000, - }); - return { - eventLog, - projectionStore, - ingest, - messageStore, - source, - service, - setLatest(value) { - latest = value; - }, - }; -} - -export function createA2ADispositionAuth(harness, overrides = {}) { - return { - invocationId: 'inv-1', - callbackToken: 'token', - userId: 'user-1', - ownerAuthProvenance: 'unknown', - catId: createCatId('codex-sol'), - threadId: 'thread-1', - a2aTriggerMessageId: harness.source.id, - originTriggerMessageId: harness.source.id, - clientMessageIds: new Set(), - createdAt: 1, - expiresAt: 99_000, - ...overrides, - }; -} - -export function createA2ADispositionWake(harness) { - return { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - handoff: { - sourceEventId: `route:${harness.source.id}:codex-sol`, - messageId: harness.source.id, - fromCatId: 'fable5', - }, - }; -} diff --git a/packages/api/test/helpers/artifact-review-live-fixture.ts b/packages/api/test/helpers/artifact-review-live-fixture.ts index f7e3758153..96f9c6a8c7 100644 --- a/packages/api/test/helpers/artifact-review-live-fixture.ts +++ b/packages/api/test/helpers/artifact-review-live-fixture.ts @@ -46,7 +46,7 @@ export async function createLiveReviewFixture( function publish(name = `review-input.${extension}`) { return messages.append({ userId: 'operator', - catId, + from: { kind: 'agent', catId }, threadId: thread.id, mentions: [], timestamp: Date.now(), diff --git a/packages/api/test/helpers/dual-store-harness.js b/packages/api/test/helpers/dual-store-harness.js index 205627295a..35a7161585 100644 --- a/packages/api/test/helpers/dual-store-harness.js +++ b/packages/api/test/helpers/dual-store-harness.js @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -100,15 +101,17 @@ function createTestContext(store, storeType, redis) { * @param {string} [opts.idempotencyKey] */ async function appendDirect(opts) { - const msg = await store.append({ - userId: opts.userId ?? userId, - catId: opts.catId ?? null, - content: opts.content, - mentions: opts.mentions ?? [], - timestamp: opts.timestamp, - threadId, - idempotencyKey: opts.idempotencyKey, - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: opts.userId ?? userId, + catId: opts.catId ?? null, + content: opts.content, + mentions: opts.mentions ?? [], + timestamp: opts.timestamp, + threadId, + idempotencyKey: opts.idempotencyKey, + }), + ); return msg; } @@ -120,18 +123,20 @@ function createTestContext(store, storeType, redis) { * Pass catId: 'opus' explicitly when testing timeline-published cat speech. */ async function appendQueued(opts) { - const msg = await store.append({ - userId: opts.userId ?? userId, - catId: opts.catId ?? null, - content: opts.content, - mentions: opts.mentions ?? [], - timestamp: opts.timestamp, - threadId, - deliveryStatus: 'queued', - idempotencyKey: opts.idempotencyKey, - origin: opts.origin, - extra: opts.extra, - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: opts.userId ?? userId, + catId: opts.catId ?? null, + content: opts.content, + mentions: opts.mentions ?? [], + timestamp: opts.timestamp, + threadId, + deliveryStatus: 'queued', + idempotencyKey: opts.idempotencyKey, + origin: opts.origin, + extra: opts.extra, + }), + ); return msg; } @@ -205,14 +210,16 @@ function createTestContext(store, storeType, redis) { } // Memory store: append normally with the timestamp const timestamp = typeof score === 'number' && Number.isFinite(score) && score > 0 ? Math.floor(score) : Date.now(); - const msg = store.append({ - userId, - catId: null, - content: content ?? `legacy-${id}`, - mentions: mentions ?? [], - timestamp, - threadId, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: content ?? `legacy-${id}`, + mentions: mentions ?? [], + timestamp, + threadId, + }), + ); return msg; } diff --git a/packages/api/test/helpers/f247-first-message-harness.js b/packages/api/test/helpers/f247-first-message-harness.js deleted file mode 100644 index 4ae1320086..0000000000 --- a/packages/api/test/helpers/f247-first-message-harness.js +++ /dev/null @@ -1,234 +0,0 @@ -import assert from 'node:assert/strict'; -import { catRegistry } from '@cat-cafe/shared'; -import Fastify from 'fastify'; -import { WaitContinuationRetryCommitter } from '../../dist/domains/ball-custody/WaitContinuationRetryCommitter.js'; -import { WaitContinuationRetryPreflight } from '../../dist/domains/ball-custody/WaitContinuationRetryPreflight.js'; -import { InvocationQueue } from '../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationRegistry } from '../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; -import { InvocationTracker } from '../../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { QueuedMessageCustodyCoordinator } from '../../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { QueuedMessageCustodyStartupReconciler } from '../../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.js'; -import { QueueProcessor } from '../../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { routeParallel } from '../../dist/domains/cats/services/agents/routing/route-parallel.js'; -import { CloudInvokeBridge } from '../../dist/domains/cats/services/cloud-bridge/cloud-invoke-bridge.js'; -import { InMemoryTurnExecutionStore } from '../../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js'; -import { InvocationRecordStore } from '../../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'; -import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { ThreadStore } from '../../dist/domains/cats/services/stores/ports/ThreadStore.js'; -import { messagesRoutes } from '../../dist/routes/messages.js'; -import { threadsRoutes } from '../../dist/routes/threads.js'; - -export async function waitFor(predicate, detail = 'production route completion') { - const deadline = Date.now() + 4000; - while (!(await predicate())) { - assert.ok(Date.now() < deadline, `Timed out: ${detail}`); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -export async function firstMessageHarness({ - bound = false, - targetCats = ['gpt-pro'], - beforeAppend, - configureApp, -} = {}) { - if (!catRegistry.has('gpt-pro')) { - catRegistry.register('gpt-pro', { - catId: 'gpt-pro', - provider: 'openai-chatgpt-pro', - clientId: 'openai', - avatar: '/avatars/gpt-pro.png', - }); - } - const userId = 'first-message-owner'; - const messageStore = new MessageStore(); - // Production Redis supplies this scan; the memory test store intentionally does not. - messageStore.scanByDeliveryStatus = (status) => - messageStore - .getRecent(2_000) - .filter((message) => message.deliveryStatus === status) - .map((message) => message.id); - const threadStore = new ThreadStore(); - const thread = await threadStore.create(userId, 'First cloud message'); - const registry = new InvocationRegistry(); - const invocationRecordStore = new InvocationRecordStore(); - const turnExecutionStore = new InMemoryTurnExecutionStore(); - const tracker = new InvocationTracker(); - const hostCalls = []; - const routeCalls = []; - const broadcasts = []; - const logs = []; - const log = Object.fromEntries( - ['info', 'warn', 'error'].map((level) => [level, (...args) => logs.push({ level, args })]), - ); - const socketManager = { - broadcastAgentMessage: (message) => broadcasts.push(message), - broadcastToRoom: () => {}, - emitToUser: () => {}, - }; - const bridge = new CloudInvokeBridge({ - threadStore, - hostAdapter: { - append_message: async (conversationId, text, idempotencyKey) => { - const replay = hostCalls.some((call) => call.idempotencyKey === idempotencyKey); - hostCalls.push({ conversationId, text, idempotencyKey }); - return { hostMessageId: `host-${idempotencyKey}`, idempotentReplay: replay }; - }, - }, - emitFallback: async () => {}, - }); - if (bound) await threadStore.updateCloudCatBinding(thread.id, 'gpt-pro', 'https://chatgpt.com/c/bound-chat'); - const routeDeps = { - services: Object.fromEntries( - targetCats.map((catId) => [ - catId, - { - usesChainKeyResume: () => false, - freshnessCarrierCapability: () => ({ provider: 'other', carrier: 'other', deliverySemantics: 'undeclared' }), - async *invoke() { - if (catId === 'gpt-pro') throw new Error('Cloud transport must not enter provider CLI'); - yield { type: 'text', catId, content: 'local reply', timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }, - ]), - ), - invocationDeps: { - registry, - sessionManager: { get: async () => null, set: async () => {} }, - threadStore, - apiUrl: 'http://localhost:0', - cloudInvokeBridge: bridge, - cloudReturnGrantStore: { issue: async () => ({ ok: true, status: 'issued' }) }, - turnExecutionStore, - }, - messageStore, - socketManager, - draftStore: { delete: async () => {}, touch: async () => {}, upsert: async () => {} }, - }; - const router = { - resolveTargetsAndIntent: async () => ({ targetCats, intent: { intent: 'execute' } }), - resolveExplicitTargets: async (targets) => targets, - ackCollectedCursors: async () => {}, - async *routeExecution(owner, content, threadId, messageId, targets, _intent, options) { - routeCalls.push({ messageId, targets }); - yield* routeParallel(routeDeps, targets, content, owner, threadId, { - ...options, - currentUserMessageId: messageId, - }); - }, - }; - let queue = new InvocationQueue(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const makeProcessor = () => - new QueueProcessor({ - queue, - messageStore, - threadStore, - queueCustodyCoordinator: coordinator, - invocationTracker: tracker, - invocationRecordStore, - turnExecutionStore, - router, - socketManager, - log, - }); - let processor = makeProcessor(); - const taskStore = { get: () => null }; - const opts = { - registry, - messageStore, - threadStore, - router, - socketManager, - invocationRecordStore, - invocationTracker: tracker, - turnExecutionStore, - invocationQueue: queue, - queueProcessor: processor, - retryAuthorityPreflight: new WaitContinuationRetryPreflight({ taskStore }), - retryAuthorityCommitter: new WaitContinuationRetryCommitter({ messageStore, taskStore }), - }; - if (beforeAppend) { - const append = messageStore.append.bind(messageStore); - messageStore.append = async (input) => { - await beforeAppend(input); - return append(input); - }; - } - let app; - const openApp = async () => { - app = Fastify(); - await configureApp?.(app, { userId, thread, hostCalls, routeCalls, broadcasts }); - await app.register(messagesRoutes, opts); - await app.register(threadsRoutes, opts); - await app.ready(); - }; - await openApp(); - const request = (method, url, payload, owner = userId) => - app.inject({ - method, - url, - headers: { 'x-cat-cafe-user': owner }, - ...(payload ? { payload } : {}), - }); - const settle = () => - waitFor( - () => - broadcasts.some((event) => event.type === 'done') && - !tracker.has(thread.id) && - !processor.hasActiveExecution(thread.id), - JSON.stringify(logs), - ); - return { - userId, - thread, - messageStore, - invocationRecordStore, - turnExecutionStore, - tracker, - hostCalls, - routeCalls, - broadcasts, - logs, - get app() { - return app; - }, - get queue() { - return queue; - }, - get processor() { - return processor; - }, - request, - settle, - send: (extra = {}) => - request('POST', '/api/messages', { content: '@gpt-pro hello first source', threadId: thread.id, ...extra }), - bind: () => - request('PATCH', `/api/threads/${thread.id}/cloud-bindings`, { - catId: 'gpt-pro', - chatUrl: 'https://chatgpt.com/c/bound-chat', - }), - authority: (messageId, owner) => - request('GET', `/api/messages/${messageId}/queue-targets/gpt-pro/retry-authority`, undefined, owner), - retry: (messageId, attemptId, owner) => - request('POST', `/api/messages/${messageId}/queue-targets/gpt-pro/retry`, { attemptId }, owner), - async restart() { - await app.close(); - queue = new InvocationQueue(); - processor = makeProcessor(); - opts.invocationQueue = queue; - opts.queueProcessor = processor; - const recovered = await new QueuedMessageCustodyStartupReconciler({ - messageStore, - invocationRecordStore, - turnExecutionStore, - invocationQueue: queue, - log, - }).reconcile(); - await openApp(); - return recovered; - }, - close: () => app.close(), - }; -} diff --git a/packages/api/test/helpers/incremental-context-helpers.js b/packages/api/test/helpers/incremental-context-helpers.js index 8c4737fb77..e84d0803cf 100644 --- a/packages/api/test/helpers/incremental-context-helpers.js +++ b/packages/api/test/helpers/incremental-context-helpers.js @@ -1,9 +1,10 @@ export function mockMsg(overrides) { const ts = overrides.timestamp ?? Date.now(); + const catId = overrides.catId ?? null; return { threadId: overrides.threadId ?? 'thread-1', userId: overrides.userId ?? 'user-1', - catId: overrides.catId ?? null, + from: overrides.from ?? (catId ? { kind: 'agent', catId } : { kind: 'user', userId: overrides.userId ?? 'user-1' }), content: overrides.content ?? 'test message', mentions: overrides.mentions ?? [], timestamp: ts, diff --git a/packages/api/test/helpers/issue1371-cursor-harness.js b/packages/api/test/helpers/issue1371-cursor-harness.js index ff4f8d5343..0ffd8e71e3 100644 --- a/packages/api/test/helpers/issue1371-cursor-harness.js +++ b/packages/api/test/helpers/issue1371-cursor-harness.js @@ -3,6 +3,7 @@ import { FreshnessOutputCommitCoordinator } from '../../dist/domains/cats/servic import { DeliveryCursorStore } from '../../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'; import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; import { ThreadStore } from '../../dist/domains/cats/services/stores/ports/ThreadStore.js'; +import { adaptMessageStore } from './message-from-fixtures.js'; export function deferred() { let resolve; @@ -14,7 +15,8 @@ export function deferred() { export async function cursorHarness(services, messageStore = new MessageStore()) { let sequence = 0; - const source = await messageStore.append({ + const canonicalMessageStore = adaptMessageStore(messageStore); + const source = await canonicalMessageStore.append({ userId: 'user-1', threadId: 'thread-cursor', catId: null, @@ -26,7 +28,7 @@ export async function cursorHarness(services, messageStore = new MessageStore()) const deliveryCursorStore = new DeliveryCursorStore(); const deps = { services, - messageStore, + messageStore: canonicalMessageStore, deliveryCursorStore, invocationDeps: { registry: { @@ -42,7 +44,7 @@ export async function cursorHarness(services, messageStore = new MessageStore()) apiUrl: 'http://127.0.0.1:3102', }, freshnessOutputCommitCoordinator: new FreshnessOutputCommitCoordinator({ - messageStore, + messageStore: canonicalMessageStore, closureStore: new InMemoryFreshnessClosureStore(), }), socketManager: { broadcastToRoom() {} }, diff --git a/packages/api/test/helpers/issue1371-cursor-proof-harness.js b/packages/api/test/helpers/issue1371-cursor-proof-harness.js index 6cda119403..0a5d4ec6b9 100644 --- a/packages/api/test/helpers/issue1371-cursor-proof-harness.js +++ b/packages/api/test/helpers/issue1371-cursor-proof-harness.js @@ -1,9 +1,11 @@ import { assembleIncrementalContext } from '../../dist/domains/cats/services/agents/routing/route-helpers.js'; import { cursorFor } from '../../dist/domains/cats/services/stores/cursor.js'; import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { adaptMessageStore } from './message-from-fixtures.js'; export async function proofFixture(store = new MessageStore(), mutate = (proof) => proof) { - const source = await store.append({ + const canonicalMessageStore = adaptMessageStore(store); + const source = await canonicalMessageStore.append({ userId: 'user-1', threadId: 'thread-proof', catId: null, @@ -11,7 +13,9 @@ export async function proofFixture(store = new MessageStore(), mutate = (proof) mentions: ['opus'], timestamp: Date.now(), }); - const boundary = cursorFor((await store.getByThreadAfter('thread-proof', undefined, undefined, 'user-1'))[0]); + const boundary = cursorFor( + (await canonicalMessageStore.getByThreadAfter('thread-proof', undefined, undefined, 'user-1'))[0], + ); const proof = mutate({ v: 1, cursor: boundary, @@ -21,7 +25,7 @@ export async function proofFixture(store = new MessageStore(), mutate = (proof) turnInvocationId: 'child-opus', sourceMessageId: source.id, }); - const reply = await store.append({ + const reply = await canonicalMessageStore.append({ userId: 'user-1', threadId: 'thread-proof', catId: 'opus', @@ -35,7 +39,7 @@ export async function proofFixture(store = new MessageStore(), mutate = (proof) deliveryBoundary: proof, }, }); - return { store, source, reply, proof, boundary }; + return { store: canonicalMessageStore, source, reply, proof, boundary }; } export async function coldContext(store, cursors, explicit) { diff --git a/packages/api/test/helpers/issue1371-direct-witness-harness.js b/packages/api/test/helpers/issue1371-direct-witness-harness.js deleted file mode 100644 index 44e0534728..0000000000 --- a/packages/api/test/helpers/issue1371-direct-witness-harness.js +++ /dev/null @@ -1,342 +0,0 @@ -import assert from 'node:assert/strict'; -import { mock } from 'node:test'; -import { setTimeout as delay } from 'node:timers/promises'; -import Fastify from 'fastify'; -import { InvocationQueue } from '../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationRegistry } from '../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; -import { InvocationTracker } from '../../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { QueueProcessor } from '../../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { InMemoryTurnExecutionStore } from '../../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js'; -import { InvocationRecordStore } from '../../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'; -import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { generateScriptViaThread } from '../../dist/domains/signals/services/podcast-generator.js'; -import { ConnectorInvokeTrigger } from '../../dist/infrastructure/email/ConnectorInvokeTrigger.js'; -import { triggerA2AInvocation } from '../../dist/routes/callback-a2a-trigger.js'; -import { invocationsRoutes } from '../../dist/routes/invocations.js'; -import { messagesRoutes } from '../../dist/routes/messages.js'; -import { queueRoutes } from '../../dist/routes/queue.js'; - -// Real HTTP ingress, QueueProcessor, custody CAS, and parent/child stores. The -// route event stream substitutes only for provider execution, not settlement. -export async function runDirectWitnessScenario( - t, - { - ingress = 'message', - messageUserId = 'scheduler', - proof = 'exact', - messageStore = new MessageStore(), - useHttp = false, - } = {}, -) { - const threadId = 'thread-direct-witness'; - const userId = 'user-1'; - const authorCat = ingress === 'podcast' ? 'opus' : 'codex'; - const reviewerCat = authorCat === 'opus' ? 'codex' : 'opus'; - const invocationQueue = new InvocationQueue(); - const invocationTracker = new InvocationTracker(); - const invocationRecordStore = new InvocationRecordStore(); - const turnExecutionStore = new InMemoryTurnExecutionStore(); - const queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const socketManager = { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }; - const log = { info: mock.fn(), warn: mock.fn(), error: mock.fn(), debug: mock.fn() }; - const holds = []; - const calls = []; - const completions = []; - let review; - let parentId; - - async function enqueue(content, { taskId, catId = authorCat } = {}) { - const entry = invocationQueue.enqueue({ - threadId, - userId, - ownerAuthProvenance: 'unknown', - content, - source: taskId ? 'connector' : 'agent', - sourceCategory: taskId ? 'scheduled' : 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - }).entry; - assert.ok(entry); - const message = await messageStore.append({ - threadId, - userId: taskId ? messageUserId : userId, - catId: taskId ? null : authorCat, - content, - mentions: [catId], - timestamp: entry.createdAt, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(entry), - ...(taskId - ? { - source: { - connector: 'hold-ball', - label: '持球通知', - icon: 'timer', - meta: { taskId, threadId, catId, wakeWhen: true }, - }, - } - : {}), - }); - invocationQueue.backfillMessageId(threadId, userId, entry.id, message.id); - return { entry, message }; - } - - const router = { - resolveTargetsAndIntent: async () => ({ - targetCats: [authorCat], - intent: { intent: 'execute', explicit: true, promptTags: [] }, - }), - ackCollectedCursors: async () => {}, - routeExecution: async function* (_userId, _content, _threadId, triggerId, targets, _intent, opts) { - calls.push([...targets]); - const catId = targets[0]; - const childId = `child-${catId}-${calls.length}`; - if (catId === authorCat) parentId = opts.parentInvocationId; - turnExecutionStore.createRunning({ - invocationId: childId, - parentInvocationId: opts.parentInvocationId, - threadId, - userId, - catId, - executionKind: 'ordinary', - startedAt: Date.now(), - }); - yield { - type: 'system_info', - catId, - invocationId: childId, - timestamp: Date.now(), - content: JSON.stringify({ - type: 'invocation_created', - invocationId: childId, - parentInvocationId: opts.parentInvocationId, - executionKind: 'ordinary', - startedAt: Date.now(), - }), - }; - let witnesses; - if (catId === authorCat) { - assert.equal(calls.length, 1, 'an already handled command must never re-enter the provider'); - holds.push(await enqueue('gate complete one', { taskId: 'task-one' })); - holds.push(await enqueue('gate complete two', { taskId: 'task-two' })); - review = await enqueue('please review the finished change', { catId: reviewerCat }); - const adopted = await opts.onPromptMessagesExposed({ - threadId, - userId, - catId, - invocationId: childId, - messageIds: holds.map(({ message }) => message.id), - seenAt: Date.now(), - }); - assert.equal(adopted.length, 2, 'both exact managed wakes were exposed to this child'); - witnesses = adopted.map((wake) => ({ - kind: 'managed_hold_continued', - sourceMessageId: wake.sourceMessageId, - taskId: wake.taskId, - transition: 'transferred', - })); - if (proof === 'missing') witnesses = undefined; - if (proof === 'wrong source') witnesses = witnesses.map((w) => ({ ...w, sourceMessageId: 'foreign-source' })); - if (proof === 'wrong task') witnesses = witnesses.map((w) => ({ ...w, taskId: 'foreign-task' })); - } else { - assert.equal(triggerId, review.message.id); - await opts.onPromptMessagesExposed({ - threadId, - userId, - catId, - invocationId: childId, - messageIds: [triggerId], - seenAt: Date.now(), - }); - } - turnExecutionStore.transitionTerminal(childId, { status: 'succeeded', endedAt: Date.now() }); - if (ingress === 'podcast') { - yield { - type: 'text', - catId, - content: '{"segments":[{"speaker":"宪宪","text":"测试","durationEstimate":3}],"totalDuration":3}', - }; - } - yield { - type: 'done', - catId, - invocationId: proof === 'wrong child' ? 'foreign-child' : childId, - isFinal: true, - timestamp: Date.now(), - ...(witnesses ? { turnCustodyTerminalWitness: witnesses[0], turnCustodyTerminalWitnesses: witnesses } : {}), - }; - }, - }; - const processor = new QueueProcessor({ - queue: invocationQueue, - invocationTracker, - invocationRecordStore, - messageStore, - queueCustodyCoordinator, - turnExecutionStore, - router, - socketManager, - log, - }); - const complete = processor.onInvocationComplete.bind(processor); - processor.onInvocationComplete = (...args) => { - const completion = complete(...args); - completions.push(completion); - return completion; - }; - const executionDeps = { - router, - invocationRecordStore, - invocationTracker, - invocationQueue, - queueProcessor: processor, - queueCustodyCoordinator, - messageStore, - socketManager, - log, - }; - const app = Fastify(); - t.after(() => app.close()); - const threadStore = { get: async () => ({ id: threadId, createdBy: userId }) }; - await app.register(messagesRoutes, { - registry: new InvocationRegistry(), - router, - messageStore, - socketManager, - invocationTracker, - invocationQueue, - invocationRecordStore, - queueProcessor: processor, - threadStore, - }); - await app.register(queueRoutes, { - threadStore, - invocationQueue, - invocationTracker, - queueProcessor: processor, - queueCustodyCoordinator, - messageStore, - socketManager, - }); - await app.register(invocationsRoutes, { - invocationRecordStore, - messageStore, - socketManager, - router, - invocationTracker, - queueProcessor: processor, - }); - const address = useHttp ? await app.listen({ host: '127.0.0.1', port: 0 }) : undefined; - async function request(options) { - if (!address) return app.inject(options); - const response = await fetch(address + options.url, { - method: options.method, - headers: { ...options.headers, 'content-type': 'application/json' }, - ...(options.payload ? { body: JSON.stringify(options.payload) } : {}), - }); - const body = await response.text(); - return { statusCode: response.status, body, json: () => JSON.parse(body) }; - } - if (ingress === 'message') { - const response = await request({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': userId }, - payload: { threadId, content: 'continue the authorized fix', targetCats: ['codex'] }, - }); - assert.equal(response.statusCode, 200, response.body); - } else if (ingress === 'podcast') { - await generateScriptViaThread( - { - articleId: 'direct-witness', - articleFilePath: '/tmp/issue1371-article.md', - articleTitle: 'Queue evidence', - articleContent: 'An exact adopted completion.', - mode: 'essence', - requestedBy: userId, - }, - threadId, - executionDeps, - ); - } else { - const source = await messageStore.append({ - threadId, - userId, - catId: ingress === 'standalone A2A' ? 'opus' : null, - content: 'continue the authorized fix', - mentions: ['codex'], - timestamp: Date.now(), - }); - if (ingress === 'retry') { - const created = invocationRecordStore.create({ - threadId, - userId, - targetCats: ['codex'], - intent: 'execute', - idempotencyKey: 'retry-parent', - actionLeaseCarrier: { kind: 'none' }, - }); - invocationRecordStore.update(created.invocationId, { status: 'running', userMessageId: source.id }); - invocationRecordStore.update(created.invocationId, { status: 'failed', error: 'prior provider failure' }); - const response = await request({ - method: 'POST', - url: `/api/invocations/${created.invocationId}/retry`, - headers: { 'x-cat-cafe-user': userId }, - }); - assert.equal(response.statusCode, 202, response.body); - } else if (ingress === 'connector') { - const trigger = new ConnectorInvokeTrigger(executionDeps); - assert.equal(await trigger.trigger(threadId, 'codex', userId, source.content, source.id), 'dispatched'); - } else { - await triggerA2AInvocation(executionDeps, { - targetCats: ['codex'], - userId, - ownerAuthProvenance: 'unknown', - threadId, - content: source.content, - triggerMessage: source, - }); - } - } - async function waitUntil(predicate, reason) { - const deadline = Date.now() + 5000; - while (!predicate() && Date.now() < deadline) await delay(10); - assert.ok(predicate(), reason); - } - await waitUntil(() => completions.length > 0, 'direct route must complete'); - await completions[0]; - assert.equal(invocationRecordStore.get(parentId).status, 'succeeded'); - assert.equal(holds.length, 2); - if (proof !== 'exact') { - assert.deepEqual(calls, [[authorCat]], 'invalid evidence cannot authorize source consumption or blind retry'); - for (const { message } of holds) { - const custody = (await messageStore.getById(message.id)).queueCustody; - assert.equal(custody.status, 'queued'); - assert.deepEqual(custody.handledByCatIds, []); - } - return; - } - for (const { message } of holds) { - const settled = await messageStore.getById(message.id); - assert.equal(settled.queueCustody.status, 'terminal', 'adopted command receipt must settle before dequeue'); - assert.equal(settled.queueCustody.targetOutcomeByCatId[authorCat].disposition, 'managed_hold_disposition'); - assert.equal(settled.queueCustody.targetOutcomeByCatId[authorCat].consumption.sourceMessageId, message.id); - } - await waitUntil(() => invocationQueue.list(threadId, userId).length === 0, 'review must drain the queue'); - await Promise.all(completions); - assert.deepEqual(calls, [[authorCat], [reviewerCat]], 'review starts once, with no Steer or repeat command'); - assert.deepEqual(invocationQueue.list(threadId, userId), []); - const snapshot = await request({ - method: 'GET', - url: `/api/threads/${threadId}/queue`, - headers: { 'x-cat-cafe-user': userId }, - }); - assert.deepEqual(snapshot.json().queue, []); - assert.equal(log.error.mock.calls.length, 0); - return { sourceMessageIds: holds.map(({ message }) => message.id), calls }; -} diff --git a/packages/api/test/helpers/issue1371-terminal-queue-harness.js b/packages/api/test/helpers/issue1371-terminal-queue-harness.js deleted file mode 100644 index 57ef082150..0000000000 --- a/packages/api/test/helpers/issue1371-terminal-queue-harness.js +++ /dev/null @@ -1,142 +0,0 @@ -import { mock } from 'node:test'; -import { buildHandedCvoEvent } from '../../dist/domains/ball-custody/ball-custody-events.js'; -import { InvocationQueue } from '../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationTracker } from '../../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - createInitialCrossThreadQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { QueueProcessor } from '../../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { createA2ADispositionHarness } from './a2a-dispatch-disposition-harness.js'; - -export async function runTerminalQueueHarness(scenario = 'active control') { - const h = await createA2ADispositionHarness({ - ...(scenario === 'retirement unavailable' - ? { - beforeDispositionRecord: async () => { - throw new Error('retirement store unavailable'); - }, - } - : {}), - }); - h.source.extra = { - crossPost: { sourceThreadId: 'thread-origin', sourceInvocationId: 'origin-invocation' }, - coordination: { id: 'coord-review', phase: 'active', hop: 1, subjectRef: 'task:review' }, - targetCats: ['codex-sol'], - }; - const terminal = h.messageStore.append({ - userId: 'user-1', - catId: 'codex-sol', - content: '@fable5 terminal result', - mentions: ['fable5'], - timestamp: 1_750, - threadId: 'thread-origin', - origin: 'callback', - deliveryStatus: 'queued', - extra: { - crossPost: { sourceThreadId: 'thread-1', sourceInvocationId: 'inv-1' }, - coordination: { id: 'coord-review', phase: 'terminal', hop: 2, subjectRef: 'task:review' }, - causal: { kind: 'invocation_reply', triggerMessageId: h.source.id }, - stream: { invocationId: 'inv-1', turnInvocationId: 'inv-1' }, - targetCats: ['fable5'], - }, - }); - h.setLatest(false); - if (scenario === 'done_notify') { - await h.ingest.record( - buildHandedCvoEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - messageId: 'unrelated-cvo-message', - intent: 'done_notify', - at: 1_751, - }), - ); - } - const queue = new InvocationQueue(); - const deps = { - queue, - messageStore: h.messageStore, - a2aDispatchDispositionService: h.service, - queueCustodyCoordinator: new QueuedMessageCustodyCoordinator({ messageStore: h.messageStore }), - invocationTracker: new InvocationTracker(), - invocationRecordStore: { - create: async () => ({ outcome: 'created', invocationId: 'inv-stub' }), - update: async () => {}, - }, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - router: { - routeExecution: mock.fn(async function* (...args) { - await args[6].onPromptMessagesExposed({ - threadId: terminal.threadId, - userId: terminal.userId, - catId: 'fable5', - invocationId: 'terminal-child', - messageIds: [terminal.id], - seenAt: 1_760, - }); - h.messageStore.append({ - userId: terminal.userId, - catId: 'fable5', - content: 'Review finished.', - mentions: [], - timestamp: 1_765, - threadId: terminal.threadId, - replyTo: terminal.id, - extra: { - causal: { kind: 'invocation_reply', triggerMessageId: terminal.id }, - stream: { invocationId: 'inv-stub', turnInvocationId: 'terminal-child' }, - }, - }); - yield { - type: 'done', - catId: 'fable5', - invocationId: 'terminal-child', - timestamp: 1_770, - turnCustodyTerminalWitness: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - }; - }), - ackCollectedCursors: async () => {}, - }, - }; - const { entry } = queue.enqueue({ - threadId: terminal.threadId, - userId: terminal.userId, - ownerAuthProvenance: 'unknown', - content: terminal.content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['fable5'], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex-sol', - a2aParentInvocationId: 'inv-1', - a2aTriggerMessageId: terminal.id, - }); - queue.backfillMessageId(terminal.threadId, terminal.userId, entry.id, terminal.id); - const queued = queue.getEntrySnapshot(terminal.threadId, terminal.userId, entry.id); - h.messageStore.initializeQueueCustody( - terminal.id, - createInitialCrossThreadQueuedMessageCustody(terminal.id, [queued]), - ); - const processor = new QueueProcessor(deps); - const result = await processor.executeEntry(queue.markProcessing(terminal.threadId, terminal.userId)); - await processor.onInvocationComplete( - terminal.threadId, - 'fable5', - result.status, - result.invocationId, - result.successfulCatIds, - result.primaryEntryRequeued, - result.terminalInvocationIdByCatId, - result.attemptedQueueEntryIds, - result.terminalConsumptionByInvocationId, - ); - const custody = h.messageStore.getById(terminal.id).queueCustody; - return { h, terminal, queue, deps, entry, queued, processor, result, custody }; -} diff --git a/packages/api/test/helpers/message-from-fixtures.js b/packages/api/test/helpers/message-from-fixtures.js new file mode 100644 index 0000000000..bd8171886e --- /dev/null +++ b/packages/api/test/helpers/message-from-fixtures.js @@ -0,0 +1,125 @@ +/** + * RFC #1356 test-fixture bridge. + * + * Historical suites often build Store/Queue inputs with the old catId/source + * projections. Production writers must never rely on this bridge: it is + * intentionally scoped to tests so the runtime append/enqueue boundaries stay + * fail-closed when MessageFrom is absent. + */ + +const MESSAGE_STORE_ADAPTED = Symbol('message-store-from-fixtures'); +const INVOCATION_QUEUE_ADAPTED = Symbol('invocation-queue-from-fixtures'); +let adaptedQueueSourceSequence = 0; + +export function canonicalTestMessageInput(input) { + const { catId, lifecycle: legacyLifecycle, ...rest } = input; + const { from: lifecycleFrom, ...lifecycle } = legacyLifecycle ?? {}; + const from = + input.from ?? + lifecycleFrom ?? + (catId === 'system' + ? { kind: 'system', service: input.source?.connector ?? 'test-fixture' } + : input.extra?.pluginMessage?.instanceId + ? { kind: 'plugin', instanceId: input.extra.pluginMessage.instanceId } + : catId + ? { kind: 'agent', catId } + : input.userId === 'system' || input.userId === 'scheduler' + ? { kind: 'system', service: input.source?.connector ?? 'test-fixture' } + : input.source + ? { + kind: 'external', + connectorId: input.source.connector, + ...(input.source.sender ? { sender: input.source.sender } : {}), + } + : { kind: 'user', userId: input.userId }); + return { + ...rest, + from, + ...(legacyLifecycle ? { lifecycle } : {}), + }; +} + +export function adaptMessageStore(store) { + if (store[MESSAGE_STORE_ADAPTED]) return store; + const append = store.append.bind(store); + const appendWithQueueLedgerAdmission = store.appendWithQueueLedgerAdmission?.bind(store); + store.append = (input) => append(canonicalTestMessageInput(input)); + if (appendWithQueueLedgerAdmission) { + store.appendWithQueueLedgerAdmission = (input, buildAdmission, ledgerStore, maxQueuedUserEntries) => + appendWithQueueLedgerAdmission( + canonicalTestMessageInput(input), + buildAdmission, + ledgerStore, + maxQueuedUserEntries, + ); + } + Object.defineProperty(store, MESSAGE_STORE_ADAPTED, { value: true }); + return store; +} + +export function canonicalTestQueueInput(input) { + if (input.from) return { ownerAuthProvenance: 'unknown', ...input }; + const { source = 'user', callerCatId, senderMeta, ...rest } = input; + const from = + source === 'agent' + ? { kind: 'agent', catId: callerCatId ?? 'opus' } + : source === 'connector' + ? { + kind: 'external', + connectorId: senderMeta?.connector ?? 'test-fixture', + ...(senderMeta?.id + ? { sender: { id: senderMeta.id, ...(senderMeta.name ? { name: senderMeta.name } : {}) } } + : {}), + } + : source === 'system' + ? { kind: 'system', service: 'test-fixture' } + : { kind: 'user', userId: input.userId }; + return { ownerAuthProvenance: 'unknown', ...rest, from }; +} + +export function adaptInvocationQueue(queue) { + if (queue[INVOCATION_QUEUE_ADAPTED]) return queue; + const enqueueDurableNow = queue.enqueueDurableNow.bind(queue); + queue.enqueue = (input) => { + const canonical = canonicalTestQueueInput(input); + adaptedQueueSourceSequence += 1; + return enqueueDurableNow({ + ...canonical, + sourceId: + canonical.sourceId ?? + canonical.messageId ?? + canonical.idempotencyKey ?? + canonical.continuationKey ?? + `adapted-queue-source-${adaptedQueueSourceSequence}`, + }); + }; + Object.defineProperty(queue, INVOCATION_QUEUE_ADAPTED, { value: true }); + return queue; +} + +/** Seed the exact public response source that an authenticated callback turn owns. */ +export function appendTestLifecycleResponseSource( + store, + { invocationId, catId = 'opus', threadId = 'thread-1', userId = 'user-1', timestamp = 100 }, +) { + return store.append({ + from: { kind: 'agent', catId }, + userId, + content: 'test callback response source', + mentions: [], + origin: 'stream', + timestamp, + threadId, + idempotencyKey: `message-lifecycle-response:${invocationId}`, + lifecycle: { + kind: 'response', + orderKey: `${timestamp}:${invocationId}`, + invocationId, + targetId: catId, + inputEntryIds: [], + inputMessageIds: [], + status: 'processing', + startedAt: timestamp, + }, + }); +} diff --git a/packages/api/test/helpers/persisted-queue-fixture.ts b/packages/api/test/helpers/persisted-queue-fixture.ts index 43c3bd9693..0f927e1396 100644 --- a/packages/api/test/helpers/persisted-queue-fixture.ts +++ b/packages/api/test/helpers/persisted-queue-fixture.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import { InvocationQueue } from '../../src/domains/cats/services/agents/invocation/InvocationQueue.js'; import { InvocationTracker } from '../../src/domains/cats/services/agents/invocation/InvocationTracker.js'; import { PersistedQueueDelivery } from '../../src/domains/cats/services/agents/invocation/PersistedQueueDelivery.js'; -import { QueuedMessageCustodyCoordinator } from '../../src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import { QueueProcessor } from '../../src/domains/cats/services/agents/invocation/QueueProcessor.js'; import { InvocationRecordStore } from '../../src/domains/cats/services/stores/ports/InvocationRecordStore.js'; import { MessageStore } from '../../src/domains/cats/services/stores/ports/MessageStore.js'; @@ -12,15 +11,13 @@ export function createPersistedQueueFixture(messages = new MessageStore()) { const queue = new InvocationQueue(); const tracker = new InvocationTracker(); const records = new InvocationRecordStore(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: messages }); - const starts: { threadId: string; userId: string; invocationId: string }[] = []; + const starts: { threadId: string; userId: string; invocationId: string; messageIds: readonly string[] }[] = []; const completed: Promise[] = []; const releases: (() => void)[] = []; const processor = new QueueProcessor({ queue, invocationTracker: tracker, messageStore: messages, - queueCustodyCoordinator: coordinator, invocationRecordStore: { async create(input) { return records.create(input as Parameters[0]); @@ -33,16 +30,38 @@ export function createPersistedQueueFixture(messages = new MessageStore()) { socketManager: { emitToUser() {}, broadcastAgentMessage() {}, broadcastToRoom() {} }, log: { info() {}, warn() {}, error() {} }, router: { + async resolveExplicitTargets(requestedCatIds) { + return [...requestedCatIds]; + }, + async resolveConversationTargetsAtAdmission(requestedCatIds) { + return [...requestedCatIds]; + }, async *routeExecution(userId, _content, threadId, _messageId, targets, _intent, options) { const invocationId = String(options?.parentInvocationId); - const catId = targets[0]!; - starts.push({ threadId, userId, invocationId }); + const catId = targets[0]; + if (!catId) throw new Error('persisted Queue fixture requires one target'); + const messageIds = options?.persistedPromptMessageIds ?? []; + starts.push({ threadId, userId, invocationId, messageIds }); const startedAt = Date.now(); + const lifecycle = await options?.onLifecycleInvocationStarted?.({ + threadId, + userId, + catId, + invocationId, + parentInvocationId: invocationId, + startedAt, + }); yield { type: 'system_info', catId, turnInvocationId: invocationId, turnExecutionStartedAt: startedAt, + ...(lifecycle + ? { + lifecycleResponseMessageId: lifecycle.responseMessageId, + lifecyclePriorFrontierMessageId: lifecycle.priorFrontierMessageId, + } + : {}), timestamp: startedAt, extra: { turnExecution: { executionKind: 'ordinary', invocationId, parentInvocationId: invocationId } }, }; @@ -80,20 +99,20 @@ export function createPersistedQueueFixture(messages = new MessageStore()) { }); async function waitForAwakening(messageId: string) { for (let attempt = 0; attempt < 100; attempt += 1) { - const message = messages.getById(messageId); - const catId = message?.mentions[0]; - const id = catId && message?.queueCustody?.awakenedInvocationIdByCatId?.[catId]; - if (id) { - assert.ok(records.get(id)); - return id; + const started = starts.find((candidate) => candidate.messageIds.includes(messageId)); + if (started) { + assert.ok(records.get(started.invocationId)); + return started.invocationId; } await new Promise((resolve) => setTimeout(resolve, 5)); } assert.fail('QueueProcessor did not persist the admitted child awakening'); } async function close() { - releases.splice(0).forEach((release) => release()); + releases.splice(0).forEach((release) => { + release(); + }); await Promise.all(completed); } - return { queue, tracker, records, coordinator, processor, delivery, messages, starts, waitForAwakening, close }; + return { queue, tracker, records, processor, delivery, messages, starts, waitForAwakening, close }; } diff --git a/packages/api/test/helpers/proposal-test-harness.js b/packages/api/test/helpers/proposal-test-harness.js index 55c79cb6e1..b934efc3a7 100644 --- a/packages/api/test/helpers/proposal-test-harness.js +++ b/packages/api/test/helpers/proposal-test-harness.js @@ -6,6 +6,7 @@ */ import Fastify from 'fastify'; +import { adaptMessageStore, canonicalTestMessageInput } from './message-from-fixtures.js'; export async function createProposalTestContext({ proposalStoreOverride, @@ -29,7 +30,7 @@ export async function createProposalTestContext({ const registry = new InvocationRegistry(); const threadStore = new ThreadStore(); - const messageStore = messageStoreOverride ?? new MessageStore(); + const messageStore = adaptMessageStore(messageStoreOverride ?? new MessageStore()); const proposalStore = proposalStoreOverride ?? new InMemoryProposalStore(); const taskStore = taskStoreOverride ?? new TaskStore(); const socketEvents = []; @@ -86,15 +87,17 @@ export async function createProposalTestContext({ const dedupKey = body.clientRequestId ? `${userId}:${catId}:${threadId}:${body.clientRequestId}` : undefined; let origin = dedupKey ? originByRequest.get(dedupKey) : undefined; if (!origin) { - origin = messageStore.append({ - userId, - catId: null, - content: 'Please propose a child thread', - contentBlocks: originContentBlocks, - mentions: [], - timestamp: Date.now(), - threadId, - }); + origin = messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: 'Please propose a child thread', + contentBlocks: originContentBlocks, + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); if (dedupKey) originByRequest.set(dedupKey, origin); } const { invocationId, callbackToken } = await registry.create(userId, catId, threadId, undefined, origin.id); @@ -125,14 +128,16 @@ export async function createProposalTestContext({ } async function withdraw({ userId, catId, threadId, proposalId }) { - const origin = await messageStore.append({ - userId, - catId: null, - content: `Withdraw thread proposal ${proposalId}`, - mentions: [], - timestamp: Date.now(), - threadId, - }); + const origin = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: `Withdraw thread proposal ${proposalId}`, + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const { invocationId, callbackToken } = await registry.create(userId, catId, threadId, undefined, origin.id); return app.inject({ method: 'POST', diff --git a/packages/api/test/helpers/queued-message-custody.js b/packages/api/test/helpers/queued-message-custody.js deleted file mode 100644 index c53e7255a5..0000000000 --- a/packages/api/test/helpers/queued-message-custody.js +++ /dev/null @@ -1,20 +0,0 @@ -export function makeQueuedMessageCustody(overrides = {}) { - return { - version: 1, - entryId: 'entry-1', - revision: 1, - intent: 'implement', - status: 'queued', - allTargetCats: ['opus', 'codex'], - pendingTargetCats: ['opus', 'codex'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 1_000, - updatedAt: 1_000, - ...overrides, - }; -} diff --git a/packages/api/test/helpers/session-handoff-route-fixture.js b/packages/api/test/helpers/session-handoff-route-fixture.js index 3b76653f6e..6a6c2e24c8 100644 --- a/packages/api/test/helpers/session-handoff-route-fixture.js +++ b/packages/api/test/helpers/session-handoff-route-fixture.js @@ -34,7 +34,7 @@ export function buildSessionHandoffDeps({ sealAccepted = true, sessionActive = t }; const enqueueCalls = []; const invocationQueue = { - enqueue: (input) => { + enqueueDurable: async (input) => { enqueueCalls.push(input); return { outcome: 'enqueued', entry: { id: `entry_${enqueueCalls.length}` } }; }, diff --git a/packages/api/test/helpers/turn-custody-route-harness.js b/packages/api/test/helpers/turn-custody-route-harness.js index 36917be855..89dbd75d93 100644 --- a/packages/api/test/helpers/turn-custody-route-harness.js +++ b/packages/api/test/helpers/turn-custody-route-harness.js @@ -1,5 +1,10 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { catRegistry } from '@cat-cafe/shared'; +const TEMPLATE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../cat-template.json'); +process.env.CAT_TEMPLATE_PATH = TEMPLATE_PATH; + function createSequenceService(texts) { let callCount = 0; return { @@ -21,6 +26,7 @@ function createSequenceService(texts) { function createMockDeps(services, triggerMessage, projection) { let sequence = 0; let projectionOpenCount = 0; + const appendedMessages = []; return { services, invocationDeps: { @@ -37,19 +43,22 @@ function createMockDeps(services, triggerMessage, projection) { apiUrl: 'http://127.0.0.1:3004', }, messageStore: { - append: async (message) => ({ - id: `stored-${++sequence}`, - userId: message.userId ?? '', - catId: message.catId ?? null, - content: message.content ?? '', - mentions: message.mentions ?? [], - timestamp: message.timestamp ?? 0, - source: message.source, - origin: message.origin, - mentionsUser: message.mentionsUser, - toolEvents: message.toolEvents, - extra: message.extra, - }), + append: async (message) => { + appendedMessages.push(message); + return { + id: `stored-${++sequence}`, + userId: message.userId ?? '', + catId: message.catId ?? null, + content: message.content ?? '', + mentions: message.mentions ?? [], + timestamp: message.timestamp ?? 0, + source: message.source, + origin: message.origin, + mentionsUser: message.mentionsUser, + toolEvents: message.toolEvents, + extra: message.extra, + }; + }, getById: async (messageId) => (messageId === triggerMessage.id ? triggerMessage : null), getRecent: async () => [], getMentionsFor: async () => [], @@ -77,6 +86,8 @@ function createMockDeps(services, triggerMessage, projection) { return projection; }, }, + getProjectionOpenCount: () => projectionOpenCount, + getAppendedMessages: () => appendedMessages, }; } @@ -85,7 +96,7 @@ export async function runTurnCustodyRoute({ output, triggerMessage, wake, projec const { loadCatConfig, toAllCatConfigs } = await import('../../dist/config/cat-config-loader.js'); const { routeSerial } = await import('../../dist/domains/cats/services/agents/routing/route-serial.js'); catRegistry.reset(); - for (const [id, config] of Object.entries(toAllCatConfigs(loadCatConfig()))) { + for (const [id, config] of Object.entries(toAllCatConfigs(loadCatConfig(TEMPLATE_PATH)))) { catRegistry.register(id, config); } try { @@ -110,6 +121,10 @@ export async function runTurnCustodyRoute({ output, triggerMessage, wake, projec )) { // Exhaust the real route so its pending Phase T close emits evidence. } + return { + projectionOpenCount: deps.getProjectionOpenCount(), + appendedMessages: deps.getAppendedMessages(), + }; } finally { catRegistry.reset(); for (const [id, config] of Object.entries(original)) { diff --git a/packages/api/test/helpers/typed-wait-custody-fixture.js b/packages/api/test/helpers/typed-wait-custody-fixture.js index 0f7c0a821f..b5e88b9a35 100644 --- a/packages/api/test/helpers/typed-wait-custody-fixture.js +++ b/packages/api/test/helpers/typed-wait-custody-fixture.js @@ -1,56 +1,35 @@ import { createTypedWaitRegistration } from '../../dist/domains/ball-custody/TypedWaitRegistration.js'; -import { InvocationQueue } from '../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; import { TaskStore } from '../../dist/domains/cats/services/stores/ports/TaskStore.js'; export async function createTypedWaitCustodyFixture(options = {}) { const store = options.messageStore ?? new MessageStore(); const taskStore = options.taskStore ?? new TaskStore(); - const queue = new InvocationQueue(); - const entry = queue.enqueue({ + const message = await store.append({ + from: { kind: 'system', service: 'hold-ball' }, threadId: 'thread-wait', userId: 'user-1', content: 'Command result', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - ownerAuthProvenance: 'strict', - }).entry; - const message = await store.append({ - threadId: entry.threadId, - userId: 'scheduler', - catId: null, - content: entry.content, mentions: ['opus'], timestamp: Date.now(), - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(entry), source: { connector: 'hold-ball', label: 'command', icon: 'hold-ball', - meta: { wakeWhen: true, taskId: 'hold-1', threadId: entry.threadId, catId: 'opus' }, + meta: { + wakeWhen: true, + managedHold: true, + phase: 'wake', + taskId: 'hold-1', + threadId: 'thread-wait', + catId: 'opus', + }, }, }); - queue.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - queue.markProcessingById(entry.threadId, entry.id); - queue.markProcessingSeen(entry.threadId, entry.userId, entry.id, ['opus'], 'child-1'); - const processing = queue.list(entry.threadId, entry.userId)[0]; - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: store, - readWaitRegistration: (id) => taskStore.getWaitRegistration(id), - }); - await coordinator.persistEntry(processing); const task = await taskStore.create({ kind: 'pr_tracking', subjectKey: 'pr:owner/repo#4513', - threadId: entry.threadId, + threadId: 'thread-wait', title: 'CI', userId: 'user-1', ownerCatId: 'opus', @@ -81,29 +60,5 @@ export async function createTypedWaitCustodyFixture(options = {}) { automationState: { await: active }, waitRegistration: receipt, }); - const outcome = { - invocationId: 'child-1', - disposition: 'managed_hold_disposition', - evidenceRef: { kind: 'turn_execution', invocationId: 'child-1' }, - handledAt: Date.now(), - consumption: { - kind: 'managed_hold_continued', - sourceMessageId: message.id, - taskId: 'hold-1', - transition: 'event_wait', - waitRegistration: { taskId: task.id, generation: 1 }, - }, - }; - return { - store, - taskStore, - task, - active, - receipt, - message, - outcome, - processing, - coordinator, - commit: () => coordinator.commitSuccessfulTargets(processing, ['opus'], 'child-1', Date.now(), { opus: outcome }), - }; + return { store, taskStore, task, active, receipt, message }; } diff --git a/packages/api/test/hold-ball-cancel-route.test.js b/packages/api/test/hold-ball-cancel-route.test.js index f4656c27b2..9b77994ebc 100644 --- a/packages/api/test/hold-ball-cancel-route.test.js +++ b/packages/api/test/hold-ball-cancel-route.test.js @@ -209,6 +209,16 @@ describe('F167 Phase J AC-J1: DELETE /api/callbacks/hold-ball/:taskId', () => { assert.equal(deps._storedMessages.length, 1, 'AC-J3: should emit cancel confirmation'); assert.match(deps._storedMessages[0].content, /持球已取消/); assert.equal(deps._storedMessages[0].threadId, 'thread-del1'); + assert.equal(deps._storedMessages[0].userId, 'user1', 'terminal visibility belongs to the hold owner'); + assert.equal(deps._storedMessages[0].idempotencyKey, 'hold-ball-terminal:hold-ball-123-abc:cancel'); + assert.deepEqual(deps._storedMessages[0].source.meta, { + managedHold: true, + phase: 'terminal', + taskId: 'hold-ball-123-abc', + threadId: 'thread-del1', + catId: 'codex', + outcome: 'ordinary_cancelled', + }); assert.equal(deps._broadcasts.length, 1, 'should broadcast connector_message'); assert.equal(deps._broadcasts[0].room, 'thread:thread-del1'); @@ -588,42 +598,6 @@ describe('F167 Phase J AC-J1: DELETE /api/callbacks/hold-ball/:taskId', () => { assert.deepEqual(deps._removed, []); }); - test('GET status projects bounded missing-disposition escalation as non-cancelable', async () => { - const task = makeHoldTask('hold-ball-escalated', 'thread-escalated', 'codex-sol'); - task.enabled = false; - task.params = { - ...task.params, - holdLifecycle: { - mode: 'wake_when', - status: 'escalated', - wakeAt: task.trigger.fireAt, - createdBy: 'hold-ball:codex-sol', - managedCommand: { - state: 'escalated', - command: 'pnpm gate', - startedAt: Date.now() - 2_000, - dispositionEscalationReason: 'managed_hold_disposition_missing', - dispositionEscalatedAttemptId: 'entry-1:codex-sol:2', - dispositionEscalatedAt: Date.now() - 1_000, - }, - }, - }; - const deps = makeStubDeps([task], { 'thread-escalated': 'test-user' }); - const app = await createApp(deps); - - const res = await app.inject({ - method: 'GET', - url: '/api/callbacks/hold-ball/hold-ball-escalated/status', - headers: { 'x-cat-cafe-user': 'test-user' }, - }); - - assert.equal(res.statusCode, 200); - const body = JSON.parse(res.body); - assert.equal(body.status, 'escalated'); - assert.equal(body.cancelable, false); - assert.equal(body.lifecycle.managedCommand.state, 'escalated'); - }); - test('200 feedback report for stale hold task — emits feedback without live task', async () => { const deps = makeStubDeps([], { 'thread-feedback': 'test-user' }); const feedbackCalls = []; diff --git a/packages/api/test/hold-ball-cancel.test.js b/packages/api/test/hold-ball-cancel.test.js index 7ad8aa5515..6644310e09 100644 --- a/packages/api/test/hold-ball-cancel.test.js +++ b/packages/api/test/hold-ball-cancel.test.js @@ -189,7 +189,7 @@ describe('F167 Phase J AC-J4~J6: cancelPendingHoldsForThread', () => { const cancelled = cancelPendingHoldsForThread('thread-completion-pending', deps); - assert.deepEqual(cancelled, [], 'ordinary prose is not an invocation-bound disposition'); + assert.deepEqual(cancelled, [], 'ordinary prose is not an explicit hold cancellation'); assert.deepEqual(deps._unregistered, []); assert.deepEqual(deps._removed, []); assert.equal(completed.enabled, true); @@ -231,7 +231,7 @@ describe('F167 Phase J AC-J4~J6: cancelPendingHoldsForThread', () => { }); for (const dispatchedState of ['condition_met', 'message_written', 'enqueued', 'dispatched']) { - test(`does not retire an accepted ${dispatchedState} wake before its invocation-bound disposition`, () => { + test(`does not retire an accepted ${dispatchedState} wake before its canonical response terminal`, () => { const accepted = makeTask({ id: `hold-ball-accepted-${dispatchedState}`, deliveryThreadId: 'thread-accepted-wake', diff --git a/packages/api/test/hook-pipeline-integration.test.js b/packages/api/test/hook-pipeline-integration.test.js index d337921060..52b26abc4e 100644 --- a/packages/api/test/hook-pipeline-integration.test.js +++ b/packages/api/test/hook-pipeline-integration.test.js @@ -117,6 +117,28 @@ describe('Pipeline Integration (real registry + resolvers + templates)', () => { assert.equal(manifests.length, 46, `Expected 46 hooks, got ${manifests.length}`); }); + it('D12 manifest and fallback template describe recent speech without implying execution', () => { + const hook = registry.getHook('D12'); + assert.ok(hook, 'D12 should be registered'); + assert.equal(hook.manifest.name, '最近发言者'); + assert.equal(hook.manifest.template, 'd12-recent-speaker.md'); + assert.equal(hook.manifest.resolver, 'D12Resolver'); + assert.equal(hook.manifest.userExplanation, '最近在此 thread 发过言的成员(不表示正在执行)'); + + const pipeline = new pipelineMod.HookPipeline(registry, resolversMod.RESOLVER_MAP, () => null); + const result = pipeline.executeStage( + 'per-turn', + makeRichInput({ + activeParticipants: [ + { catId: 'codex', label: '缅因猫(codex)', lastMessageAt: 1000 }, + { catId: 'opus', label: '布偶猫(opus)', lastMessageAt: 2000 }, + ], + }), + ); + const d12Patch = result.patches.find((patch) => patch.hookId === 'D12'); + assert.equal(d12Patch?.content, '最近发言:缅因猫(codex)'); + }); + it('session-init stage fires L1-L7 + S1 + S8 + S9 + B1 + C1 (always-fire hooks)', () => { const pipeline = new pipelineMod.HookPipeline(registry, resolversMod.RESOLVER_MAP, templateMod.renderSegment); const input = makeRichInput(); diff --git a/packages/api/test/hook-resolvers-turn.test.js b/packages/api/test/hook-resolvers-turn.test.js index ec0c09cceb..0e0df3a20f 100644 --- a/packages/api/test/hook-resolvers-turn.test.js +++ b/packages/api/test/hook-resolvers-turn.test.js @@ -254,7 +254,7 @@ describe('Turn resolvers D11-D21, R1-R2, N1', () => { assert.equal(result.vars.SKILL_NAME, 'tdd'); }); - it('D12 fires with qualifying active participant', () => { + it('D12 fires with a qualifying recent speaker', () => { const input = makeInput({ activeParticipants: [ { catId: 'codex', label: '缅因猫(codex)', lastMessageAt: 1000 }, @@ -263,7 +263,7 @@ describe('Turn resolvers D11-D21, R1-R2, N1', () => { }); const result = new mod.D12Resolver().resolve(input); assert.equal(result.status, 'fired'); - assert.equal(result.vars.ACTIVE_LABEL, '缅因猫(codex)'); + assert.equal(result.vars.RECENT_SPEAKER_LABEL, '缅因猫(codex)'); }); it('D12 skips when only self is active', () => { diff --git a/packages/api/test/image-upload.test.js b/packages/api/test/image-upload.test.js index ae1ec5b417..fdca48317c 100644 --- a/packages/api/test/image-upload.test.js +++ b/packages/api/test/image-upload.test.js @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; import { ensureFakeCliOnPath } from './helpers/fake-cli-path.js'; import { fakeL0Compiler } from './helpers/fake-l0-compiler.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; ensureFakeCliOnPath('claude'); ensureFakeCliOnPath('codex'); @@ -283,17 +284,19 @@ describe('contentBlocks in GET /api/messages', () => { }); it('returns contentBlocks when present', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'check this image', - contentBlocks: [ - { type: 'text', text: 'check this image' }, - { type: 'image', url: '/uploads/test.png' }, - ], - mentions: ['opus'], - timestamp: 1000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'check this image', + contentBlocks: [ + { type: 'text', text: 'check this image' }, + { type: 'image', url: '/uploads/test.png' }, + ], + mentions: ['opus'], + timestamp: 1000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -305,13 +308,15 @@ describe('contentBlocks in GET /api/messages', () => { }); it('omits contentBlocks when not present', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'text only', - mentions: [], - timestamp: 1000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'text only', + mentions: [], + timestamp: 1000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -323,14 +328,15 @@ describe('contentBlocks in GET /api/messages', () => { describe('multipart image target routing', () => { let app; let uploadDir; - const routeExecutionCalls = []; + let messageStore; + let invocationQueue; const broadcastedAgentMessages = []; beforeEach(async () => { uploadDir = await mkdtemp(join(tmpdir(), 'cat-cafe-image-target-')); - routeExecutionCalls.length = 0; broadcastedAgentMessages.length = 0; + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { InvocationRegistry } = await import( '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' @@ -340,7 +346,8 @@ describe('multipart image target routing', () => { ); const { messagesRoutes } = await import('../dist/routes/messages.js'); - const messageStore = new MessageStore(); + messageStore = new MessageStore(); + invocationQueue = new InvocationQueue(); const mockRouter = { async resolveTargetsAndIntent() { return { @@ -348,15 +355,6 @@ describe('multipart image target routing', () => { intent: { intent: 'execute', explicit: false, promptTags: [] }, }; }, - async *routeExecution(_userId, _content, _threadId, _userMessageId, targetCats, _intent, routeOptions) { - routeExecutionCalls.push({ - targetCats: [...targetCats], - contentBlocks: routeOptions?.contentBlocks, - uploadDir: routeOptions?.uploadDir, - }); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now(), isFinal: true }; - }, - async ackCollectedCursors() {}, }; app = Fastify(); @@ -372,6 +370,8 @@ describe('multipart image target routing', () => { }, router: mockRouter, invocationRecordStore: new InvocationRecordStore(), + invocationQueue, + queueProcessor: { async requestDrain() {} }, uploadDir, }); await app.ready(); @@ -402,20 +402,14 @@ describe('multipart image target routing', () => { payload, }); - assert.equal(res.statusCode, 200); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.equal(routeExecutionCalls.length, 1); - // P1 regression guard: targetCats must match router resolution, not be overridden to codex - assert.deepEqual( - routeExecutionCalls[0].targetCats, - ['opus'], - 'image message should route to the resolved target cat, not forced to codex', - ); - assert.equal(routeExecutionCalls[0].uploadDir, uploadDir); - assert.ok(Array.isArray(routeExecutionCalls[0].contentBlocks), 'routeExecution should receive contentBlocks'); + assert.equal(res.statusCode, 202); + const [entry] = invocationQueue.list('default', 'alice'); + assert.deepEqual(entry.targets, ['opus'], 'image message should retain the resolved target at Queue ingress'); + const stored = await messageStore.getById(JSON.parse(res.body).userMessageId); + assert.ok(Array.isArray(stored.contentBlocks), 'canonical source message should retain contentBlocks'); assert.ok( - routeExecutionCalls[0].contentBlocks.some((b) => b.type === 'image'), - 'routeExecution should receive image content block', + stored.contentBlocks.some((block) => block.type === 'image'), + 'canonical source message should retain the image content block for provider admission', ); // No forced-to-codex notice should be broadcast const notice = broadcastedAgentMessages.find( diff --git a/packages/api/test/infrastructure/socket-cancel-invocation.test.js b/packages/api/test/infrastructure/socket-cancel-invocation.test.js index 70aa9b91f9..08f14c8f7a 100644 --- a/packages/api/test/infrastructure/socket-cancel-invocation.test.js +++ b/packages/api/test/infrastructure/socket-cancel-invocation.test.js @@ -68,6 +68,7 @@ describe('SocketManager cancel_invocation', () => { }; queueProcessor = { canReleaseSlotForUser: mock.fn(() => true), + hasProcessingSlotReservation: mock.fn(() => false), clearPause: mock.fn(), releaseSlot: mock.fn(), suppressAutoResume: mock.fn(), @@ -99,18 +100,11 @@ describe('SocketManager cancel_invocation', () => { socket.emit('cancel_invocation', explicitCancel()); await waitFor( () => - received.filter((msg) => msg.type === 'system_info').length === 1 && - received.filter((msg) => msg.type === 'done').length === 2, + received.filter((msg) => msg.type === 'done').length === 2 && + queueProcessor.releaseSlot.mock.calls.length === 2, ); assert.equal(invocationTracker.cancelAll.mock.calls.length, 1); - assert.deepEqual( - queueProcessor.clearPause.mock.calls.map((call) => call.arguments), - [ - ['thread-1', 'opus'], - ['thread-1', 'codex'], - ], - ); assert.deepEqual( queueProcessor.releaseSlot.mock.calls.map((call) => call.arguments), [ @@ -130,7 +124,7 @@ describe('SocketManager cancel_invocation', () => { invocationTracker.cancelAll.mock.calls.map((call) => call.arguments), [['thread-1', 'default-user', 'cancel_all']], ); - assert.equal(received.filter((msg) => msg.type === 'system_info').length, 1); + assert.equal(received.filter((msg) => msg.type === 'system_info').length, 0); assert.deepEqual( received .filter((msg) => msg.type === 'done') @@ -152,15 +146,11 @@ describe('SocketManager cancel_invocation', () => { socket.emit('cancel_invocation', explicitCancel({ catId: 'opus' })); await waitFor( () => - received.filter((msg) => msg.type === 'system_info').length === 1 && - received.filter((msg) => msg.type === 'done').length === 1, + received.filter((msg) => msg.type === 'done').length === 1 && + queueProcessor.releaseSlot.mock.calls.length === 1, ); assert.equal(invocationTracker.cancel.mock.calls.length, 1); - assert.deepEqual( - queueProcessor.clearPause.mock.calls.map((call) => call.arguments), - [['thread-1', 'opus']], - ); assert.deepEqual( queueProcessor.releaseSlot.mock.calls.map((call) => call.arguments), [['thread-1', 'opus']], @@ -169,7 +159,7 @@ describe('SocketManager cancel_invocation', () => { invocationTracker.cancel.mock.calls.map((call) => call.arguments), [['thread-1', 'opus', 'default-user', 'user_cancel']], ); - assert.equal(received.filter((msg) => msg.type === 'system_info').length, 1); + assert.equal(received.filter((msg) => msg.type === 'system_info').length, 0); assert.deepEqual( received.filter((msg) => msg.type === 'done').map((msg) => msg.catId), ['opus'], @@ -178,6 +168,52 @@ describe('SocketManager cancel_invocation', () => { socket.disconnect(); }); + it('slot-specific cancel leaves an active Queue reservation for its execute promise to retire', async () => { + queueProcessor.hasProcessingSlotReservation = mock.fn(() => true); + const socket = await connectClient(port); + const received = []; + socket.on('agent_message', (msg) => received.push(msg)); + socket.emit('join_room', 'thread:thread-1'); + await new Promise((resolve) => setTimeout(resolve, 30)); + + socket.emit('cancel_invocation', explicitCancel({ catId: 'opus' })); + await waitFor(() => received.some((msg) => msg.type === 'done' && msg.catId === 'opus')); + + assert.equal(queueProcessor.releaseSlot.mock.calls.length, 0); + assert.deepEqual( + queueProcessor.hasProcessingSlotReservation.mock.calls.map((call) => call.arguments), + [['thread-1', 'opus']], + ); + socket.disconnect(); + }); + + it('cancel-all does not create anonymous suppression or release Queue-owned reservations', async () => { + queueProcessor.hasProcessingSlotReservation = mock.fn((_threadId, catId) => catId === 'opus'); + invocationTracker.cancelAll = mock.fn(() => ({ + catIds: ['opus', 'codex'], + executionIds: ['inv-opus'], + executionIdByCatId: { opus: 'inv-opus' }, + })); + const socket = await connectClient(port); + const received = []; + socket.on('agent_message', (msg) => received.push(msg)); + socket.emit('join_room', 'thread:thread-1'); + await new Promise((resolve) => setTimeout(resolve, 30)); + + socket.emit('cancel_invocation', explicitCancel()); + await waitFor(() => received.filter((msg) => msg.type === 'done').length === 2); + + assert.deepEqual( + queueProcessor.releaseSlot.mock.calls.map((call) => call.arguments), + [['thread-1', 'codex']], + ); + assert.deepEqual( + queueProcessor.suppressAutoResume.mock.calls.map((call) => call.arguments), + [['thread-1', 'opus', ['inv-opus']]], + ); + socket.disconnect(); + }); + it('releases agent session locks after abort for slot and cancel-all scopes', async () => { const lifecycle = []; invocationTracker.cancel = mock.fn(() => { @@ -237,12 +273,12 @@ describe('SocketManager cancel_invocation', () => { await new Promise((resolve) => setTimeout(resolve, 30)); socket.emit('cancel_invocation', explicitCancel({ catId: 'opus' })); - await waitFor(() => received.some((msg) => msg.type === 'done' && msg.catId === 'opus')); - - assert.deepEqual( - queueProcessor.clearPause.mock.calls.map((call) => call.arguments), - [['thread-1', 'opus']], + await waitFor( + () => + received.some((msg) => msg.type === 'done' && msg.catId === 'opus') && + queueProcessor.releaseSlot.mock.calls.length === 1, ); + assert.deepEqual( queueProcessor.releaseSlot.mock.calls.map((call) => call.arguments), [['thread-1', 'opus']], diff --git a/packages/api/test/integration/a2a-chain.test.js b/packages/api/test/integration/a2a-chain.test.js deleted file mode 100644 index 3aa5cda058..0000000000 --- a/packages/api/test/integration/a2a-chain.test.js +++ /dev/null @@ -1,296 +0,0 @@ -/** - * A2A Chain Integration Tests - * 验证 A2A 链通过 AgentRouter → routeSerial → parseA2AMentions 完整链路 - * - * 使用 mock services, 不需要真实 CLI。 - */ - -import assert from 'node:assert/strict'; -import { describe, mock, test } from 'node:test'; -import { migrateRouterOpts } from '../helpers/agent-registry-helpers.js'; - -// Mock service that yields specific text -function createMockService(catId, text) { - return { - invoke: mock.fn(async function* (_prompt) { - yield { type: 'session_init', catId, sessionId: `${catId}-session`, timestamp: Date.now() }; - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }), - }; -} - -function createMockRegistry() { - let counter = 0; - return { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }; -} - -function createMockMessageStore() { - const stored = []; - return { - _stored: stored, - append: async (msg) => { - stored.push(msg); - return { id: `msg-${stored.length}`, ...msg }; - }, - getById: () => null, - getRecent: () => [], - getMentionsFor: () => [], - getBefore: () => [], - getByThread: () => [], - getByThreadAfter: () => [], - getByThreadBefore: () => [], - }; -} - -function claimedA2ASlotOptions() { - return { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - }; -} - -describe('A2A Chain Integration (AgentRouter end-to-end)', () => { - test('complete A2A chain: opus → @缅因猫 → codex invoked with previous context', async () => { - const { AgentRouter } = await import('../../dist/domains/cats/services/agents/routing/AgentRouter.js'); - - // opus responds with line-start @缅因猫 mention - const mockOpus = createMockService('opus', '代码写好了\n@缅因猫 请 review 这段代码'); - const mockCodex = createMockService('codex', 'LGTM,代码没问题'); - const mockGemini = createMockService('gemini', 'unused'); - const messageStore = createMockMessageStore(); - - const router = new AgentRouter( - await migrateRouterOpts({ - claudeService: mockOpus, - codexService: mockCodex, - geminiService: mockGemini, - registry: createMockRegistry(), - messageStore, - }), - ); - - const messages = []; - for await (const msg of router.route( - 'user-1', - '@opus 写个 hello world', - undefined, - undefined, - undefined, - undefined, - claimedA2ASlotOptions(), - )) { - messages.push(msg); - } - - // Both opus and codex should be invoked - assert.equal(mockOpus.invoke.mock.callCount(), 1, 'opus should be called once'); - assert.equal(mockCodex.invoke.mock.callCount(), 1, 'codex should be called via A2A'); - assert.equal(mockGemini.invoke.mock.callCount(), 0, 'gemini should not be called'); - - // Prompt should include direct-message reply target hint for the A2A-invoked cat. - const codexPrompt = mockCodex.invoke.mock.calls[0]?.arguments?.[0]; - assert.equal(typeof codexPrompt, 'string', 'codex invocation should receive a prompt string'); - assert.ok( - codexPrompt.includes('Direct message from 布偶猫(opus)'), - 'codex prompt should instruct replying to 布偶猫(opus) (not the user)', - ); - - // Should have a2a_handoff event - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 1, 'should yield exactly one a2a_handoff'); - assert.ok(handoffs[0].content.includes('→'), 'handoff shows arrow transition'); - - // Messages from both cats present - const opusText = messages.filter((m) => m.type === 'text' && m.catId === 'opus'); - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.ok(opusText.length > 0, 'should have opus text'); - assert.ok(codexText.length > 0, 'should have codex text'); - - // isFinal should be true only on the last done (codex) - const dones = messages.filter((m) => m.type === 'done'); - const opusDone = dones.find((m) => m.catId === 'opus'); - const codexDone = dones.find((m) => m.catId === 'codex'); - assert.ok(!opusDone.isFinal, 'opus done should NOT be isFinal'); - assert.ok(codexDone.isFinal, 'codex done (chain end) SHOULD be isFinal'); - - // messageStore should have opus mentions = ['codex'] - const opusStored = messageStore._stored.find((m) => m.catId === 'opus'); - assert.ok(opusStored, 'opus message should be stored'); - assert.deepEqual(opusStored.mentions, ['codex'], 'opus should have codex mention stored'); - }); - - test('A2A depth limit prevents excessive chaining', async () => { - const { AgentRouter } = await import('../../dist/domains/cats/services/agents/routing/AgentRouter.js'); - - // Chain: opus → @codex → @gemini → (blocked by depth=2) - let _opusCalls = 0; - const mockOpus = { - invoke: mock.fn(async function* () { - _opusCalls++; - yield { type: 'session_init', catId: 'opus', sessionId: 'opus-s', timestamp: Date.now() }; - yield { type: 'text', catId: 'opus', content: '开始\n@缅因猫 帮忙review', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - }; - const mockCodex = createMockService('codex', '需要设计配合\n@暹罗猫 帮忙设计 UI'); - const mockGemini = createMockService('gemini', '还需要调整\n@布偶猫 请修复'); - const messageStore = createMockMessageStore(); - - // Set MAX_A2A_DEPTH=2 via env (will be read by route-strategies) - const prevDepth = process.env.MAX_A2A_DEPTH; - process.env.MAX_A2A_DEPTH = '2'; - - try { - const router = new AgentRouter( - await migrateRouterOpts({ - claudeService: mockOpus, - codexService: mockCodex, - geminiService: mockGemini, - registry: createMockRegistry(), - messageStore, - }), - ); - - const messages = []; - for await (const msg of router.route( - 'user-1', - '@opus implement feature', - undefined, - undefined, - undefined, - undefined, - claimedA2ASlotOptions(), - )) { - messages.push(msg); - } - - // opus→codex (hop 1), codex→gemini (hop 2), gemini→opus (blocked — depth 2 exhausted) - assert.equal(mockOpus.invoke.mock.callCount(), 1, 'opus called once (no return hop)'); - assert.equal(mockCodex.invoke.mock.callCount(), 1, 'codex called once'); - assert.equal(mockGemini.invoke.mock.callCount(), 1, 'gemini called once'); - - // Exactly 2 handoffs - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 2, 'should have exactly 2 A2A hops'); - } finally { - if (prevDepth !== undefined) { - process.env.MAX_A2A_DEPTH = prevDepth; - } else { - delete process.env.MAX_A2A_DEPTH; - } - } - }); - - test('P1: original pending target keeps replying to user (no direct-message override)', async () => { - const { AgentRouter } = await import('../../dist/domains/cats/services/agents/routing/AgentRouter.js'); - - const mockOpus = createMockService('opus', '我先看一下\n@缅因猫 你也看看'); - const mockCodex = createMockService('codex', '我来补充结论'); - const mockGemini = createMockService('gemini', 'unused'); - - const router = new AgentRouter( - await migrateRouterOpts({ - claudeService: mockOpus, - codexService: mockCodex, - geminiService: mockGemini, - registry: createMockRegistry(), - messageStore: createMockMessageStore(), - }), - ); - - const messages = []; - for await (const msg of router.route('user-1', '@opus @codex 一起看这个问题')) { - messages.push(msg); - } - - assert.equal(mockOpus.invoke.mock.callCount(), 1, 'opus should be called once'); - assert.equal(mockCodex.invoke.mock.callCount(), 1, 'codex should be called once as original target'); - assert.equal(mockGemini.invoke.mock.callCount(), 0, 'gemini should not be called'); - - const codexPrompt = mockCodex.invoke.mock.calls[0]?.arguments?.[0]; - assert.equal(typeof codexPrompt, 'string', 'codex invocation should receive a prompt string'); - assert.ok( - !codexPrompt.includes('Direct message from 布偶猫(opus)'), - 'original target must not be forced to reply to another cat', - ); - - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 0, 'no new A2A handoff should be emitted for already-pending original target'); - }); - - test('self-mention and non-line-start mention do not trigger A2A', async () => { - const { AgentRouter } = await import('../../dist/domains/cats/services/agents/routing/AgentRouter.js'); - - // opus mentions itself and mentions codex mid-line (not at line start) - const mockOpus = createMockService('opus', '我是布偶猫 @布偶猫\n之前缅因猫说的 @缅因猫 方案不错'); - const mockCodex = createMockService('codex', 'should not be called'); - const mockGemini = createMockService('gemini', 'should not be called'); - - const router = new AgentRouter( - await migrateRouterOpts({ - claudeService: mockOpus, - codexService: mockCodex, - geminiService: mockGemini, - registry: createMockRegistry(), - messageStore: createMockMessageStore(), - }), - ); - - const messages = []; - for await (const msg of router.route('user-1', '@opus 分析一下')) { - messages.push(msg); - } - - // Only opus should be called - assert.equal(mockOpus.invoke.mock.callCount(), 1, 'opus called'); - assert.equal(mockCodex.invoke.mock.callCount(), 0, 'codex NOT called'); - assert.equal(mockGemini.invoke.mock.callCount(), 0, 'gemini NOT called'); - - // No handoffs - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 0, 'no A2A handoffs'); - }); -}); - -// --- F122 AC-A5/A6: queue behavior regression coverage --- -// These tests verify that queue gating behaves correctly during A2A activity. -// Primary coverage lives in dedicated test files; this section cross-references -// them as explicit regression anchors for F122. - -describe('F122 regression: queue behavior during active invocations (AC-A5, AC-A6)', () => { - test('AC-A5: user message during A2A must be queued (cross-ref: queue-gate-thread-level)', async () => { - // Core test: queue-gate-thread-level.test.js → "cat B active → message to cat A queued" - // This test verifies the same invariant via WorklistRegistry: when a worklist - // is active (A2A chain running), the thread-level tracker check returns true, - // so messages go through the queue path. - const { registerWorklist, unregisterWorklist, hasWorklist } = await import( - '../../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'f122-regression-a5'; - const entry = registerWorklist(threadId, ['opus', 'codex'], 10); - try { - // While worklist is active, hasWorklist should be true - assert.equal(hasWorklist(threadId), true, 'worklist active during A2A chain'); - // This is the signal that messages.ts uses to decide queue vs immediate - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('AC-A6: pushToWorklist structured reason enables safe fallback (cross-ref: worklist-registry)', async () => { - // Core test: worklist-registry.test.js → F122 PushResult structured reason tests - // This test verifies that pushToWorklist returns actionable reasons that allow - // enqueueA2ATargets (callback-a2a-trigger.ts) to make correct fallback decisions. - const { pushToWorklist } = await import('../../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - - const result = pushToWorklist('nonexistent-f122', ['opus']); - assert.deepEqual(result.added, [], 'no targets added when worklist missing'); - assert.equal(result.reason, 'not_found', 'reason tells caller to fall back'); - }); -}); diff --git a/packages/api/test/integration/cross-cat-context.test.js b/packages/api/test/integration/cross-cat-context.test.js index 06e4c315f7..d10aaa5059 100644 --- a/packages/api/test/integration/cross-cat-context.test.js +++ b/packages/api/test/integration/cross-cat-context.test.js @@ -11,6 +11,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, mock, test } from 'node:test'; import { migrateRouterOpts } from '../helpers/agent-registry-helpers.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const { AgentRouter } = await import('../../dist/domains/cats/services/agents/routing/AgentRouter.js'); const { MessageStore } = await import('../../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -126,14 +127,16 @@ describe('Cross-Cat Context (暗号测试)', () => { // Seed 25 messages directly into messageStore for (let i = 0; i < 25; i++) { - await messageStore.append({ - userId: 'user-1', - catId: i % 2 === 0 ? null : 'opus', - content: `history-msg-${i}`, - mentions: [], - timestamp: i * 1000, - threadId: 'thread-3', - }); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: i % 2 === 0 ? null : 'opus', + content: `history-msg-${i}`, + mentions: [], + timestamp: i * 1000, + threadId: 'thread-3', + }), + ); } await collect(router.route('user-1', '@opus summarize', 'thread-3')); diff --git a/packages/api/test/integration/history.test.js b/packages/api/test/integration/history.test.js index cfd0348dba..3587bb1736 100644 --- a/packages/api/test/integration/history.test.js +++ b/packages/api/test/integration/history.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; describe('POST → GET /api/messages roundtrip', () => { let app; @@ -35,20 +36,24 @@ describe('POST → GET /api/messages roundtrip', () => { it('messages stored via append() are returned by GET', async () => { // Simulate what AgentRouter does: store user msg + cat reply - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'hello @opus', - mentions: ['opus'], - timestamp: 1000, - }); - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'hello human', - mentions: [], - timestamp: 2000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'hello @opus', + mentions: ['opus'], + timestamp: 1000, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'hello human', + mentions: [], + timestamp: 2000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -74,7 +79,7 @@ describe('POST → GET /api/messages roundtrip', () => { extra: { targetCats: [`codex${loneLowSurrogate}`] }, }; - const stored = messageStore.append(input); + const stored = messageStore.append(canonicalTestMessageInput(input)); assert.equal(stored.content, 'before�after 😀'); assert.deepEqual(stored.extra.targetCats, ['codex�']); @@ -84,13 +89,15 @@ describe('POST → GET /api/messages roundtrip', () => { it('returns well-formed Unicode JSON when legacy rows contain lone surrogates', async () => { const loneHighSurrogate = String.fromCharCode(0xd800); const loneLowSurrogate = String.fromCharCode(0xdc00); - const legacy = messageStore.append({ - userId: 'default-user', - catId: 'codex', - content: 'placeholder', - mentions: [], - timestamp: 2600, - }); + const legacy = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'codex', + content: 'placeholder', + mentions: [], + timestamp: 2600, + }), + ); // Simulate a row persisted before the Unicode boundary existed. legacy.content = `legacy${loneHighSurrogate}message 😀`; legacy.extra = { targetCats: [`codex${loneLowSurrogate}`] }; @@ -108,13 +115,15 @@ describe('POST → GET /api/messages roundtrip', () => { it('cursor pagination returns correct pages', async () => { // Insert 5 messages for (let i = 0; i < 5; i++) { - messageStore.append({ - userId: 'default-user', - catId: null, - content: `msg ${i}`, - mentions: [], - timestamp: 1000 + i * 100, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: `msg ${i}`, + mentions: [], + timestamp: 1000 + i * 100, + }), + ); } // First page: latest 2 @@ -144,13 +153,15 @@ describe('POST → GET /api/messages roundtrip', () => { }); it('response format matches frontend ChatMessage interface', async () => { - messageStore.append({ - userId: 'default-user', - catId: 'codex', - content: 'review done', - mentions: [], - timestamp: 5000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'codex', + content: 'review done', + mentions: [], + timestamp: 5000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); diff --git a/packages/api/test/integration/mcp-prompt-e2e.test.js b/packages/api/test/integration/mcp-prompt-e2e.test.js index c9e0ee3348..d82078d6f9 100644 --- a/packages/api/test/integration/mcp-prompt-e2e.test.js +++ b/packages/api/test/integration/mcp-prompt-e2e.test.js @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const { InvocationRegistry } = await import('../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); const { MessageStore } = await import('../../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -90,22 +91,26 @@ describe('MCP Prompt Injection E2E', () => { test('injected thread-context endpoint succeeds with real credentials', async () => { // Pre-populate some messages - messageStore.append({ - userId: 'user-1', - catId: null, - content: '你好', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-e2e', - }); - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '你好co-creator', - mentions: [], - timestamp: Date.now() + 1, - threadId: 'thread-e2e', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '你好', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-e2e', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '你好co-creator', + mentions: [], + timestamp: Date.now() + 1, + threadId: 'thread-e2e', + }), + ); const { invocationId, callbackToken } = await registry.create('user-1', 'gemini', 'thread-e2e'); diff --git a/packages/api/test/integration/thread-wiring.test.js b/packages/api/test/integration/thread-wiring.test.js index 6d567a5ded..33fa5b206b 100644 --- a/packages/api/test/integration/thread-wiring.test.js +++ b/packages/api/test/integration/thread-wiring.test.js @@ -18,6 +18,7 @@ import { PassThrough } from 'node:stream'; import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; import { migrateRouterOpts } from '../helpers/agent-registry-helpers.js'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; const { AgentRouter } = await import('../../dist/domains/cats/services/agents/routing/AgentRouter.js'); const { InvocationRegistry } = await import('../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); @@ -168,22 +169,26 @@ describe('Thread isolation: messages stay in their thread', () => { const threadB = JSON.parse(resB.body); // Add messages to each thread - messageStore.append({ - userId: 'alice', - catId: null, - content: 'msg in A', - mentions: [], - timestamp: 1000, - threadId: threadA.id, - }); - messageStore.append({ - userId: 'alice', - catId: null, - content: 'msg in B', - mentions: [], - timestamp: 2000, - threadId: threadB.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'msg in A', + mentions: [], + timestamp: 1000, + threadId: threadA.id, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'msg in B', + mentions: [], + timestamp: 2000, + threadId: threadB.id, + }), + ); // Query thread A → only msg from A const qA = await app.inject({ @@ -261,15 +266,17 @@ describe('contentBlocks round-trip: store and retrieve', () => { { type: 'image', url: '/uploads/photo.png' }, ]; - messageStore.append({ - userId: 'alice', - catId: null, - content: 'look at this', - contentBlocks: blocks, - mentions: [], - timestamp: 1000, - threadId: 'img-thread', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'look at this', + contentBlocks: blocks, + mentions: [], + timestamp: 1000, + threadId: 'img-thread', + }), + ); const msgs = messageStore.getByThread('img-thread'); assert.equal(msgs.length, 1); @@ -492,22 +499,26 @@ describe('Default thread isolation: no cross-thread message leak', () => { const messageStore = new MessageStore(); // Store messages in different threads - messageStore.append({ - userId: 'alice', - catId: null, - content: 'lobby msg', - mentions: [], - timestamp: Date.now(), - threadId: 'default', - }); - messageStore.append({ - userId: 'alice', - catId: null, - content: 'thread-B msg', - mentions: [], - timestamp: Date.now() + 1, - threadId: 'thread-B', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'lobby msg', + mentions: [], + timestamp: Date.now(), + threadId: 'default', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'thread-B msg', + mentions: [], + timestamp: Date.now() + 1, + threadId: 'thread-B', + }), + ); const app = Fastify(); const registry = new InvocationRegistry(); diff --git a/packages/api/test/invocation-queue.test.js b/packages/api/test/invocation-queue.test.js index 82074bf363..1c3ed254b4 100644 --- a/packages/api/test/invocation-queue.test.js +++ b/packages/api/test/invocation-queue.test.js @@ -1,2050 +1,434 @@ import assert from 'node:assert/strict'; -import { beforeEach, describe, it } from 'node:test'; +import { describe, it } from 'node:test'; -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); +const { InvocationQueue, queueEntryOwnerId, queueEntryTargetCats } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationQueue.js' +); +const { InMemoryQueueLedgerStore } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js' +); -/** Helper: build a minimal enqueue input */ -function entry(overrides = {}) { +let sourceSequence = 0; + +function queueInput(overrides = {}) { + sourceSequence += 1; return { - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - content: 'hello', - source: 'user', + threadId: 'thread-1', + userId: 'user-1', + sourceId: `source-${sourceSequence}`, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + content: `body-${sourceSequence}`, + messageId: `message-${sourceSequence}`, + from: { kind: 'user', userId: 'user-1' }, targetCats: ['opus'], intent: 'execute', ...overrides, }; } -describe('InvocationQueue', () => { - /** @type {InvocationQueue} */ - let queue; - beforeEach(() => { - queue = new InvocationQueue(); - }); - - // ── Basic FIFO ── - - it('rejects a producer that omits explicit owner authentication provenance', () => { - assert.throws( - () => queue.enqueue(entry({ ownerAuthProvenance: undefined })), - /ownerAuthProvenance must be explicit/, +describe('InvocationQueue ADR-043 adapter', () => { + it('rejects admissions without a durable producer identity or canonical source contract', async () => { + const queue = new InvocationQueue(); + await assert.rejects( + queue.enqueueDurable(queueInput({ sourceId: undefined, messageId: undefined })), + /persistent producer identity/, + ); + await assert.rejects(queue.enqueueDurable(queueInput({ from: undefined })), /from must be explicit/); + await assert.rejects( + queue.enqueueDurable(queueInput({ targetCats: ['opus', 'opus'] })), + /unique non-empty target ids/, ); }); - it('enqueue + dequeue FIFO order', () => { - queue.enqueue(entry({ content: 'first' })); - queue.enqueue(entry({ content: 'second', targetCats: ['codex'] })); // different target → no merge - const d1 = queue.dequeue('t1', 'u1'); - assert.equal(d1.content, 'first'); - const d2 = queue.dequeue('t1', 'u1'); - assert.equal(d2.content, 'second'); - }); - - it('peek does not remove entry', () => { - queue.enqueue(entry()); - const peeked = queue.peek('t1', 'u1'); - assert.ok(peeked); - assert.equal(queue.size('t1', 'u1'), 1); - }); - - it('returns null when dequeuing empty queue', () => { - assert.equal(queue.dequeue('t1', 'u1'), null); - }); - - it('remove specific entry by id', () => { - const r = queue.enqueue(entry()); - const removed = queue.remove('t1', 'u1', r.entry.id); - assert.equal(removed.id, r.entry.id); - assert.equal(queue.size('t1', 'u1'), 0); - }); - - it('remove returns null for non-existent entry', () => { - assert.equal(queue.remove('t1', 'u1', 'nope'), null); - }); - - it('list returns shallow copy (not live reference)', () => { - queue.enqueue(entry()); - const list1 = queue.list('t1', 'u1'); - list1.push(/** @type {any} */ ({})); // mutate - assert.equal(queue.list('t1', 'u1').length, 1); // original unaffected - }); - - // ── Capacity ── - - it('enqueue returns full when at MAX_QUEUE_DEPTH', () => { - for (let i = 0; i < 5; i++) { - queue.enqueue(entry({ content: `msg${i}`, targetCats: [`cat${i}`] })); - } - const r = queue.enqueue(entry({ content: 'overflow', targetCats: ['overflow'] })); - assert.equal(r.outcome, 'full'); - assert.equal(r.entry, undefined); - }); - - it('size only counts queued entries (not processing)', () => { - queue.enqueue(entry({ content: 'a', targetCats: ['a'] })); - queue.enqueue(entry({ content: 'b', targetCats: ['b'] })); - queue.markProcessing('t1', 'u1'); // first → processing - assert.equal(queue.size('t1', 'u1'), 1); // only 'b' counts - }); + it('stores one deterministic source row with a pending target set and replays by source identity', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const queue = new InvocationQueue(ledger); + const input = queueInput({ sourceId: 'fanout-source', messageId: 'fanout-message', targetCats: ['opus', 'codex'] }); - it('same idempotencyKey replays are deduped to one active entry', () => { - const first = queue.enqueue(entry({ content: 'first', idempotencyKey: 'idem-1' })); - assert.equal(first.outcome, 'enqueued'); - assert.equal(first.deduped, undefined); + const first = await queue.enqueueDurable(input); + const replay = await queue.enqueueDurable(input); - const replay = queue.enqueue(entry({ content: 'replay', idempotencyKey: 'idem-1' })); - assert.equal(replay.outcome, 'enqueued'); + assert.equal(first.entries.length, 1); assert.equal(replay.deduped, true); - assert.equal(replay.entry.id, first.entry.id); - assert.equal(queue.size('t1', 'u1'), 1); - assert.equal(queue.list('t1', 'u1')[0].content, 'first'); - }); - - // ── F175: no merge — every entry is independent ── - - it('same-source same-target entries are independent (F175 no merge)', () => { - const r1 = queue.enqueue(entry({ content: '猫猫' })); - assert.equal(r1.outcome, 'enqueued'); - const r2 = queue.enqueue(entry({ content: '你好' })); - assert.equal(r2.outcome, 'enqueued'); - assert.equal(queue.size('t1', 'u1'), 2); - assert.equal(queue.list('t1', 'u1')[0].content, '猫猫'); - assert.equal(queue.list('t1', 'u1')[1].content, '你好'); + assert.deepEqual( + replay.entries.map((entry) => entry.id), + first.entries.map((entry) => entry.id), + ); + assert.deepEqual(first.entry.targets, ['opus', 'codex']); + assert.ok(first.entries.every((entry) => entry.payload.messageId === 'fanout-message')); + assert.ok(first.entries.every((entry) => !('targetCats' in entry))); + assert.equal(queue.list('thread-1', 'user-1').length, 1); }); - it('different-source entries are independent', () => { - queue.enqueue(entry({ source: 'user' })); - const r2 = queue.enqueue(entry({ source: 'connector' })); - assert.equal(r2.outcome, 'enqueued'); - assert.equal(queue.size('t1', 'u1'), 2); - }); + it('represents a targetless conversation as one row with an empty pending target set', async () => { + const queue = new InvocationQueue(); + const result = await queue.enqueueDurable(queueInput({ targetCats: [] })); - it('different-targetCats entries are independent', () => { - queue.enqueue(entry({ content: '@opus 你好', targetCats: ['opus'] })); - const r2 = queue.enqueue(entry({ content: '@codex 帮忙看看', targetCats: ['codex'] })); - assert.equal(r2.outcome, 'enqueued'); - assert.equal(queue.size('t1', 'u1'), 2); + assert.equal(result.entries.length, 1); + assert.equal(result.entry.target, undefined); + assert.deepEqual(queueEntryTargetCats(result.entry), []); }); - it('entries after processing entry are independent', () => { - queue.enqueue(entry({ content: 'first' })); - queue.markProcessing('t1', 'u1'); - const r2 = queue.enqueue(entry({ content: 'second' })); - assert.equal(r2.outcome, 'enqueued'); - assert.equal(queue.list('t1', 'u1').length, 2); - }); + it('keeps author and queue owner independent and isolates owner scopes', async () => { + const queue = new InvocationQueue(); + const external = await queue.enqueueDurable( + queueInput({ + userId: 'operator-1', + owner: { kind: 'system', service: 'github-ingress' }, + from: { kind: 'external', connectorId: 'github', sender: { id: 'octocat' } }, + sourceId: 'external-owner-source', + messageId: 'external-owner-message', + }), + ); - it('different-intent entries are independent', () => { - queue.enqueue(entry({ intent: 'execute' })); - const r2 = queue.enqueue(entry({ intent: 'whisper' })); - assert.equal(r2.outcome, 'enqueued'); - assert.equal(queue.size('t1', 'u1'), 2); + assert.deepEqual(external.entry.owner, { kind: 'system', service: 'github-ingress' }); + assert.equal(queueEntryOwnerId(external.entry), 'system:github-ingress'); + assert.equal(queue.list('thread-1', 'operator-1').length, 0); + assert.equal(queue.list('thread-1', 'system:github-ingress').length, 1); }); - it('consecutive connector entries are independent', () => { - const r1 = queue.enqueue(entry({ source: 'connector', content: 'msg from user A' })); - assert.equal(r1.outcome, 'enqueued'); - const r2 = queue.enqueue(entry({ source: 'connector', content: 'msg from user B' })); - assert.equal(r2.outcome, 'enqueued'); - assert.equal(queue.size('t1', 'u1'), 2); - }); + it('orders by explicit position without mutating immutable admission identity', async () => { + const queue = new InvocationQueue(); + const first = await queue.enqueueDurable(queueInput({ sourceId: 'position-first' })); + const second = await queue.enqueueDurable(queueInput({ sourceId: 'position-second' })); - it('consecutive user entries are independent (F175)', () => { - queue.enqueue(entry({ source: 'user', content: 'first' })); - const r2 = queue.enqueue(entry({ source: 'user', content: 'second' })); - assert.equal(r2.outcome, 'enqueued'); - assert.equal(queue.size('t1', 'u1'), 2); + assert.deepEqual( + queue.list('thread-1', 'user-1').map((entry) => entry.id), + [first.entry.id, second.entry.id], + ); + assert.equal(await queue.setPositionDurable('thread-1', 'user-1', second.entry.id, 0), true); + assert.deepEqual( + queue.list('thread-1', 'user-1').map((entry) => entry.id), + [second.entry.id, first.entry.id], + ); + assert.equal((await queue.getDurableEntry('thread-1', second.entry.id)).position, 0); }); - // ── #1291 Gate 6: exact ordinary-user Batch Steer reservation ── - - describe('exact user Batch Steer reservation (#1291)', () => { - it('atomically reserves only the explicit allowlist and marks every selected entry as steering', () => { - const a = queue.enqueue(entry({ content: 'a', ownerAuthProvenance: 'strict' })).entry; - const b = queue.enqueue(entry({ content: 'b', ownerAuthProvenance: 'strict' })).entry; - const c = queue.enqueue(entry({ content: 'c', ownerAuthProvenance: 'strict' })).entry; - - const result = queue.reserveExactUserBatch('t1', 'u1', [a.id, b.id]); - - assert.equal(result.outcome, 'reserved'); - assert.deepEqual(result.entryIds, [a.id, b.id]); - assert.equal(queue.list('t1', 'u1')[0].id, a.id, 'primary entry is promoted'); - const byId = new Map(queue.list('t1', 'u1').map((candidate) => [candidate.id, candidate])); - assert.deepEqual(byId.get(a.id).steerRequestedByCatIds, ['opus']); - assert.deepEqual(byId.get(b.id).steerRequestedByCatIds, ['opus']); - assert.equal(byId.get(c.id).steerRequestedByCatIds, undefined, 'unselected C is untouched'); - assert.equal(byId.get(c.id).exactSteerBatch, undefined, 'unselected C is not reserved'); - }); - - it('keeps an exact reservation out of ordinary dequeue until its owner claims it', () => { - const a = queue.enqueue(entry({ content: 'a', ownerAuthProvenance: 'strict' })).entry; - const b = queue.enqueue(entry({ content: 'b', ownerAuthProvenance: 'strict' })).entry; - const ordinary = queue.enqueue( - entry({ content: 'ordinary', ownerAuthProvenance: 'strict', targetCats: ['codex'] }), - ).entry; - queue.reserveExactUserBatch('t1', 'u1', [a.id, b.id]); - - assert.equal( - queue.markProcessingById('t1', a.id), - false, - 'an id lookup without reservation identity must not steal an exact reservation', - ); - assert.equal( - queue.markProcessing('t1', 'u1')?.id, - ordinary.id, - 'ordinary dequeue skips the complete reserved allowlist', - ); - const byId = new Map(queue.list('t1', 'u1').map((candidate) => [candidate.id, candidate])); - assert.equal(byId.get(a.id).status, 'queued'); - assert.equal(byId.get(b.id).status, 'queued'); - }); - - it('claims an activated reservation only with its exact process-local identity', () => { - const target = queue.enqueue(entry({ content: 'target', ownerAuthProvenance: 'strict' })).entry; - const reserved = queue.reserveExactUserEntry('t1', 'u1', target.id, 'opus'); - assert.equal(reserved.outcome, 'reserved'); - - assert.equal( - queue.claimExactSteerReservation('t1', 'u1', target.id, reserved.reservationId), - null, - 'durable reservation is not claimable before preemption begins and succeeds', - ); - assert.equal(queue.beginExactSteerPreemption('t1', 'u1', reserved.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'u1', reserved.reservationId), true); - assert.equal(queue.claimExactSteerReservation('t1', 'u1', target.id, 'wrong-reservation'), null); - - const claimed = queue.claimExactSteerReservation('t1', 'u1', target.id, reserved.reservationId); - assert.equal(claimed.id, target.id); - assert.equal(claimed.status, 'processing'); - }); - - it('marks the complete reservation processing in one dequeue transition', () => { - const a = queue.enqueue(entry({ content: 'a', ownerAuthProvenance: 'strict' })).entry; - const b = queue.enqueue(entry({ content: 'b', ownerAuthProvenance: 'strict' })).entry; - const c = queue.enqueue(entry({ content: 'c', ownerAuthProvenance: 'strict' })).entry; - const reserved = queue.reserveExactUserBatch('t1', 'u1', [a.id, b.id]); - assert.equal(reserved.outcome, 'reserved'); - assert.equal(queue.beginExactSteerPreemption('t1', 'u1', reserved.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'u1', reserved.reservationId), true); - - const primary = queue.claimExactSteerReservation('t1', 'u1', a.id, reserved.reservationId); + it('claims targetless Steer selection durably and restores the same row in place', async () => { + const queue = new InvocationQueue(); + const first = await queue.enqueueDurable(queueInput({ sourceId: 'before-steer' })); + const selected = await queue.enqueueDurable(queueInput({ sourceId: 'targetless-steer', targetCats: [] })); + const last = await queue.enqueueDurable(queueInput({ sourceId: 'after-steer' })); + const before = queue.list('thread-1', 'user-1').map((entry) => entry.id); - assert.equal(primary.id, a.id); - const byId = new Map(queue.list('t1', 'u1').map((candidate) => [candidate.id, candidate])); - assert.equal(byId.get(a.id).status, 'processing'); - assert.equal(byId.get(b.id).status, 'processing'); - assert.equal(byId.get(a.id).processingStartedAt, byId.get(b.id).processingStartedAt); - assert.equal(byId.get(c.id).status, 'queued'); - }); - - it('withdrawing one deferred member releases the reservation and leaves siblings ordinary', () => { - const a = queue.enqueue(entry({ content: 'a', ownerAuthProvenance: 'strict' })).entry; - const b = queue.enqueue(entry({ content: 'b', ownerAuthProvenance: 'strict' })).entry; - queue.reserveExactUserBatch('t1', 'u1', [a.id, b.id]); + const claim = await queue.claimExactSteerEntryDurable('thread-1', 'user-1', selected.entry.id, 'codex', 1_000); + assert.equal(claim.outcome, 'claimed'); + assert.deepEqual(claim.entries[0].targets, ['codex']); + assert.equal(claim.entries[0].delivery.steerRequestedAt, 1_000); + assert.equal(await queue.restoreClaimedEntries('thread-1', [selected.entry.id]), true); - const removed = queue.remove('t1', 'u1', b.id); + const restored = queue.getEntrySnapshot('thread-1', 'user-1', selected.entry.id); + assert.equal(restored.status, 'queued'); + assert.deepEqual(restored.targets, []); + assert.equal(restored.delivery.steerRequestedAt, undefined); + assert.deepEqual( + queue.list('thread-1', 'user-1').map((entry) => entry.id), + before, + ); + assert.deepEqual(before, [first.entry.id, selected.entry.id, last.entry.id]); + }); - assert.equal(removed.id, b.id); - const remaining = queue.list('t1', 'u1'); - assert.equal(remaining.length, 1); - assert.equal(remaining[0].id, a.id); - assert.equal(remaining[0].exactSteerBatch, undefined); - assert.equal(remaining[0].steerRequestedByCatIds, undefined); + it('removes admitted work from the durable Queue and settles only its process-local attempt', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable(queueInput({ sourceId: 'terminal-once' })); + const claim = await queue.markProcessingDurable('thread-1', 'user-1', { + entryId: admitted.entry.id, + targetCats: ['opus'], }); + assert.equal(claim.status, 'claimed'); + assert.equal(await queue.commitClaimedProcessing('thread-1', [admitted.entry.id], 2_000), true); - it('rejects the complete reserved set without mutating any entry when one carrier is ineligible', () => { - const a = queue.enqueue(entry({ content: 'a', ownerAuthProvenance: 'strict' })).entry; - const fenced = queue.enqueue( - entry({ - content: 'fenced', - ownerAuthProvenance: 'strict', - actionSuccessorFence: { leaseId: 'lease-1' }, - }), - ).entry; - - const before = queue.list('t1', 'u1'); - const result = queue.reserveExactUserBatch('t1', 'u1', [a.id, fenced.id]); - - assert.deepEqual(result, { outcome: 'rejected', reason: 'entry_ineligible' }); - assert.deepEqual(queue.list('t1', 'u1'), before); - }); + assert.ok( + await queue.removeProcessedAcrossUsersDurable( + 'thread-1', + admitted.entry.id, + 'failed', + 'invocation_failed', + 3_000, + ), + ); + assert.equal(queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); + assert.equal(await queue.removeProcessedAcrossUsersDurable('thread-1', admitted.entry.id), null); + assert.equal(await queue.getDurableEntry('thread-1', admitted.entry.id), null); + }); - it('rejects incompatible target cats and intents without partial reservation', () => { - const a = queue.enqueue(entry({ content: 'a', ownerAuthProvenance: 'strict' })).entry; - const otherCat = queue.enqueue( - entry({ content: 'b', ownerAuthProvenance: 'strict', targetCats: ['codex'] }), - ).entry; - const before = queue.list('t1', 'u1'); + it('retires parallel targets independently while preserving one source claim', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable( + queueInput({ sourceId: 'parallel-cutover', targetCats: ['opus', 'codex'] }), + ); + const claim = await queue.markProcessingGroupDurable( + 'thread-1', + 'user-1', + { entryId: admitted.entry.id, targetCats: ['opus', 'codex'] }, + [admitted.entry.id], + ); + assert.equal(claim.entry.status, 'claimed'); - assert.deepEqual(queue.reserveExactUserBatch('t1', 'u1', [a.id, otherCat.id]), { - outcome: 'rejected', - reason: 'entries_incompatible', - }); - assert.deepEqual(queue.list('t1', 'u1'), before); + assert.deepEqual(await queue.retireClaimedLifecycleTarget('thread-1', admitted.entry.id, 'opus', 2_000), { + outcome: 'retired', + rowStatus: 'claimed', }); + const afterFirst = await queue.getDurableEntry('thread-1', admitted.entry.id); + assert.equal(afterFirst.status, 'claimed'); + assert.deepEqual(afterFirst.targets, ['codex']); + assert.deepEqual(afterFirst.claimedTargetIds, ['codex']); + assert.equal(queue.findProcessingByCat('thread-1', 'opus').status, 'processing'); - it('rejects connector/system, freshness, continuation/A2A, pinned, and legacy-unattributed carriers', () => { - const cases = [ - { source: 'connector' }, - { source: 'agent', sourceCategory: 'scheduled' }, - { sourceCategory: 'freshness', freshnessClosureId: 'closure-1' }, - { sourceCategory: 'continuation' }, - { sourceCategory: 'a2a', a2aTriggerMessageId: 'trigger-1' }, - { source: 'agent', sourceCategory: 'continuation', continuationKey: 'pinned-1' }, - { ownerAuthProvenance: 'unknown' }, - { - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: 'parent-1' }, - }, - }, - ]; - - for (const [index, carrier] of cases.entries()) { - const isolated = new InvocationQueue(); - const a = isolated.enqueue(entry({ content: `a-${index}`, ownerAuthProvenance: 'strict' })).entry; - const blocked = isolated.enqueue( - entry({ content: `blocked-${index}`, ownerAuthProvenance: 'strict', ...carrier }), - ).entry; - assert.deepEqual(isolated.reserveExactUserBatch('t1', 'u1', [a.id, blocked.id]), { - outcome: 'rejected', - reason: 'entry_ineligible', - }); - } + assert.deepEqual(await queue.retireClaimedLifecycleTarget('thread-1', admitted.entry.id, 'codex', 2_001), { + outcome: 'retired', + rowStatus: 'absent', }); + assert.equal(await queue.getDurableEntry('thread-1', admitted.entry.id), null); + assert.equal(queue.findProcessingByCat('thread-1', 'codex').status, 'processing'); + assert.ok(await queue.removeProcessedAcrossUsersDurable('thread-1', admitted.entry.id)); + assert.equal(queue.findProcessingByCat('thread-1', 'opus'), null); + assert.equal(queue.findProcessingByCat('thread-1', 'codex'), null); }); - it('preserves senderMeta on enqueued connector entry', () => { - const r = queue.enqueue( - entry({ - source: 'connector', - senderMeta: { id: 'ou_abc', name: 'You' }, - }), + it('retires an already-dispatched target after a sibling restores the shared claim', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable( + queueInput({ sourceId: 'parallel-restore-race', targetCats: ['opus', 'codex'] }), ); - assert.equal(r.outcome, 'enqueued'); - assert.deepEqual(r.entry.senderMeta, { id: 'ou_abc', name: 'You' }); - }); - - // ── F254 D1.2a: per-cat queued_seen ── - - it('binds exact child creation before body exposure and clears only the retry-matching witness', () => { - const r = queue.enqueue(entry({ content: 'wake before read', targetCats: ['opus', 'codex'] })); - - assert.equal(queue.markQueuedAwakened('t1', 'u1', r.entry.id, 'opus', 'child-opus', 1_100), true); - assert.equal(queue.markQueuedAwakened('t1', 'u1', r.entry.id, 'opus', 'child-opus', 1_100), false); - assert.equal(queue.markQueuedAwakened('t1', 'u1', r.entry.id, 'codex', 'child-codex', 1_200), true); - - let snapshot = queue.getEntrySnapshot('t1', 'u1', r.entry.id); - assert.deepEqual(snapshot.queuedAwakenedInvocationIdByCatId, { - opus: 'child-opus', - codex: 'child-codex', - }); - assert.deepEqual(snapshot.queuedAwakenedAtByCatId, { opus: 1_100, codex: 1_200 }); - assert.equal(snapshot.queuedSeenByCatIds, undefined); - assert.throws( - () => queue.markQueuedAwakened('t1', 'u1', r.entry.id, 'opus', 'child-opus', 1_101), - /timestamp is immutable/, + assert.ok( + await queue.markProcessingGroupDurable( + 'thread-1', + 'user-1', + { entryId: admitted.entry.id, targetCats: ['opus', 'codex'] }, + [admitted.entry.id], + ), ); + assert.equal(await queue.restoreClaimedEntries('thread-1', [admitted.entry.id]), true); - assert.equal(queue.clearQueuedSeenInvocationForCats('t1', ['opus'], 'child-opus'), 1); - snapshot = queue.getEntrySnapshot('t1', 'u1', r.entry.id); - assert.deepEqual(snapshot.queuedAwakenedInvocationIdByCatId, { codex: 'child-codex' }); - assert.deepEqual(snapshot.queuedAwakenedAtByCatId, { codex: 1_200 }); + assert.deepEqual(await queue.retireClaimedLifecycleTarget('thread-1', admitted.entry.id, 'opus', 2_000), { + outcome: 'retired', + rowStatus: 'queued', + }); + const remaining = await queue.getDurableEntry('thread-1', admitted.entry.id); + assert.equal(remaining.status, 'queued'); + assert.deepEqual(remaining.targets, ['codex']); + assert.equal(queue.findProcessingByCat('thread-1', 'opus').status, 'processing'); }); - it('markQueuedSeen hides queued freshness only for that cat target', () => { - const r = queue.enqueue( - entry({ - content: 'queued for two cats', - targetCats: ['opus', 'codex'], - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: 'parent-opus' }, - codex: { requested: 'continue_current', boundParentInvocationId: 'parent-codex' }, - }, - }), + it('rebuilds the admitted attempt when canonical reconciliation won the restore race', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable( + queueInput({ sourceId: 'parallel-reconciled-race', targetCats: ['opus', 'codex'] }), ); - - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus'), true); - - assert.deepEqual( - queue.getQueuedFreshnessMessagesForCat('t1', 'u1', 'opus', { parentInvocationId: 'parent-opus' }), - [], - 'seen target should not be nagged again', + assert.ok( + await queue.markProcessingGroupDurable( + 'thread-1', + 'user-1', + { entryId: admitted.entry.id, targetCats: ['opus', 'codex'] }, + [admitted.entry.id], + ), ); - assert.equal( - queue.getQueuedFreshnessMessagesForCat('t1', 'u1', 'codex', { parentInvocationId: 'parent-codex' }).length, - 1, - 'other targets must not be consumed by opus reading', + assert.equal(await queue.restoreClaimedEntries('thread-1', [admitted.entry.id]), true); + const reconciled = await queue.reconcileQueuedMessageTargetsDurable( + 'thread-1', + 'user-1', + admitted.entry.id, + [], + ['opus'], + {}, ); + assert.equal(reconciled.outcome, 'updated'); + + assert.deepEqual(await queue.retireClaimedLifecycleTarget('thread-1', admitted.entry.id, 'opus', 2_000), { + outcome: 'retired', + rowStatus: 'queued', + }); + const remaining = await queue.getDurableEntry('thread-1', admitted.entry.id); + assert.equal(remaining.status, 'queued'); + assert.deepEqual(remaining.targets, ['codex']); + assert.equal(queue.findProcessingByCat('thread-1', 'opus').status, 'processing'); }); - it('markQueuedSeen does not hide queued bodies from full read view', () => { - const r = queue.enqueue( - entry({ - content: 'body stays readable after seen', - targetCats: ['opus'], - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: 'parent-opus' }, - }, - }), + it('freezes and withdraws the one source row for every pending message target', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable( + queueInput({ sourceId: 'withdraw-source', messageId: 'withdraw-message', targetCats: ['opus', 'codex'] }), ); + const claim = await queue.claimMessageEntriesForWithdrawal('thread-1', 'user-1', 'withdraw-message', 1_000); - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus'), true); + assert.equal(claim.outcome, 'claimed'); + assert.equal(claim.entries.length, 1); assert.equal( - queue.getQueuedFreshnessMessagesForCat('t1', 'u1', 'opus', { parentInvocationId: 'parent-opus' }).length, - 0, - ); - - const readable = queue.getQueuedBodyMessagesForCat('t1', 'u1', 'opus', 'parent-opus'); - assert.equal(readable.length, 1); - assert.equal(readable[0].entryId, r.entry.id); - assert.equal(readable[0].content, 'body stays readable after seen'); - }); - - it('queued body visibility is target-cat scoped, not thread-wide', () => { - queue.enqueue( - entry({ - content: 'sol-only queued body', - targetCats: ['codex-sol'], - authorIntentByCatId: { - 'codex-sol': { requested: 'continue_current', boundParentInvocationId: 'parent-sol' }, - }, - }), + await queue.commitClaimedMessageWithdrawal( + 'thread-1', + admitted.entries.map((entry) => entry.id), + ), + true, ); - - assert.equal(queue.getQueuedBodyMessagesForCat('t1', 'u1', 'codex-sol', 'parent-sol').length, 1); - assert.deepEqual(queue.getQueuedBodyMessagesForCat('t1', 'u1', 'gpt52', 'parent-sol'), []); + assert.equal(queue.list('thread-1', 'user-1').length, 0); + assert.equal(await queue.getDurableEntry('thread-1', admitted.entry.id), null); }); - it('coalescing queued agent content clears queued_seen so new content can nag again', () => { - const r = queue.enqueue( - entry({ - content: 'first handoff', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'codex', - targetCats: ['opus'], - }), - ); - - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus'), true); - assert.equal(queue.getQueuedFreshnessMessagesForCat('t1', 'u1', 'opus').length, 0); + it('keeps awakened and seen evidence only on the process-local admitted attempt', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable(queueInput({ sourceId: 'processing-receipt-source' })); + const processing = await queue.markProcessingDurable('thread-1', 'user-1', { + entryId: admitted.entry.id, + targetCats: ['opus'], + }); + assert.ok(processing); + assert.equal(await queue.commitClaimedProcessing('thread-1', [admitted.entry.id], 200), true); assert.equal( - queue.coalesceContentIntoQueuedAgent('t1', 'u1', r.entry.id, 'second handoff', 'msg-2', 'codex'), + await queue.markProcessingAwakened('thread-1', 'user-1', admitted.entry.id, 'opus', 'inv-processing', 210), true, ); - - const queued = queue.getQueuedFreshnessMessagesForCat('t1', 'u1', 'opus'); - assert.equal(queued.length, 1); - assert.match(queued[0].content, /second handoff/); - }); - - it('does not coalesce A2A carriers across owner authentication provenance', () => { - const r = queue.enqueue( - entry({ - content: 'strict handoff', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'codex', - a2aParentInvocationId: 'parent-1', - targetCats: ['opus'], - ownerAuthProvenance: 'strict', - }), + assert.deepEqual( + await queue.markProcessingSeen('thread-1', 'user-1', admitted.entry.id, 'opus', 'inv-processing', 220), + { changed: true, newlySeen: true }, ); - assert.equal(queue.findInFlightAgentEntry('t1', 'opus', 'codex', 'parent-1', 'compatibility_fallback'), null); + assert.equal(await queue.getDurableEntry('thread-1', admitted.entry.id), null); + const attempt = queue.findProcessingByCat('thread-1', 'opus'); + assert.equal(attempt.status, 'processing'); + assert.equal(attempt.processingStartedAt, 200); + assert.equal('awakenedInvocationId' in attempt.delivery, false); + assert.equal('seenInvocationId' in attempt.delivery, false); + assert.equal( - queue.coalesceContentIntoQueuedAgent( - 't1', - 'u1', - r.entry.id, - 'fallback handoff', - 'msg-fallback', - 'codex', - 'parent-1', - 'compatibility_fallback', - ), + await queue.markProcessingAwakened('thread-1', 'user-1', admitted.entry.id, 'codex', 'inv-processing', 230), false, + 'receipt evidence must remain target-bound', ); - assert.equal(queue.getEntrySnapshot('t1', 'u1', r.entry.id).content, 'strict handoff'); }); - // ── F254 D1.2b: queued_handled consumes only the completed target ── - - it('markQueuedHandledForCatAcrossUsers removes only the seen target from a multi-target entry', () => { - const r = queue.enqueue( - entry({ - content: 'multi target queued body', - targetCats: ['opus', 'codex'], - messageId: 'msg-1', + it('restores reversible claims during hydration after a host restart', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const firstHost = new InvocationQueue(ledger); + const admitted = await firstHost.enqueueDurable(queueInput({ sourceId: 'restart-claim' })); + assert.ok( + await firstHost.markProcessingDurable('thread-1', 'user-1', { + entryId: admitted.entry.id, + targetCats: ['opus'], }), ); - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus', 'inv-opus-1'), true); - - const handled = queue.markQueuedHandledForCatAcrossUsers('t1', 'opus', 'inv-opus-1'); - assert.equal(handled.length, 1); - assert.deepEqual(handled[0].messageIds, ['msg-1']); - assert.deepEqual(handled[0].remainingTargetCats, ['codex']); - assert.equal(handled[0].fullyConsumed, false); - const remaining = queue.list('t1', 'u1'); - assert.equal(remaining.length, 1); - assert.deepEqual(remaining[0].targetCats, ['codex']); + const restarted = new InvocationQueue(ledger); + assert.equal(await restarted.hydrateFromLedger(), 1); + assert.equal(restarted.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'queued'); + assert.equal((await restarted.getDurableEntry('thread-1', admitted.entry.id)).status, 'queued'); }); - it('markQueuedHandledForCatAcrossUsers removes the entry when the last seen target is handled', () => { - const r = queue.enqueue( - entry({ - content: 'single target queued body', + it('retires only the History-proven claimed target during restart hydration', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const firstHost = new InvocationQueue(ledger); + const admitted = await firstHost.enqueueDurable( + queueInput({ sourceId: 'restart-partial-delivery', targetCats: ['opus', 'codex'] }), + ); + assert.ok( + await firstHost.markProcessingDurable('thread-1', 'user-1', { + entryId: admitted.entry.id, targetCats: ['opus'], - messageId: 'msg-1', }), ); - queue.backfillMessageId('t1', 'u1', r.entry.id, 'msg-1b'); - r.entry.mergedMessageIds.push('msg-2'); - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus', 'inv-opus-1'), true); - - const handled = queue.markQueuedHandledForCatAcrossUsers('t1', 'opus', 'inv-opus-1'); - - assert.equal(handled.length, 1); - assert.deepEqual(handled[0].messageIds, ['msg-1', 'msg-1b', 'msg-2']); - assert.deepEqual(handled[0].remainingTargetCats, []); - assert.equal(handled[0].fullyConsumed, true); - assert.equal(queue.size('t1', 'u1'), 0); - }); - - it('markQueuedHandledForCatAcrossUsers ignores entries not seen by the completing cat', () => { - const r = queue.enqueue(entry({ targetCats: ['opus'], messageId: 'msg-1' })); - - const handled = queue.markQueuedHandledForCatAcrossUsers('t1', 'opus', 'inv-opus-1'); - - assert.deepEqual(handled, []); - assert.equal(queue.size('t1', 'u1'), 1); - assert.deepEqual(queue.list('t1', 'u1')[0].targetCats, ['opus']); - assert.equal(queue.list('t1', 'u1')[0].id, r.entry.id); - }); - - it('markQueuedHandledForCatAcrossUsers ignores processing entries', () => { - const r = queue.enqueue(entry({ targetCats: ['opus'], messageId: 'msg-1' })); - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus', 'inv-opus-1'), true); - queue.markProcessing('t1', 'u1'); - - const handled = queue.markQueuedHandledForCatAcrossUsers('t1', 'opus', 'inv-opus-1'); - - assert.deepEqual(handled, []); - assert.equal(queue.list('t1', 'u1')[0].status, 'processing'); - }); - - it('markQueuedHandledForCatAcrossUsers ignores stale seen markers from another invocation', () => { - const r = queue.enqueue(entry({ targetCats: ['opus'], messageId: 'msg-1' })); - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus', 'inv-failed'), true); - - const handled = queue.markQueuedHandledForCatAcrossUsers('t1', 'opus', 'inv-unrelated-success'); - - assert.deepEqual(handled, []); - assert.equal(queue.size('t1', 'u1'), 1); - assert.deepEqual(queue.list('t1', 'u1')[0].targetCats, ['opus']); - }); - - it('clearQueuedSeenInvocationForCats removes only matching retry evidence', () => { - const r = queue.enqueue(entry({ targetCats: ['opus', 'codex'], messageId: 'msg-1' })); - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus', 'inv-retry'), true); - assert.equal(queue.markQueuedSeen('t1', 'u1', r.entry.id, 'codex', 'inv-other'), true); - - const cleared = queue.clearQueuedSeenInvocationForCats('t1', ['opus'], 'inv-retry'); - - assert.equal(cleared, 1); - const remaining = queue.list('t1', 'u1')[0]; - assert.deepEqual(remaining.queuedSeenByCatIds.sort(), ['codex', 'opus']); - assert.deepEqual(remaining.queuedSeenInvocationIdByCatId, { codex: 'inv-other' }); - const handled = queue.markQueuedHandledForCatAcrossUsers('t1', 'opus', 'inv-retry'); - assert.deepEqual(handled, [], 'cleared retry evidence must not consume stale queued_seen'); + const restarted = new InvocationQueue(ledger); assert.equal( - queue.markQueuedSeen('t1', 'u1', r.entry.id, 'opus', 'inv-retry'), - false, - 'retry read refreshes evidence without creating a second queued_seen transition', - ); - const currentAttemptHandled = queue.markQueuedHandledForCatAcrossUsers('t1', 'opus', 'inv-retry'); - assert.equal(currentAttemptHandled.length, 1, 'fresh retry read evidence remains eligible for handled closure'); - }); - - it('clearQueuedSeenInvocationForCats also clears the exact stale Steer invocation', () => { - const r = queue.enqueue(entry({ targetCats: ['opus'], messageId: 'msg-steer' })); - assert.equal(queue.markSteering('t1', 'u1', r.entry.id, 'opus'), true); - queue.markProcessing('t1', 'u1'); - assert.deepEqual(queue.markProcessingSeen('t1', 'u1', r.entry.id, ['opus'], 'inv-steer-retry'), ['opus']); - assert.equal(queue.rollbackProcessing('t1', r.entry.id), true); - - const cleared = queue.clearQueuedSeenInvocationForCats('t1', ['opus'], 'inv-steer-retry'); - - assert.equal(cleared, 1); - const remaining = queue.list('t1', 'u1')[0]; - assert.equal(remaining.queuedSeenInvocationIdByCatId, undefined); - assert.equal(remaining.steeredInvocationIdByCatId, undefined); - }); - - // ── Backfill / Merge IDs ── - - it('backfillMessageId sets messageId on new entry (null → value)', () => { - const r = queue.enqueue(entry()); - assert.equal(r.entry.messageId, null); - queue.backfillMessageId('t1', 'u1', r.entry.id, 'msg-123'); - assert.equal(queue.list('t1', 'u1')[0].messageId, 'msg-123'); - }); - - // ── Move / reorder ── - - it('move up swaps entry with previous', () => { - queue.enqueue(entry({ content: 'a', targetCats: ['a'] })); - const r2 = queue.enqueue(entry({ content: 'b', targetCats: ['b'] })); - const moved = queue.move('t1', 'u1', r2.entry.id, 'up'); - assert.equal(moved, true); - assert.equal(queue.list('t1', 'u1')[0].content, 'b'); - assert.equal(queue.list('t1', 'u1')[1].content, 'a'); - }); - - it('move down swaps entry with next', () => { - const r1 = queue.enqueue(entry({ content: 'a', targetCats: ['a'] })); - queue.enqueue(entry({ content: 'b', targetCats: ['b'] })); - const moved = queue.move('t1', 'u1', r1.entry.id, 'down'); - assert.equal(moved, true); - assert.equal(queue.list('t1', 'u1')[0].content, 'b'); - }); - - it('move returns false for processing entry', () => { - queue.enqueue(entry({ content: 'a', targetCats: ['a'] })); - queue.enqueue(entry({ content: 'b', targetCats: ['b'] })); - const processing = queue.markProcessing('t1', 'u1'); - assert.equal(queue.move('t1', 'u1', processing.id, 'down'), false); - }); - - it('move at boundary is no-op (returns true, idempotent)', () => { - const r1 = queue.enqueue(entry({ content: 'only' })); - assert.equal(queue.move('t1', 'u1', r1.entry.id, 'up'), true); - }); - - it('system continuation stays first even when user entries have explicit positions', () => { - queue.enqueue( - entry({ - content: 'continue sealed work', - source: 'agent', - sourceCategory: 'continuation', - continuationKey: 't1:opus:inv-1:sess-1:1', - autoExecute: true, + await restarted.hydrateFromLedger({ + getById: async () => ({ + deliveryStatus: 'delivered', + lifecycle: { + kind: 'input', + orderKey: '1:restart-partial-delivery', + dispatchRefs: [ + { + targetId: 'opus', + phase: 'dispatched', + statusMessageId: 'response-opus', + dispatchedAt: 2, + }, + ], + }, + }), }), + 1, ); - const user = queue.enqueue(entry({ content: 'new user request', targetCats: ['codex'] })); - - assert.equal(queue.setPosition('t1', 'u1', user.entry.id, 0), true); - - assert.equal(queue.list('t1', 'u1')[0].content, 'continue sealed work'); - assert.equal(queue.peekOldestAcrossUsers('t1').content, 'continue sealed work'); + assert.deepEqual(restarted.list('thread-1', 'user-1')[0].targets, ['codex']); + assert.deepEqual((await ledger.get('thread-1', admitted.entry.id)).targets, ['codex']); }); - it('system continuation entries cannot be moved, promoted, or assigned user positions', () => { - const continuation = queue.enqueue( - entry({ - content: 'continue sealed work', - source: 'agent', - sourceCategory: 'continuation', - continuationKey: 't1:opus:inv-1:sess-1:1', - autoExecute: true, - }), + it('treats an already-retired target with queued siblings as converged after an uncertain commit receipt', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const queue = new InvocationQueue(ledger); + const admitted = await queue.enqueueDurable( + queueInput({ sourceId: 'uncertain-retirement-receipt', targetCats: ['opus', 'codex'] }), ); - queue.enqueue(entry({ content: 'new user request', targetCats: ['codex'] })); - - assert.equal(queue.setPosition('t1', 'u1', continuation.entry.id, 9), false); - assert.equal(queue.move('t1', 'u1', continuation.entry.id, 'down'), false); - assert.equal(queue.promote('t1', 'u1', continuation.entry.id), false); - assert.equal(queue.list('t1', 'u1')[0].content, 'continue sealed work'); - }); - - // ── Clear ── - - it('clear returns all removed entries', () => { - queue.enqueue(entry({ content: 'a', targetCats: ['a'] })); - queue.enqueue(entry({ content: 'b', targetCats: ['b'] })); - const cleared = queue.clear('t1', 'u1'); - assert.equal(cleared.length, 2); - assert.equal(queue.size('t1', 'u1'), 0); - }); - - // ── markProcessing / removeProcessed ── - - it('markProcessing returns entry with status=processing', () => { - queue.enqueue(entry()); - const p = queue.markProcessing('t1', 'u1'); - assert.equal(p.status, 'processing'); - assert.equal(queue.list('t1', 'u1')[0].status, 'processing'); - }); - - it('markProcessing returns null on empty queue', () => { - assert.equal(queue.markProcessing('t1', 'u1'), null); - }); - - it('removeProcessed removes processing entry by entryId', () => { - const r = queue.enqueue(entry()); - const marked = queue.markProcessing('t1', 'u1'); - const removed = queue.removeProcessed('t1', 'u1', marked.id); - assert.ok(removed); - assert.equal(removed.id, r.entry.id); - assert.equal(queue.list('t1', 'u1').length, 0); - }); - - // ── Cross-user isolation (scopeKey) ── - - it('different users in same thread are isolated', () => { - queue.enqueue(entry({ userId: 'alice', content: 'alice msg' })); - queue.enqueue(entry({ userId: 'bob', content: 'bob msg' })); - assert.equal(queue.size('t1', 'alice'), 1); - assert.equal(queue.size('t1', 'bob'), 1); - assert.equal(queue.list('t1', 'alice')[0].content, 'alice msg'); - assert.equal(queue.list('t1', 'bob')[0].content, 'bob msg'); - }); - - // ── Cross-user system methods ── - - it('peekOldestAcrossUsers returns earliest across all users', () => { - queue.enqueue(entry({ userId: 'bob', content: 'bob first' })); - queue.enqueue(entry({ userId: 'alice', content: 'alice second' })); - const oldest = queue.peekOldestAcrossUsers('t1'); - assert.equal(oldest.content, 'bob first'); - }); - - it('markProcessingAcrossUsers marks oldest entry', () => { - queue.enqueue(entry({ userId: 'bob', content: 'bob' })); - queue.enqueue(entry({ userId: 'alice', content: 'alice' })); - const p = queue.markProcessingAcrossUsers('t1'); - assert.equal(p.userId, 'bob'); - assert.equal(p.status, 'processing'); - }); - - it('removeProcessedAcrossUsers removes processing entry by entryId', () => { - queue.enqueue(entry({ userId: 'bob' })); - const marked = queue.markProcessingAcrossUsers('t1'); - const removed = queue.removeProcessedAcrossUsers('t1', marked.id); - assert.equal(removed.userId, 'bob'); - assert.equal(queue.list('t1', 'bob').length, 0); - }); - - it('hasQueuedForThread returns true when any user has queued entries', () => { - assert.equal(queue.hasQueuedForThread('t1'), false); - queue.enqueue(entry({ userId: 'alice' })); - assert.equal(queue.hasQueuedForThread('t1'), true); - }); - - it('hasQueuedForThread ignores stale queued entries', () => { - queue.enqueue(entry({ userId: 'alice' })); - const listed = queue.list('t1', 'alice'); - listed[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - assert.equal( - queue.hasQueuedForThread('t1'), - false, - 'stale queued entries must not permanently force thread-wide broadcast messages into queue mode', + assert.ok( + await queue.markProcessingDurable('thread-1', 'user-1', { + entryId: admitted.entry.id, + targetCats: ['opus'], + }), ); - }); + assert.equal(await queue.commitClaimedProcessing('thread-1', [admitted.entry.id], 200), true); - it('hasDispatchableQueuedForThread keeps stale user entries visible for dispatch', () => { - queue.enqueue(entry({ userId: 'alice', source: 'user' })); - const listed = queue.list('t1', 'alice'); - listed[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - assert.equal(queue.hasQueuedForThread('t1'), false, 'freshness/fairness gate should still ignore stale user work'); assert.equal( - queue.hasDispatchableQueuedForThread('t1'), + await queue.reconcileClaimedLifecycleTargets('thread-1', [admitted.entry.id], { + getById: async () => ({ + lifecycle: { + kind: 'input', + orderKey: '1:uncertain-retirement-receipt', + dispatchRefs: [ + { targetId: 'opus', phase: 'dispatched', statusMessageId: 'response-opus', dispatchedAt: 2 }, + ], + }, + }), + }), true, - 'dispatch gate must still see stale user work as pending queue work', ); + assert.deepEqual(queue.list('thread-1', 'user-1')[0].targets, ['codex']); }); - it('hasDispatchableQueuedForThread keeps stale connector entries visible for dispatch', () => { - queue.enqueue(entry({ userId: 'alice', source: 'connector' })); - const listed = queue.list('t1', 'alice'); - listed[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - assert.equal( - queue.hasQueuedForThread('t1'), - false, - 'freshness/fairness gate should still ignore stale connector work', - ); - assert.equal( - queue.hasDispatchableQueuedForThread('t1'), - true, - 'dispatch gate must still see stale connector work as pending queue work', + it('does not reconstruct admitted attempts from Queue after restart', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const firstHost = new InvocationQueue(ledger); + const admitted = await firstHost.enqueueDurable(queueInput({ sourceId: 'restart-processing' })); + assert.ok( + await firstHost.markProcessingDurable('thread-1', 'user-1', { + entryId: admitted.entry.id, + targetCats: ['opus'], + }), ); - }); - - // ── Cross-thread isolation ── - - it('different threads are fully isolated', () => { - queue.enqueue(entry({ threadId: 't1' })); - queue.enqueue(entry({ threadId: 't2' })); - assert.equal(queue.size('t1', 'u1'), 1); - assert.equal(queue.size('t2', 'u1'), 1); - queue.clear('t1', 'u1'); - assert.equal(queue.size('t1', 'u1'), 0); - assert.equal(queue.size('t2', 'u1'), 1); - }); - - // ── queuePosition ── - - it('enqueue returns 1-based queuePosition', () => { - const r1 = queue.enqueue(entry({ targetCats: ['a'] })); - assert.equal(r1.queuePosition, 1); - const r2 = queue.enqueue(entry({ targetCats: ['b'] })); - assert.equal(r2.queuePosition, 2); - }); - - // ── P1-1 fix: removeProcessed by entryId ── - - it('removeProcessed with wrong entryId does NOT remove', () => { - queue.enqueue(entry({ userId: 'u1', targetCats: ['a'] })); - queue.markProcessing('t1', 'u1'); - // Pass wrong entryId — should NOT remove - const removed = queue.removeProcessed('t1', 'u1', 'wrong-id'); - assert.equal(removed, null); - // Entry should still be there - assert.equal(queue.list('t1', 'u1').length, 1); - }); - - it('removeProcessedAcrossUsers with wrong entryId does NOT remove', () => { - queue.enqueue(entry({ userId: 'u1', targetCats: ['a'] })); - queue.markProcessingAcrossUsers('t1'); - // Pass wrong entryId — should NOT remove - const removed = queue.removeProcessedAcrossUsers('t1', 'wrong-id'); - assert.equal(removed, null); - }); - - // ── rollbackEnqueue removes entry (F175: no merge, simplified) ── - - it('rollbackEnqueue removes the entry from queue', () => { - const rA = queue.enqueue(entry({ content: 'A msg' })); - queue.enqueue(entry({ content: 'B msg' })); - queue.rollbackEnqueue('t1', 'u1', rA.entry.id); - const afterRollback = queue.list('t1', 'u1'); - assert.equal(afterRollback.length, 1); - assert.equal(afterRollback[0].content, 'B msg'); - }); - - it('clear() purges originalContents metadata', () => { - queue.enqueue(entry({ content: 'a' })); - queue.enqueue(entry({ content: 'b' })); - const cleared = queue.clear('t1', 'u1'); - assert.equal(cleared.length, 2); - assert.equal(queue.list('t1', 'u1').length, 0); - }); - - // ── Old queued agent entry defense (review P1/P2) ── + assert.equal(await firstHost.commitClaimedProcessing('thread-1', [admitted.entry.id], 1_000), true); - it('enqueue keeps old agent tail entry live while preserving F175 no-merge semantics', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'stale handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - // Backdate to make it stale - const listed = queue.list('t1', 'system'); - listed[0].createdAt = Date.now() - 120_000; - - // New A2A handoff for same cat stays independent under F175, and the old entry is not pruned. - const r2 = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'fresh handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - assert.equal(r2.outcome, 'enqueued', 'F175 keeps queued entries independent instead of merging'); - assert.equal(queue.list('t1', 'system').length, 2, 'old queued agent work is still live pending work'); + const restarted = new InvocationQueue(ledger); + assert.equal(await restarted.hydrateFromLedger(), 0); + assert.equal(restarted.list('thread-1', 'user-1').length, 0); + assert.equal(await restarted.getDurableEntry('thread-1', admitted.entry.id), null); }); - it('countAgentEntriesForThread includes old queued agent entries', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'stale', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'fresh', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex', - }); - // Backdate first entry to make it stale - const listed = queue.list('t1', 'system'); - listed[0].createdAt = Date.now() - 120_000; - - assert.equal( - queue.countAgentEntriesForThread('t1'), - 2, - 'old queued agent entries still count because they are pending work, not zombies', + it('treats a live claim as busy but ignores an explicitly excluded Steer reservation', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable( + queueInput({ + sourceId: 'agent-claim', + kind: 'message_wake', + from: { kind: 'agent', catId: 'codex' }, + sourceCategory: 'a2a', + }), ); - }); - - it('agent enqueue bypasses user depth while old queued agent entries remain pending work', () => { - // Fill to MAX_QUEUE_DEPTH with agent entries, then backdate them all. - for (let i = 0; i < 5; i++) { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: `stale-${i}`, - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: [`cat${i}`], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - } - const listed = queue.list('t1', 'system'); - for (const e of listed) { - e.createdAt = Date.now() - 120_000; // stale (> 60s threshold) - } - - // F175 only depth-limits user messages; agent work bypasses user queue depth but remains counted. - const r = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'fresh handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - assert.equal(r.outcome, 'enqueued'); - assert.equal(queue.countAgentEntriesForThread('t1'), 6); - }); - - it('hasQueuedForThread keeps old queued agent entries visible for dispatch', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'stale handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - const listed = queue.list('t1', 'system'); - listed[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - assert.equal(queue.hasQueuedForThread('t1'), true); - assert.equal(queue.list('t1', 'system').length, 1, 'old agent row must remain queued for dispatch'); - }); - - it('markProcessingAcrossUsers dispatches old queued agent entries instead of deleting them', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'stale handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - const stale = queue.list('t1', 'system'); - stale[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - queue.enqueue(entry({ userId: 'alice', content: 'fresh user work' })); - const marked = queue.markProcessingAcrossUsers('t1'); - - assert.equal(marked.userId, 'system'); - assert.equal(marked.content, 'stale handoff'); - assert.equal(queue.list('t1', 'system')[0].status, 'processing'); - }); - - // ── F122B: agent source + autoExecute ── - - it('accepts agent source with autoExecute and callerCatId', () => { - const result = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'A2A handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex', - }); - assert.equal(result.outcome, 'enqueued'); - assert.equal(result.entry.source, 'agent'); - assert.equal(result.entry.autoExecute, true); - assert.equal(result.entry.callerCatId, 'codex'); - }); - - it('autoExecute defaults to false when not provided', () => { - const result = queue.enqueue(entry()); - assert.equal(result.entry.autoExecute, false); - assert.equal(result.entry.callerCatId, undefined); - }); + assert.ok(await queue.markProcessingByIdDurable('thread-1', admitted.entry.id, 'opus')); - it('agent entries do not merge with user entries', () => { - queue.enqueue(entry({ content: 'user msg' })); - const r2 = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'A2A handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex', - }); - // Different userId (system vs u1) → different scope key → never merge - assert.equal(r2.outcome, 'enqueued'); + assert.equal(queue.hasActiveOrQueuedAgentForCat('thread-1', 'opus'), true); + assert.equal(queue.findProcessingByCat('thread-1', 'opus').id, admitted.entry.id); + assert.equal(queue.findProcessingByCat('thread-1', 'opus', admitted.entry.id), null); }); - // ── hasQueuedAgentForCat: only checks 'queued' (callback-path dedup) ── - - it('hasQueuedAgentForCat returns true for queued agent entry', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'callback handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - assert.equal(queue.hasQueuedAgentForCat('t1', 'codex'), true); - assert.equal(queue.hasQueuedAgentForCat('t1', 'opus'), false); - }); + it('returns detached snapshots so readers cannot mutate ledger-backed queue state', async () => { + const queue = new InvocationQueue(); + const admitted = await queue.enqueueDurable(queueInput({ sourceId: 'clone-source', content: 'original' })); + const listed = queue.list('thread-1', 'user-1'); + listed[0].payload.content = 'mutated'; + admitted.entry.delivery.authorIntentByTarget = {}; - it('hasQueuedAgentForCat returns false for processing entries (allows new handoffs to enqueue)', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'callback handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.markProcessing('t1', 'system'); - assert.equal( - queue.hasQueuedAgentForCat('t1', 'codex'), - false, - 'processing entries must not block new callback handoffs (P1-1 fix)', - ); - }); - - it('hasQueuedAgentForCat returns false for user-sourced entries', () => { - queue.enqueue(entry({ targetCats: ['opus'] })); - assert.equal(queue.hasQueuedAgentForCat('t1', 'opus'), false, 'user entries should not block A2A dedup'); - }); - - it('hasQueuedAgentForCat returns true for old queued entry (> STALE_QUEUED_THRESHOLD_MS)', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'callback handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - // Backdate createdAt to 2 minutes ago — well past the 60s stale threshold - const listed = queue.list('t1', 'system'); - listed[0].createdAt = Date.now() - 120_000; - assert.equal( - queue.hasQueuedAgentForCat('t1', 'codex'), - true, - 'old queued entry (>60s) remains valid pending work and should block duplicate A2A enqueue', - ); - }); - - it('hasQueuedAgentForCat returns false after entry completes', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - const e = queue.markProcessing('t1', 'system'); - queue.removeProcessed('t1', 'system', e.id); - assert.equal(queue.hasQueuedAgentForCat('t1', 'codex'), false); - }); - - it('listAutoExecute includes old queued entries older than threshold', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'fresh', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'stale', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['opencode'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - - // list() returns shallow-copied array with reference elements — mutating - // createdAt here reaches the real entry inside the queue (coupling on purpose). - const listed = queue.list('t1', 'system'); - listed[1].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - const autoEntries = queue.listAutoExecute('t1'); - assert.equal(autoEntries.length, 2, 'old queued autoExecute entries must remain dispatchable'); - assert.deepEqual(autoEntries.map((entry) => entry.targetCats[0]).sort(), ['codex', 'opencode']); - }); - - it('hasQueuedOrProcessingForCat treats old queued autoExecute entries as busy', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'stale but still dispatchable', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - - const listed = queue.list('t1', 'system'); - listed[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - assert.equal( - queue.hasQueuedOrProcessingForCat('t1', 'codex'), - true, - 'busy check must match listAutoExecute: old queued autoExecute work is still dispatchable', - ); - }); - - // ── hasActiveOrQueuedAgentForCat: processing + queued entries block, regardless of queued age ── - - it('hasActiveOrQueuedAgentForCat returns true for fresh queued entry (cross-path dedup)', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), - true, - 'fresh queued entry must block text-scan to prevent double-trigger', - ); - }); - - it('hasActiveOrQueuedAgentForCat returns true for old queued entry (> threshold)', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - // Simulate stale by backdating createdAt - const q = queue.list('t1', 'system'); - q[0].createdAt = Date.now() - 120_000; // 2 minutes ago - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), - true, - 'old queued entry (>60s) is still pending work and must block duplicate text-scan A2A', - ); - }); - - it('hasActiveOrQueuedAgentForCat returns true for processing entry (prevents text-scan double-trigger)', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.markProcessing('t1', 'system'); - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), - true, - 'must detect processing entries to prevent text-scan double-trigger', - ); - }); - - it('hasActiveOrQueuedAgentForCat can exclude the current processing entry', () => { - const current = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'current multi-target handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['opus-47', 'codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.markProcessing('t1', 'system'); - - assert.equal(queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), true); - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex', { excludeEntryId: current.entry.id }), - false, - 'current route entry must not block a later same-route A2A handoff back to an already-run target', - ); - }); - - it('hasActiveOrQueuedAgentForCat still blocks other pending entries when excluding current entry', () => { - const current = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'current route', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['opus-47', 'codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.markProcessing('t1', 'system'); - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'already queued callback handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex', { excludeEntryId: current.entry.id }), - true, - 'a separate queued agent entry must still block duplicate text-scan A2A', - ); - }); - - it('hasActiveOrQueuedAgentForCat returns false after entry completes', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - const e = queue.markProcessing('t1', 'system'); - queue.removeProcessed('t1', 'system', e.id); - assert.equal(queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), false); - }); - - it('hasQueuedOrProcessingForCat does not match another thread by prefix collision', () => { - queue.enqueue({ - threadId: 't1:child', - userId: 'u1', - content: 'queued in another thread', - source: 'user', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - }); - - assert.equal( - queue.hasQueuedOrProcessingForCat('t1', 'codex'), - false, - 'thread t1 must not inherit queued entries from thread t1:child', - ); - assert.equal(queue.hasQueuedOrProcessingForCat('t1:child', 'codex'), true); - }); - - it('hasActiveOrQueuedAgentForCat still blocks for fresh processing entry (< STALE_PROCESSING_THRESHOLD)', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.markProcessing('t1', 'system'); - // Backdate processingStartedAt to 5 minutes — well within the 10-minute threshold - const listed = queue.list('t1', 'system'); - listed[0].processingStartedAt = Date.now() - 5 * 60_000; - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), - true, - 'fresh processing entry (5 min) must still block text-scan dedup', - ); - }); - - it('hasActiveOrQueuedAgentForCat still blocks when entry queued long ago but just started processing', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - // Backdate createdAt to 11 minutes ago (sat in queue a long time) - const listed = queue.list('t1', 'system'); - listed[0].createdAt = Date.now() - 11 * 60_000; - // NOW start processing — processingStartedAt should be fresh - queue.markProcessing('t1', 'system'); - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), - true, - 'entry queued 11 min ago but just started processing must still block (P1 regression)', - ); - }); - - it('hasActiveOrQueuedAgentForCat returns false for stale processing entry (> STALE_PROCESSING_THRESHOLD)', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - queue.markProcessing('t1', 'system'); - // Backdate processingStartedAt to 11 minutes — beyond the 10-minute threshold - const listed = queue.list('t1', 'system'); - listed[0].processingStartedAt = Date.now() - 11 * 60_000; - assert.equal( - queue.hasActiveOrQueuedAgentForCat('t1', 'codex'), - false, - 'stale processing entry (11 min) must NOT block text-scan — zombie defense', - ); - }); - - // ── hasQueuedUserMessagesForThread: fairness gate must only count user-sourced entries ── - - it('hasQueuedUserMessagesForThread returns false when only agent entries are queued', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - assert.equal( - queue.hasQueuedUserMessagesForThread('t1'), - false, - 'agent-sourced entries must NOT block A2A text-scan fairness gate', - ); - // Sanity: unfiltered hasQueuedForThread still sees it - assert.equal(queue.hasQueuedForThread('t1'), true); - }); - - it('hasQueuedUserMessagesForThread returns true when user entry is queued', () => { - queue.enqueue(entry({ source: 'user' })); - assert.equal( - queue.hasQueuedUserMessagesForThread('t1'), - true, - 'user-sourced entries must block A2A text-scan to respect queue fairness', - ); - }); - - it('hasQueuedUserMessagesForThread ignores connector entries (treated like agent)', () => { - queue.enqueue(entry({ source: 'connector' })); - assert.equal( - queue.hasQueuedUserMessagesForThread('t1'), - false, - 'connector-sourced entries should not block A2A text-scan (user-only method)', - ); - }); - - // ── F185 Phase B: text-scan fairness gate must use hasQueuedNonAgentForThread ── - - it('hasQueuedNonAgentForThread blocks text-scan when connector entry is queued (F185-B AC-B1)', () => { - queue.enqueue(entry({ source: 'connector', targetCats: ['opus'] })); - assert.equal( - queue.hasQueuedNonAgentForThread('t1'), - true, - 'connector-sourced entries must block A2A text-scan fairness gate', - ); - }); - - it('hasQueuedNonAgentForThread does not block when only agent entries are queued (F185-B AC-B8)', () => { - queue.enqueue(entry({ source: 'agent', targetCats: ['opus'], callerCatId: 'codex' })); - assert.equal(queue.hasQueuedNonAgentForThread('t1'), false, 'pure agent queue must not trigger fairness gate'); - }); - - // ── F185 Phase B P1-1: deferred A2A messageId preservation ── - - it('enqueue preserves messageId when provided (F185-B deferred handoff)', () => { - const result = queue.enqueue( - entry({ - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'opus', - messageId: 'msg-trigger-123', - }), - ); - assert.equal( - result.entry.messageId, - 'msg-trigger-123', - 'deferred A2A entry must carry triggerMessageId as messageId', - ); - }); - - it('enqueue defaults messageId to null when not provided', () => { - const result = queue.enqueue(entry({ source: 'user' })); - assert.equal(result.entry.messageId, null, 'messageId must default to null for normal entries'); - }); - - // ── F175: priority / sourceCategory / position fields ── - - it('enqueue preserves priority field', () => { - const result = queue.enqueue(entry({ priority: 'urgent', sourceCategory: 'ci' })); - assert.equal(result.entry.priority, 'urgent'); - assert.equal(result.entry.sourceCategory, 'ci'); - }); - - it('defaults priority to normal when omitted', () => { - const result = queue.enqueue(entry()); - assert.equal(result.entry.priority, 'normal'); - assert.equal(result.entry.sourceCategory, undefined); - assert.equal(result.entry.position, undefined); - }); - - it('priority field survives list() round-trip', () => { - queue.enqueue(entry({ priority: 'urgent', sourceCategory: 'review' })); - const listed = queue.list('t1', 'u1'); - assert.equal(listed[0].priority, 'urgent'); - assert.equal(listed[0].sourceCategory, 'review'); - }); - - it('position field is undefined by default', () => { - const result = queue.enqueue(entry()); - assert.equal(result.entry.position, undefined); - }); - - // ── F175: no merge — every message is independent ── - - it('same-source same-target user messages are NOT merged (F175)', () => { - queue.enqueue(entry({ content: 'a' })); - queue.enqueue(entry({ content: 'b' })); - const list = queue.list('t1', 'u1'); - assert.equal(list.length, 2); - assert.equal(list[0].content, 'a'); - assert.equal(list[1].content, 'b'); - }); - - // ── F175: source-specific capacity ── - - it('connector messages bypass MAX_QUEUE_DEPTH (F175)', () => { - for (let i = 0; i < 7; i++) { - const r = queue.enqueue(entry({ content: `msg${i}`, source: 'connector', targetCats: ['c1'] })); - assert.equal(r.outcome, 'enqueued', `connector entry ${i} should enqueue`); - } - assert.equal(queue.list('t1', 'u1').length, 7); - }); - - it('agent messages bypass MAX_QUEUE_DEPTH (F175)', () => { - for (let i = 0; i < 7; i++) { - const r = queue.enqueue(entry({ content: `msg${i}`, source: 'agent', targetCats: [`c${i}`] })); - assert.equal(r.outcome, 'enqueued', `agent entry ${i} should enqueue`); - } - }); - - it('user messages still limited by MAX_QUEUE_DEPTH (F175)', () => { - for (let i = 0; i < 5; i++) { - queue.enqueue(entry({ content: `msg${i}`, targetCats: [`c${i}`] })); - } - const r = queue.enqueue(entry({ content: 'overflow', targetCats: ['overflow'] })); - assert.equal(r.outcome, 'full'); - }); - - // ── F175: multi-dimensional dequeue ordering ── - - it('urgent entry dequeues before normal via peekOldestAcrossUsers', () => { - queue.enqueue(entry({ userId: 'u1', content: 'normal-first', priority: 'normal' })); - queue.enqueue( - entry({ userId: 'u2', content: 'urgent-second', source: 'connector', targetCats: ['c1'], priority: 'urgent' }), - ); - const next = queue.peekOldestAcrossUsers('t1'); - assert.equal(next.content, 'urgent-second'); - assert.equal(next.priority, 'urgent'); - }); - - it('same priority orders by createdAt (FIFO)', () => { - queue.enqueue(entry({ userId: 'u1', content: 'first', priority: 'normal' })); - queue.enqueue( - entry({ userId: 'u2', content: 'second', source: 'connector', targetCats: ['c1'], priority: 'normal' }), - ); - const next = queue.peekOldestAcrossUsers('t1'); - assert.equal(next.content, 'first'); - }); - - it('markProcessingAcrossUsers picks urgent before normal', () => { - queue.enqueue(entry({ userId: 'u1', content: 'normal', priority: 'normal' })); - queue.enqueue( - entry({ userId: 'u2', content: 'urgent', source: 'connector', targetCats: ['c1'], priority: 'urgent' }), - ); - const picked = queue.markProcessingAcrossUsers('t1'); - assert.equal(picked.content, 'urgent'); - assert.equal(picked.status, 'processing'); - }); - - it('explicit position overrides priority in dequeue', () => { - queue.enqueue( - entry({ userId: 'u1', content: 'urgent-no-pos', source: 'connector', targetCats: ['c1'], priority: 'urgent' }), - ); - const r = queue.enqueue( - entry({ userId: 'u1', content: 'normal-with-pos', targetCats: ['c2'], priority: 'normal' }), - ); - queue.setPosition('t1', 'u1', r.entry.id, 0); - const next = queue.peekOldestAcrossUsers('t1'); - assert.equal(next.content, 'normal-with-pos'); - }); - - it('setPosition returns false for processing entry', () => { - queue.enqueue(entry({ content: 'a' })); - const processing = queue.markProcessing('t1', 'u1'); - assert.equal(queue.setPosition('t1', 'u1', processing.id, 0), false); - }); - - it('setPosition returns false for non-existent entry', () => { - assert.equal(queue.setPosition('t1', 'u1', 'nonexistent', 0), false); - }); - - it('position does not let one user jump ahead of another in cross-user scheduling', () => { - queue.enqueue(entry({ userId: 'alice', content: 'alice-first' })); - const bobEntry = queue.enqueue(entry({ userId: 'bob', content: 'bob-second' })); - queue.setPosition('t1', 'bob', bobEntry.entry.id, 0); - - const next = queue.peekOldestAcrossUsers('t1'); - assert.equal(next.userId, 'alice', 'alice enqueued first — position should not let bob jump ahead cross-user'); - }); - - // ── F175 Task 5: collectUserBatch ── - - it('collectUserBatch collects adjacent user entries with same userId+intent+targetCats', () => { - queue.enqueue(entry({ content: 'a', source: 'user', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'b', source: 'user', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'c', source: 'user', targetCats: ['c1'], intent: 'execute' })); - const batch = queue.collectUserBatch('t1', 'u1'); - assert.equal(batch.length, 3); - assert.equal(batch.map((e) => e.content).join('\n'), 'a\nb\nc'); - }); - - it('does not batch user entries across owner authentication provenance', () => { - queue.enqueue( - entry({ - content: 'strict-owner-message', - source: 'user', - targetCats: ['c1'], - intent: 'execute', - ownerAuthProvenance: 'strict', - }), - ); - queue.enqueue( - entry({ - content: 'fallback-owner-message', - source: 'user', - targetCats: ['c1'], - intent: 'execute', - ownerAuthProvenance: 'compatibility_fallback', - }), - ); - - const batch = queue.collectUserBatch('t1', 'u1'); - - assert.equal(batch.length, 1); - assert.equal(batch[0].content, 'strict-owner-message'); - assert.equal(batch[0].ownerAuthProvenance, 'strict'); - }); - - it('collectUserBatch stops at different intent', () => { - queue.enqueue(entry({ content: 'a', source: 'user', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'b', source: 'user', targetCats: ['c1'], intent: 'search' })); - const batch = queue.collectUserBatch('t1', 'u1'); - assert.equal(batch.length, 1); - assert.equal(batch[0].content, 'a'); - }); - - it('collectUserBatch stops at connector/agent entry', () => { - queue.enqueue(entry({ content: 'a', source: 'user', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'b', source: 'connector', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'c', source: 'user', targetCats: ['c1'], intent: 'execute' })); - const batch = queue.collectUserBatch('t1', 'u1'); - assert.equal(batch.length, 1); - }); - - it('collectUserBatch does not batch across different targetCats', () => { - queue.enqueue(entry({ content: 'a', source: 'user', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'b', source: 'user', targetCats: ['c1', 'c2'], intent: 'execute' })); - const batch = queue.collectUserBatch('t1', 'u1'); - assert.equal(batch.length, 1); - }); - - it('collectUserBatch returns single-entry batch for connector source', () => { - queue.enqueue(entry({ content: 'a', source: 'connector', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'b', source: 'connector', targetCats: ['c1'], intent: 'execute' })); - const batch = queue.collectUserBatch('t1', 'u1'); - assert.equal(batch.length, 1); - assert.equal(batch[0].content, 'a'); - }); - - it('collectUserBatch returns empty array for empty queue', () => { - const batch = queue.collectUserBatch('t1', 'u1'); - assert.equal(batch.length, 0); - }); - - it('collectUserBatch skips processing entries and starts from first queued', () => { - queue.enqueue(entry({ content: 'processing', source: 'user', targetCats: ['c1'], intent: 'execute' })); - queue.markProcessing('t1', 'u1'); - queue.enqueue(entry({ content: 'a', source: 'user', targetCats: ['c1'], intent: 'execute' })); - queue.enqueue(entry({ content: 'b', source: 'user', targetCats: ['c1'], intent: 'execute' })); - const batch = queue.collectUserBatch('t1', 'u1'); - assert.equal(batch.length, 2); - assert.equal(batch[0].content, 'a'); - assert.equal(batch[1].content, 'b'); - }); - - // ── P2-3 fix: markProcessing/peekNextQueued must respect comparator ── - - it('markProcessing respects position override (P2-3)', () => { - const rA = queue.enqueue(entry({ content: 'A' })); - const rB = queue.enqueue(entry({ content: 'B' })); - queue.setPosition('t1', 'u1', rB.entry.id, 0); - - const processing = queue.markProcessing('t1', 'u1'); - assert.equal(processing.content, 'B', 'markProcessing should pick position-0 entry first'); - }); - - it('peekNextQueued respects priority ordering (P2-3)', () => { - queue.enqueue(entry({ content: 'normal', priority: 'normal' })); - queue.enqueue(entry({ content: 'urgent', priority: 'urgent' })); - - const next = queue.peekNextQueued('t1', 'u1'); - assert.equal(next.content, 'urgent', 'peekNextQueued should return urgent entry first'); - }); - - // ── R2-P1: promote() must win over existing position in comparator ── - - it('promote() makes entry win comparator even when another has position=0 (R2-P1)', () => { - const rA = queue.enqueue(entry({ content: 'A' })); - const rB = queue.enqueue(entry({ content: 'B' })); - queue.setPosition('t1', 'u1', rB.entry.id, 0); - - queue.promote('t1', 'u1', rA.entry.id); - - const next = queue.peekNextQueued('t1', 'u1'); - assert.equal(next.content, 'A', 'promoted entry should beat position=0 in comparator'); - }); - - it('move(up) swaps position with neighbor in comparator order (R2-P1)', () => { - const rA = queue.enqueue(entry({ content: 'A' })); - const rB = queue.enqueue(entry({ content: 'B' })); - queue.setPosition('t1', 'u1', rA.entry.id, 5); - queue.setPosition('t1', 'u1', rB.entry.id, 0); - - // B is at position 0 (first), A is at position 5 (second) - // move A up → should swap with B - queue.move('t1', 'u1', rA.entry.id, 'up'); - - const next = queue.peekNextQueued('t1', 'u1'); - assert.equal(next.content, 'A', 'after move up, A should be first in comparator'); - }); - - it('move(up) on 3+ entries without positions only swaps adjacent pair (R3-P1)', () => { - queue.enqueue(entry({ content: 'A' })); - queue.enqueue(entry({ content: 'B' })); - const rC = queue.enqueue(entry({ content: 'C' })); - - queue.move('t1', 'u1', rC.entry.id, 'up'); - - const items = queue.list('t1', 'u1').map((e) => e.content); - assert.deepStrictEqual(items, ['A', 'C', 'B'], 'move(C, up) should only swap C and B'); - }); - - it('move(down) on 3+ entries without positions only swaps adjacent pair (R3-P1)', () => { - queue.enqueue(entry({ content: 'A' })); - const rB = queue.enqueue(entry({ content: 'B' })); - queue.enqueue(entry({ content: 'C' })); - - queue.move('t1', 'u1', rB.entry.id, 'down'); - - const items = queue.list('t1', 'u1').map((e) => e.content); - assert.deepStrictEqual(items, ['A', 'C', 'B'], 'move(B, down) should only swap B and C'); - }); - - // ── F185: hasQueuedNonAgentForThread (AC-6) ── - - it('hasQueuedNonAgentForThread returns false when only agent entries are queued', () => { - queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - assert.equal(queue.hasQueuedNonAgentForThread('t1'), false, 'agent-only queue should not trigger fairness gate'); - }); - - it('hasQueuedNonAgentForThread returns true when connector entry is queued', () => { - queue.enqueue(entry({ source: 'connector', targetCats: ['opus'] })); - assert.equal(queue.hasQueuedNonAgentForThread('t1'), true, 'connector entry must trigger fairness gate'); - }); - - it('hasQueuedNonAgentForThread returns true when user entry is queued', () => { - queue.enqueue(entry({ source: 'user' })); - assert.equal(queue.hasQueuedNonAgentForThread('t1'), true, 'user entry must trigger fairness gate'); - }); - - it('hasQueuedNonAgentForThread ignores processing entries', () => { - queue.enqueue(entry({ source: 'connector', targetCats: ['opus'] })); - queue.markProcessing('t1', 'u1'); - assert.equal(queue.hasQueuedNonAgentForThread('t1'), false, 'processing entries are already being handled'); - }); - - it('hasQueuedNonAgentForThread returns false for empty queue', () => { - assert.equal(queue.hasQueuedNonAgentForThread('t1'), false); - }); - - // ── F185: agent urgent prohibition (AC-8) ── - - it('rejects urgent priority for agent entries (non-continuation)', () => { - const result = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'A2A handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - priority: 'urgent', - }); - assert.equal(result.entry.priority, 'normal', 'agent entry urgent must be downgraded to normal'); - }); - - it('allows urgent priority for continuation entries (AC-12)', () => { - const result = queue.enqueue({ - threadId: 't1', - userId: 'system', - content: 'continuation', - source: 'agent', - ownerAuthProvenance: 'unknown', - sourceCategory: 'continuation', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - priority: 'urgent', - }); - assert.equal(result.entry.priority, 'urgent', 'continuation entry must keep urgent priority'); - }); - - it('allows urgent priority for connector entries', () => { - const result = queue.enqueue(entry({ source: 'connector', priority: 'urgent', sourceCategory: 'ci' })); - assert.equal(result.entry.priority, 'urgent', 'connector urgent must not be affected'); - }); - - // ── R2-P2: collectUserBatch returns entries in comparator order ── - - it('collectUserBatch returns entries sorted by comparator (R2-P2)', () => { - queue.enqueue(entry({ content: 'B' })); - const rD = queue.enqueue(entry({ content: 'D' })); - const rE = queue.enqueue(entry({ content: 'E' })); - // Array order: B, D, E. Set positions: D=0, E=1, B=2 - queue.setPosition('t1', 'u1', rD.entry.id, 0); - queue.setPosition('t1', 'u1', rE.entry.id, 1); - // B has no position → comparator puts it after positioned entries - - // Mark D as processing (simulating processNext picked it) - queue.markProcessing('t1', 'u1'); - - const batch = queue.collectUserBatch('t1', 'u1'); - const contents = batch.map((e) => e.content); - assert.deepStrictEqual(contents, ['E', 'B'], 'batch should follow comparator order: E(pos=1) then B(no pos)'); - }); - - // ── F153: callerTraceContext propagation ── - - it('F153: callerTraceContext flows through enqueue + dequeue', () => { - const ctx = { traceId: 'aabb', spanId: 'ccdd', traceFlags: 1 }; - queue.enqueue(entry({ callerTraceContext: ctx })); - const d = queue.dequeue('t1', 'u1'); - assert.deepEqual(d.callerTraceContext, ctx); - }); - - it('F153: entries without callerTraceContext have undefined', () => { - queue.enqueue(entry()); - const d = queue.dequeue('t1', 'u1'); - assert.equal(d.callerTraceContext, undefined); - }); - - // findProcessingByCat (2026-06-02 Steer 抢占 tombstone support) - describe('findProcessingByCat', () => { - it('finds the processing entry targeting a cat (across users)', () => { - queue.enqueue(entry({ userId: 'u1', targetCats: ['opus'] })); - queue.markProcessing('t1', 'u1'); // → processing - queue.enqueue(entry({ userId: 'u2', targetCats: ['codex'] })); - queue.markProcessing('t1', 'u2'); - const found = queue.findProcessingByCat('t1', 'opus'); - assert.ok(found); - assert.equal(found.targetCats[0], 'opus'); - assert.equal(found.status, 'processing'); - }); - - it('returns null when the cat has no processing entry (only queued)', () => { - queue.enqueue(entry({ targetCats: ['opus'] })); // queued, not processing - assert.equal(queue.findProcessingByCat('t1', 'opus'), null); - }); - - it('returns null for a different cat', () => { - queue.enqueue(entry({ targetCats: ['opus'] })); - queue.markProcessing('t1', 'u1'); - assert.equal(queue.findProcessingByCat('t1', 'codex'), null); - }); - }); - - // ── #815: findSubsumedA2ACandidates userId scoping ── - - describe('#815: findSubsumedA2ACandidates userId isolation', () => { - it('returns A2A candidates only from the specified userId queue', () => { - // User A's A2A entry - queue.enqueue( - entry({ - userId: 'userA', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - content: 'userA trigger', - autoExecute: true, - }), - ); - // User B's A2A entry for same cat - queue.enqueue( - entry({ - userId: 'userB', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - content: 'userB trigger', - autoExecute: true, - }), - ); - - const activeCats = new Set(['opus']); - const candidatesA = queue.findSubsumedA2ACandidates('t1', 'userA', activeCats); - const candidatesB = queue.findSubsumedA2ACandidates('t1', 'userB', activeCats); - - assert.equal(candidatesA.length, 1); - assert.equal(candidatesA[0].content, 'userA trigger'); - assert.equal(candidatesB.length, 1); - assert.equal(candidatesB[0].content, 'userB trigger'); - }); - - it('does not return A2A entries from other users when consuming', () => { - queue.enqueue( - entry({ - userId: 'userA', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - autoExecute: true, - }), - ); - queue.enqueue( - entry({ - userId: 'userB', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - autoExecute: true, - }), - ); - - const activeCats = new Set(['opus']); - // Find and consume only userA's entries - const candidates = queue.findSubsumedA2ACandidates('t1', 'userA', activeCats); - assert.equal(candidates.length, 1); - - const consumed = queue.consumeEntriesById(new Set([candidates[0].id])); - assert.equal(consumed.length, 1); - - // userB's entry should still be there - const remainingB = queue.findSubsumedA2ACandidates('t1', 'userB', activeCats); - assert.equal(remainingB.length, 1); - }); - - it('skips entries whose targetCats are not all active', () => { - queue.enqueue( - entry({ - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus', 'codex'], - autoExecute: true, - }), - ); - - // Only opus is active, but entry targets [opus, codex] - const candidates = queue.findSubsumedA2ACandidates('t1', 'u1', new Set(['opus'])); - assert.equal(candidates.length, 0); - - // Both active → found - const candidates2 = queue.findSubsumedA2ACandidates('t1', 'u1', new Set(['opus', 'codex'])); - assert.equal(candidates2.length, 1); - }); - - it('skips non-a2a entries', () => { - // User entry (not a2a) — different targetCats to prevent merge - queue.enqueue(entry({ targetCats: ['codex'] })); - // A2A entry — queued, should match - queue.enqueue(entry({ source: 'agent', sourceCategory: 'a2a', targetCats: ['opus'], autoExecute: true })); - - // Only the A2A entry matches, not the user entry - const candidates = queue.findSubsumedA2ACandidates('t1', 'u1', new Set(['opus', 'codex'])); - assert.equal(candidates.length, 1); - assert.equal(candidates[0].sourceCategory, 'a2a'); - }); - - it('skips processing entries', () => { - const a2aResult = queue.enqueue( - entry({ source: 'agent', sourceCategory: 'a2a', targetCats: ['opus'], autoExecute: true }), - ); - queue.markProcessing('t1', 'u1', a2aResult.entry.id); - - const candidates = queue.findSubsumedA2ACandidates('t1', 'u1', new Set(['opus'])); - assert.equal(candidates.length, 0); // processing → skipped - }); - - it('returns empty for non-existent userId', () => { - queue.enqueue(entry({ source: 'agent', sourceCategory: 'a2a', targetCats: ['opus'], autoExecute: true })); - const candidates = queue.findSubsumedA2ACandidates('t1', 'nonexistent', new Set(['opus'])); - assert.equal(candidates.length, 0); - }); - }); - - // ── #815: consumeEntriesById ── - - describe('#815: consumeEntriesById', () => { - it('removes only entries with matching IDs', () => { - const r1 = queue.enqueue( - entry({ source: 'agent', sourceCategory: 'a2a', targetCats: ['opus'], autoExecute: true, content: 'a' }), - ); - const r2 = queue.enqueue( - entry({ source: 'agent', sourceCategory: 'a2a', targetCats: ['codex'], autoExecute: true, content: 'b' }), - ); - - const consumed = queue.consumeEntriesById(new Set([r1.entry.id])); - assert.equal(consumed.length, 1); - assert.equal(consumed[0].content, 'a'); - - // r2 should still be in queue - const remaining = queue.list('t1', 'u1'); - assert.equal(remaining.length, 1); - assert.equal(remaining[0].id, r2.entry.id); - }); - }); - - // findProcessingByCat (2026-06-02 Steer 抢占 tombstone support) - describe('findProcessingByCat', () => { - it('finds the processing entry targeting a cat (across users)', () => { - queue.enqueue(entry({ userId: 'u1', targetCats: ['opus'] })); - queue.markProcessing('t1', 'u1'); // → processing - queue.enqueue(entry({ userId: 'u2', targetCats: ['codex'] })); - queue.markProcessing('t1', 'u2'); - const found = queue.findProcessingByCat('t1', 'opus'); - assert.ok(found); - assert.equal(found.targetCats[0], 'opus'); - assert.equal(found.status, 'processing'); - }); - - it('returns null when the cat has no processing entry (only queued)', () => { - queue.enqueue(entry({ targetCats: ['opus'] })); // queued, not processing - assert.equal(queue.findProcessingByCat('t1', 'opus'), null); - }); - - it('returns null for a different cat', () => { - queue.enqueue(entry({ targetCats: ['opus'] })); - queue.markProcessing('t1', 'u1'); - assert.equal(queue.findProcessingByCat('t1', 'codex'), null); - }); + const current = queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id); + assert.equal(current.payload.content, 'original'); + assert.equal(current.delivery.authorIntentByTarget, undefined); }); }); diff --git a/packages/api/test/invocation-tracker.test.js b/packages/api/test/invocation-tracker.test.js index 16b56ff90b..b29fe01625 100644 --- a/packages/api/test/invocation-tracker.test.js +++ b/packages/api/test/invocation-tracker.test.js @@ -83,6 +83,141 @@ describe('InvocationTracker catId tracking (slot-aware)', () => { }); }); +describe('InvocationTracker lifecycle ActiveRun binding', () => { + it('registers a provider dispatcher only for the exact live child and stale release cannot erase a replacement', () => { + const tracker = new InvocationTracker(); + tracker.startAll('thread-1', ['opus'], 'owner-1', 'parent-1'); + const run = { + threadId: 'thread-1', + targetId: 'opus', + invocationId: 'child-1', + responseMessageId: 'response-1', + inputEntryIds: ['entry-1'], + inputMessageIds: ['message-1'], + privateInputEntryIds: [], + startedAt: 100, + }; + assert.equal(tracker.bindLifecycleActiveRun(run, 'parent-1'), true); + const first = { + invocationId: 'child-1', + capabilities: { append: true, steer: true }, + handle: { + provider: 'openai_codex', + carrier: 'codex_app_server', + threadId: 'native-thread-1', + turnId: 'native-turn-1', + }, + dispatch: async () => ({ accepted: true, handle: first.handle }), + }; + const releaseFirst = tracker.bindAgentClientActiveRunDispatcher('thread-1', 'opus', first, 'parent-1'); + assert.equal(typeof releaseFirst, 'function'); + assert.equal(tracker.getAgentClientActiveRunDispatcher('thread-1', 'opus'), first); + + const stale = { ...first, invocationId: 'child-stale' }; + assert.equal(tracker.bindAgentClientActiveRunDispatcher('thread-1', 'opus', stale, 'parent-1'), null); + + releaseFirst(); + assert.equal(tracker.getAgentClientActiveRunDispatcher('thread-1', 'opus'), undefined); + const replacement = { ...first, handle: { ...first.handle } }; + const releaseReplacement = tracker.bindAgentClientActiveRunDispatcher('thread-1', 'opus', replacement, 'parent-1'); + assert.equal(typeof releaseReplacement, 'function'); + releaseFirst(); + assert.equal(tracker.getAgentClientActiveRunDispatcher('thread-1', 'opus'), replacement); + }); + + it('mirrors one durable Append only onto the exact live Active Run', () => { + const tracker = new InvocationTracker(); + tracker.start('thread-1', 'codex', 'user-1', ['codex'], 'parent-1'); + const run = { + threadId: 'thread-1', + targetId: 'codex', + invocationId: 'turn-1', + responseMessageId: 'response-1', + inputEntryIds: ['entry-old'], + inputMessageIds: ['message-old'], + privateInputEntryIds: [], + startedAt: 1, + }; + assert.equal(tracker.bindLifecycleActiveRun(run, 'parent-1'), true); + tracker.bindAgentClientActiveRunDispatcher( + 'thread-1', + 'codex', + { + invocationId: 'turn-1', + capabilities: { append: true, steer: true }, + handle: { provider: 'openai_codex', carrier: 'codex_app_server', threadId: 'native-1', turnId: 'turn-1' }, + dispatch: async () => ({ accepted: true, handle: {} }), + }, + 'parent-1', + ); + + assert.equal( + tracker.appendLifecycleActiveRunInputs( + 'thread-1', + 'codex', + { invocationId: 'turn-stale', responseMessageId: 'response-1' }, + 'entry-new', + ['message-new'], + ), + false, + ); + assert.equal( + tracker.appendLifecycleActiveRunInputs( + 'thread-1', + 'codex', + { invocationId: 'turn-1', responseMessageId: 'response-1' }, + 'entry-new', + ['message-new'], + ), + true, + ); + assert.deepEqual(tracker.getActiveSlots('thread-1')[0].activeRun.inputEntryIds, ['entry-old', 'entry-new']); + assert.deepEqual(tracker.getActiveSlots('thread-1')[0].activeRun.inputMessageIds, ['message-old', 'message-new']); + }); + + it('binds the exact child response only to its current parent-owned slot', () => { + const tracker = new InvocationTracker(); + tracker.startAll('thread-1', ['opus'], 'owner-1', 'parent-1'); + const run = { + threadId: 'thread-1', + targetId: 'opus', + invocationId: 'child-1', + responseMessageId: 'response-1', + inputEntryIds: ['entry-1'], + inputMessageIds: ['message-1'], + privateInputEntryIds: [], + startedAt: 100, + }; + + assert.equal(tracker.bindLifecycleActiveRun(run, 'parent-1'), true); + assert.deepEqual(tracker.getActiveSlots('thread-1'), [{ catId: 'opus', startedAt: run.startedAt, activeRun: run }]); + assert.equal(tracker.bindLifecycleActiveRun({ ...run, invocationId: 'child-2' }, 'parent-2'), false); + assert.equal(tracker.getActiveSlots('thread-1')[0].activeRun.invocationId, 'child-1'); + }); + + it('removes ActiveRun with the slot and rejects a stale replacement binding', () => { + const tracker = new InvocationTracker(); + const oldBatch = tracker.startAll('thread-1', ['opus'], 'owner-1', 'parent-1'); + const oldRun = { + threadId: 'thread-1', + targetId: 'opus', + invocationId: 'child-1', + responseMessageId: 'response-1', + inputEntryIds: ['entry-1'], + inputMessageIds: ['message-1'], + privateInputEntryIds: [], + startedAt: 100, + }; + assert.equal(tracker.bindLifecycleActiveRun(oldRun, 'parent-1'), true); + tracker.completeAll('thread-1', ['opus'], oldBatch); + assert.deepEqual(tracker.getActiveSlots('thread-1'), []); + + tracker.startAll('thread-1', ['opus'], 'owner-1', 'parent-2'); + assert.equal(tracker.bindLifecycleActiveRun(oldRun, 'parent-1'), false); + assert.equal(tracker.getActiveSlots('thread-1')[0].activeRun, undefined); + }); +}); + describe('InvocationTracker preempt reason', () => { test('start aborts previous invocation with reason "preempted"', () => { const tracker = new InvocationTracker(); diff --git a/packages/api/test/invocations-retry.test.js b/packages/api/test/invocations-retry.test.js deleted file mode 100644 index 2090d1c614..0000000000 --- a/packages/api/test/invocations-retry.test.js +++ /dev/null @@ -1,748 +0,0 @@ -/** - * Invocations Retry Tests (ADR-008 S2) - * POST /api/invocations/:id/retry — 实际执行 retry 全路径 - */ - -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import Fastify from 'fastify'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { InvocationRecordStore } from '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { invocationsRoutes } from '../dist/routes/invocations.js'; - -/** Stub AgentRouter: routeExecution yields one visible turn and its terminal success witness. */ -function createMockRouter(options = {}) { - const { shouldThrow } = options; - const routeOptions = []; - return { - routeOptions, - routeExecution: async function* (_userId, _msg, _threadId, _userMsgId, _cats, _intent, opts) { - routeOptions.push(opts); - if (shouldThrow) { - throw new Error('Agent execution failed'); - } - yield { type: 'text', catId: 'opus', content: 'retry response', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute', explicit: false, promptTags: [] }, - }), - ackCollectedCursors: async () => {}, - }; -} - -/** Stub SocketManager: records broadcasts for assertions */ -function createMockSocketManager() { - const messages = []; - return { - broadcastAgentMessage(msg, threadId) { - messages.push({ type: 'agent', msg, threadId }); - }, - broadcastToRoom(room, event, data) { - messages.push({ type: 'room', room, event, data }); - }, - getMessages() { - return messages; - }, - }; -} - -/** - * Helper: set up a Fastify app with invocationsRoutes + a 'failed' InvocationRecord - * that has a stored user message linked to it. - */ -async function setupRetryScenario(routerOverride, trackerOverride, queueProcessorOverride, targetCats = ['opus']) { - const invocationRecordStore = new InvocationRecordStore(); - const messageStore = new MessageStore(); - const invocationTracker = trackerOverride ?? new InvocationTracker(); - const socketManager = createMockSocketManager(); - const router = routerOverride ?? createMockRouter(); - - // Pre-populate: store a user message and create a failed invocation record - const storedMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: '@布偶猫 hello retry', - mentions: ['opus'], - timestamp: Date.now(), - threadId: 'thread-1', - }); - - const createResult = invocationRecordStore.create({ - threadId: 'thread-1', - userId: 'user-1', - targetCats, - intent: 'execute', - idempotencyKey: 'key-retry-1', - actionLeaseCarrier: { kind: 'none' }, - }); - // Backfill userMessageId + transition through proper lifecycle: queued → running → failed - invocationRecordStore.update(createResult.invocationId, { - userMessageId: storedMsg.id, - status: 'running', - }); - invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - error: 'CLI timeout', - }); - - const app = Fastify(); - await app.register(invocationsRoutes, { - invocationRecordStore, - messageStore, - socketManager, - router, - invocationTracker, - ...(queueProcessorOverride ? { queueProcessor: queueProcessorOverride } : {}), - }); - await app.ready(); - - return { app, invocationRecordStore, messageStore, socketManager, router, invocationId: createResult.invocationId }; -} - -describe('POST /api/invocations/:id/retry (ADR-008 S2)', () => { - it('F293 retries only unfinished targets and preserves earlier successful witnesses', async () => { - const calls = []; - const router = createMockRouter(); - router.routeExecution = async function* (_owner, content, _thread, messageId, targets, _intent, options) { - calls.push({ content, messageId, targets, options }); - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }; - const { - app, - invocationRecordStore: store, - invocationId, - } = await setupRetryScenario(router, undefined, undefined, ['opus', 'codex']); - store.update(invocationId, { status: 'running' }); - store.update(invocationId, { status: 'failed', error: 'routing_preflight_rejected', successfulCatIds: ['opus'] }); - const originalMessageId = store.get(invocationId).userMessageId; - const response = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - headers: { 'x-cat-cafe-user': 'user-1' }, - }); - assert.equal(response.statusCode, 202); - await new Promise((resolve) => setTimeout(resolve, 50)); - assert.deepEqual(calls[0].targets, ['codex']); - assert.equal(calls[0].messageId, originalMessageId); - assert.equal(calls[0].content, '@布偶猫 hello retry'); - assert.equal(calls[0].options.ownerAuthProvenance, 'strict'); - assert.deepEqual(store.get(invocationId).successfulCatIds, ['opus', 'codex']); - assert.equal(store.get(invocationId).status, 'succeeded'); - await app.close(); - }); - - it('retry failed → 202 + record transitions running→succeeded', async () => { - const { app, invocationRecordStore, router, invocationId } = await setupRetryScenario(); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - assert.equal(res.statusCode, 202); - const body = res.json(); - assert.equal(body.status, 'retrying'); - assert.equal(body.invocationId, invocationId); - - // Wait for background execution to complete - await new Promise((r) => setTimeout(r, 100)); - - const record = invocationRecordStore.get(invocationId); - assert.equal(record.status, 'succeeded'); - assert.equal(router.routeOptions[0]?.humanDispositionInvocationOrigin, 'direct_owner'); - }); - - it('retry acquires its replacement through QueueProcessor joint ownership', async () => { - const tracker = new InvocationTracker(); - const acquireCalls = []; - const queueProcessor = { - acquireExternalExecution(threadId, catIds, userId, options) { - acquireCalls.push([threadId, catIds, userId, options]); - return tracker.startAll(threadId, catIds, userId, options.executionId); - }, - onInvocationComplete: async () => {}, - }; - const { app, invocationId } = await setupRetryScenario(undefined, tracker, queueProcessor); - - const res = await app.inject({ method: 'POST', url: `/api/invocations/${invocationId}/retry` }); - - assert.equal(res.statusCode, 202); - assert.deepEqual(acquireCalls, [ - ['thread-1', ['opus'], 'user-1', { mode: 'replacement', executionId: invocationId }], - ]); - }); - - it('retry claim loss preserves the queued pre-start reservation', async () => { - const tracker = new InvocationTracker(); - const queueProcessor = new QueueProcessor({ - invocationTracker: tracker, - log: { info() {}, warn() {}, error() {} }, - }); - const slotKey = JSON.stringify(['thread-1', 'opus']); - const queuedReservation = { - startedAt: Date.now(), - entryId: 'queued-entry', - userId: 'user-1', - }; - queueProcessor.processingSlots.set(slotKey, queuedReservation); - - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(undefined, tracker, queueProcessor); - const originalUpdate = invocationRecordStore.update.bind(invocationRecordStore); - invocationRecordStore.update = async (id, data) => { - if (data.expectedStatus) return null; - return originalUpdate(id, data); - }; - - const res = await app.inject({ method: 'POST', url: `/api/invocations/${invocationId}/retry` }); - - assert.equal(res.statusCode, 409); - assert.equal(res.json().code, 'INVOCATION_NOT_RETRYABLE'); - assert.equal( - queueProcessor.processingSlots.get(slotKey), - queuedReservation, - 'a retry that loses the durable claim must not retire unrelated queued ownership', - ); - assert.equal(tracker.has('thread-1', 'opus'), false); - await app.close(); - }); - - it('retry ownership refusal restores an originally failed durable claim', async () => { - const queueProcessor = { - acquireExternalExecution() { - return null; - }, - }; - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(undefined, undefined, queueProcessor); - - const res = await app.inject({ method: 'POST', url: `/api/invocations/${invocationId}/retry` }); - - assert.equal(res.statusCode, 409); - assert.equal(res.json().code, 'INVOCATION_OWNER_CHANGED'); - const record = invocationRecordStore.get(invocationId); - assert.equal(record.status, 'failed'); - assert.equal(record.error, 'CLI timeout'); - await app.close(); - }); - - it('retry ownership refusal re-reads and retries a transient rollback miss', async () => { - const queueProcessor = { - acquireExternalExecution() { - return null; - }, - }; - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(undefined, undefined, queueProcessor); - const originalUpdate = invocationRecordStore.update.bind(invocationRecordStore); - let rollbackAttempts = 0; - invocationRecordStore.update = async (id, data) => { - if (data.expectedStatus === 'running' && data.status === 'failed') { - rollbackAttempts += 1; - if (rollbackAttempts === 1) return null; - } - return originalUpdate(id, data); - }; - - const res = await app.inject({ method: 'POST', url: `/api/invocations/${invocationId}/retry` }); - - assert.equal(res.statusCode, 409); - assert.equal(res.json().code, 'INVOCATION_OWNER_CHANGED'); - assert.equal(rollbackAttempts, 2, 'a still-running claim must be terminalized after a transient CAS miss'); - const record = invocationRecordStore.get(invocationId); - assert.equal(record.status, 'failed'); - assert.equal(record.error, 'CLI timeout'); - await app.close(); - }); - - it('F254 D1.2b: retry clears stale read evidence before reusing the retry id for handled closure', async () => { - const completionCalls = []; - const clearCalls = []; - const queueProcessor = { - hasQueuedNonAgentForThread: () => false, - getQueuedFreshnessMessagesForCat: () => [], - hasActiveOrQueuedAgentForCat: () => false, - hasPendingForCat: () => false, - enqueueRaw: () => ({ ok: true }), - clearQueuedSeenInvocationForCats: (...args) => { - clearCalls.push(args); - return 1; - }, - onInvocationComplete: async (...args) => { - completionCalls.push(args); - }, - }; - const router = createMockRouter(); - router.routeExecution = async function* () { - assert.equal( - clearCalls.length, - 1, - 'stale retry evidence must be cleared before provider execution can read queue', - ); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }; - - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(router, undefined, queueProcessor); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - assert.equal(res.statusCode, 202); - await new Promise((r) => setTimeout(r, 100)); - - assert.equal(completionCalls.length, 1); - assert.deepEqual(clearCalls, [['thread-1', ['opus'], invocationId]]); - assert.deepEqual( - completionCalls[0], - ['thread-1', 'opus', 'succeeded', invocationId, ['opus'], false, {}, [], {}], - 'retry clears stale seen evidence before running, then uses the retry record id for current-attempt reads', - ); - assert.deepEqual(invocationRecordStore.get(invocationId).successfulCatIds, ['opus']); - }); - - it('cloud-#7: retry passes signalForCat (startAll caller parity — per-cat cancel observable)', async () => { - // After the batchController split, controller.signal is the batch gate; a single-cat cancel - // aborts only that cat's slot controller. The retry path must pass signalForCat so the route - // observes per-cat signals (mirrors messages.ts / QueueProcessor), else a Stop on one cat of a - // retried multi-cat invocation is ignored until cancel-all. - let capturedOpts = null; - const router = createMockRouter(); - const origRoute = router.routeExecution; - router.routeExecution = async function* (...args) { - capturedOpts = args[6]; // 7th positional arg = route options - yield* origRoute(...args); - }; - const { app, invocationId } = await setupRetryScenario(router); - - const res = await app.inject({ method: 'POST', url: `/api/invocations/${invocationId}/retry` }); - assert.equal(res.statusCode, 202); - await new Promise((r) => setTimeout(r, 100)); - - assert.ok(capturedOpts, 'routeExecution was invoked'); - assert.equal(typeof capturedOpts.signalForCat, 'function', 'retry path passes signalForCat (cloud #7)'); - }); - - it('passes an authoritative A2A slot claim and durable deferral into retry execution', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - let observedClaim; - const router = createMockRouter(); - router.routeExecution = async function* (_userId, _msg, threadId, _userMsgId, _cats, _intent, options) { - tracker.startAll(threadId, ['codex'], 'external-owner', 'external-codex'); - observedClaim = options.trackA2ASlot(threadId, 'codex', 'user-1', options.invocationController); - const enqueue = options.deferA2AEnqueue({ - threadId, - userId: 'user-1', - content: '@codex review complete', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - callerCatId: 'opus', - messageId: 'msg-retry-handoff', - a2aTriggerMessageId: 'msg-retry-handoff', - autoExecute: true, - priority: 'normal', - intent: 'execute', - }); - assert.equal(enqueue.outcome, 'enqueued'); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }; - const queueProcessor = { - enqueueRaw: (entry) => queue.enqueue(entry), - hasQueuedNonAgentForThread: () => false, - getQueuedFreshnessMessagesForCat: () => [], - hasActiveOrQueuedAgentForCat: () => false, - hasPendingForCat: () => false, - clearQueuedSeenInvocationForCats: () => {}, - markPromptMessagesSeen: async () => {}, - onInvocationComplete: async () => {}, - }; - const { app, invocationId } = await setupRetryScenario(router, tracker, queueProcessor); - - const res = await app.inject({ method: 'POST', url: `/api/invocations/${invocationId}/retry` }); - assert.equal(res.statusCode, 202); - await new Promise((r) => setTimeout(r, 100)); - - assert.equal(observedClaim, false, 'external Codex owner must reject inline retry A2A execution'); - const deferred = queue.list('thread-1', 'user-1'); - assert.equal(deferred.length, 1); - assert.deepEqual(deferred[0].targetCats, ['codex']); - assert.equal(deferred[0].messageId, 'msg-retry-handoff'); - assert.equal(deferred[0].ownerAuthProvenance, 'compatibility_fallback'); - }); - - it('cloud-#7: retry resolves canceled (not succeeded) when the target cat is cancelled mid-run', async () => { - // A single-cat cancel no longer aborts the batch gate, so the retry path must use the aggregate - // resolveFinalStatus (per-cat tombstones) — otherwise a user_cancel is wrongly marked succeeded. - const tracker = new InvocationTracker(); - const router = createMockRouter(); - router.routeExecution = async function* (_userId, _msg, threadId, _userMsgId, _cats, _intent, _opts) { - // simulate user clicking Stop on opus mid-run (single-cat cancel, NOT cancelAll) - tracker.cancel(threadId, 'opus', undefined, 'user_cancel'); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }; - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(router, tracker); - - const res = await app.inject({ method: 'POST', url: `/api/invocations/${invocationId}/retry` }); - assert.equal(res.statusCode, 202); - await new Promise((r) => setTimeout(r, 100)); - - const record = invocationRecordStore.get(invocationId); - assert.equal( - record.status, - 'canceled', - 'single-cat cancel → canceled via resolveFinalStatus, not succeeded (cloud #7)', - ); - }); - - it('retry queued → 202 + normal execution', async () => { - const invocationRecordStore = new InvocationRecordStore(); - const messageStore = new MessageStore(); - const socketManager = createMockSocketManager(); - const router = createMockRouter(); - const invocationTracker = new InvocationTracker(); - - const storedMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: '@布偶猫 queued msg', - mentions: ['opus'], - timestamp: Date.now(), - threadId: 'thread-q', - }); - - const createResult = invocationRecordStore.create({ - threadId: 'thread-q', - userId: 'user-1', - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: 'key-q', - actionLeaseCarrier: { kind: 'none' }, - }); - // Backfill userMessageId, status stays 'queued' - invocationRecordStore.update(createResult.invocationId, { - userMessageId: storedMsg.id, - }); - - const app = Fastify(); - await app.register(invocationsRoutes, { - invocationRecordStore, - messageStore, - socketManager, - router, - invocationTracker, - }); - await app.ready(); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${createResult.invocationId}/retry`, - }); - assert.equal(res.statusCode, 202); - - await new Promise((r) => setTimeout(r, 100)); - const record = invocationRecordStore.get(createResult.invocationId); - assert.equal(record.status, 'succeeded'); - }); - - it('concurrent retry on same invocation should accept only one request', async () => { - const invocationRecordStore = new InvocationRecordStore(); - const messageStore = new MessageStore(); - const socketManager = createMockSocketManager(); - const invocationTracker = new InvocationTracker(); - - // Slow router keeps background execution in-flight to expose retry race. - const slowRouter = { - routeExecution: async function* () { - await new Promise((r) => setTimeout(r, 150)); - yield { type: 'text', catId: 'opus', content: 'slow retry', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute', explicit: false, promptTags: [] }, - }), - ackCollectedCursors: async () => {}, - }; - - const storedMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: '@布偶猫 retry race', - mentions: ['opus'], - timestamp: Date.now(), - threadId: 'thread-race', - }); - - const createResult = invocationRecordStore.create({ - threadId: 'thread-race', - userId: 'user-1', - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: 'key-race', - actionLeaseCarrier: { kind: 'none' }, - }); - invocationRecordStore.update(createResult.invocationId, { - userMessageId: storedMsg.id, - status: 'running', - }); - invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - error: 'previous failure', - }); - - const app = Fastify(); - await app.register(invocationsRoutes, { - invocationRecordStore, - messageStore, - socketManager, - router: slowRouter, - invocationTracker, - }); - await app.ready(); - - const [r1, r2] = await Promise.all([ - app.inject({ method: 'POST', url: `/api/invocations/${createResult.invocationId}/retry` }), - app.inject({ method: 'POST', url: `/api/invocations/${createResult.invocationId}/retry` }), - ]); - - const statuses = [r1.statusCode, r2.statusCode].sort((a, b) => a - b); - assert.deepEqual(statuses, [202, 409]); - - const conflict = r1.statusCode === 409 ? r1 : r2; - const conflictBody = conflict.json(); - assert.equal(conflictBody.code, 'INVOCATION_NOT_RETRYABLE'); - - await app.close(); - }); - - it('retry running → 409 NOT_RETRYABLE', async () => { - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(); - - // Set status to running - invocationRecordStore.update(invocationId, { status: 'running' }); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - assert.equal(res.statusCode, 409); - const body = res.json(); - assert.equal(body.code, 'INVOCATION_NOT_RETRYABLE'); - assert.equal(body.currentStatus, 'running'); - }); - - it('retry succeeded → 409 NOT_RETRYABLE', async () => { - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(); - - // failed → running → succeeded (proper lifecycle) - invocationRecordStore.update(invocationId, { status: 'running' }); - invocationRecordStore.update(invocationId, { status: 'succeeded' }); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - assert.equal(res.statusCode, 409); - const body = res.json(); - assert.equal(body.code, 'INVOCATION_NOT_RETRYABLE'); - }); - - it('retry with userMessageId=null → 400 USER_MESSAGE_NOT_SAVED', async () => { - const invocationRecordStore = new InvocationRecordStore(); - const messageStore = new MessageStore(); - const socketManager = createMockSocketManager(); - const router = createMockRouter(); - const invocationTracker = new InvocationTracker(); - - // Create record but do NOT backfill userMessageId - const createResult = invocationRecordStore.create({ - threadId: 'thread-null', - userId: 'user-1', - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: 'key-null', - actionLeaseCarrier: { kind: 'none' }, - }); - // Transition through proper lifecycle without backfilling userMessageId - invocationRecordStore.update(createResult.invocationId, { status: 'running' }); - invocationRecordStore.update(createResult.invocationId, { status: 'failed' }); - - const app = Fastify(); - await app.register(invocationsRoutes, { - invocationRecordStore, - messageStore, - socketManager, - router, - invocationTracker, - }); - await app.ready(); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${createResult.invocationId}/retry`, - }); - - assert.equal(res.statusCode, 400); - const body = res.json(); - assert.equal(body.code, 'USER_MESSAGE_NOT_SAVED'); - }); - - it('post-success ackCollectedCursors failure → record status=failed (not succeeded)', async () => { - // P1 regression: if ackCollectedCursors throws after succeeded write, - // the state machine guard blocks succeeded→failed, leaving record as succeeded. - const ackFailRouter = { - routeExecution: async function* (_u, _m, _t, _mid, _cats, _intent, _opts) { - yield { type: 'text', catId: 'opus', content: 'ok', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute', explicit: false, promptTags: [] }, - }), - ackCollectedCursors: async () => { - throw new Error('ack cursor failure'); - }, - }; - - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(ackFailRouter); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - assert.equal(res.statusCode, 202); - - // Wait for background execution - await new Promise((r) => setTimeout(r, 150)); - - const record = invocationRecordStore.get(invocationId); - // Must be 'failed', not 'succeeded' — the ack step threw - assert.equal(record.status, 'failed', 'record should be failed when ackCollectedCursors throws'); - assert.match(record.error, /ack cursor failure/); - }); - - it('pre-start failure: queued→failed when running update throws', async () => { - // P2 regression: if update(status: 'running') throws inside try block, - // the catch tries queued→failed which the state machine must allow. - const invocationRecordStore = new InvocationRecordStore(); - const createResult = invocationRecordStore.create({ - threadId: 'thread-prestart', - userId: 'user-1', - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: 'key-prestart', - actionLeaseCarrier: { kind: 'none' }, - }); - - // Directly test: queued→failed should succeed (pre-start failure path) - const updated = invocationRecordStore.update(createResult.invocationId, { - status: 'failed', - error: 'Redis connection refused before execution started', - }); - assert.ok(updated, 'queued→failed should be allowed for pre-start failures'); - assert.equal(updated.status, 'failed'); - assert.match(updated.error, /Redis connection refused/); - }); - - it('retry nonexistent id → 404', async () => { - const { app } = await setupRetryScenario(); - - const res = await app.inject({ - method: 'POST', - url: '/api/invocations/nonexistent-id/retry', - }); - - assert.equal(res.statusCode, 404); - const body = res.json(); - assert.equal(body.code, 'INVOCATION_NOT_FOUND'); - }); - - it('retry during thread delete → 409 THREAD_DELETING', async () => { - const tracker = new InvocationTracker(); - const { app, invocationId } = await setupRetryScenario(undefined, tracker); - - // Now set thread to deleting - const guard = tracker.guardDelete('thread-1'); - assert.ok(guard.acquired); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - assert.equal(res.statusCode, 409); - const body = res.json(); - assert.equal(body.code, 'THREAD_DELETING'); - - guard.release(); - }); - - it('retry execution failure → record status=failed with error', async () => { - const errorRouter = createMockRouter({ shouldThrow: true }); - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(errorRouter); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - assert.equal(res.statusCode, 202); - - // Wait for background execution to fail - await new Promise((r) => setTimeout(r, 100)); - - const record = invocationRecordStore.get(invocationId); - assert.equal(record.status, 'failed'); - assert.equal(record.error, 'Agent execution failed'); - }); - - it('retry success should clear previous error', async () => { - const { app, invocationRecordStore, invocationId } = await setupRetryScenario(); - - const res = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - - assert.equal(res.statusCode, 202); - - await new Promise((r) => setTimeout(r, 100)); - - const record = invocationRecordStore.get(invocationId); - assert.equal(record.status, 'succeeded'); - assert.equal(record.error ?? '', ''); - - await app.close(); - }); -}); - -describe('MessageStore.getById()', () => { - it('returns message when found', async () => { - const store = new MessageStore(); - const msg = store.append({ - userId: 'user-1', - catId: null, - content: 'test message', - mentions: [], - timestamp: Date.now(), - }); - - const found = store.getById(msg.id); - assert.ok(found); - assert.equal(found.id, msg.id); - assert.equal(found.content, 'test message'); - }); - - it('returns null when not found', async () => { - const store = new MessageStore(); - const found = store.getById('nonexistent-id'); - assert.equal(found, null); - }); -}); diff --git a/packages/api/test/invoke-single-cat-turn-execution.test.js b/packages/api/test/invoke-single-cat-turn-execution.test.js index 9380d4984f..d212940c97 100644 --- a/packages/api/test/invoke-single-cat-turn-execution.test.js +++ b/packages/api/test/invoke-single-cat-turn-execution.test.js @@ -70,12 +70,14 @@ describe('invokeSingleCat durable child execution lifecycle', () => { test('creates running before prompt exposure/provider and terminalizes success', async () => { const store = new InMemoryTurnExecutionStore(); const exposureCalls = []; + const lifecycleCalls = []; let providerObservedStatus; let providerRecoveryAnchor; const service = { async *invoke(_prompt, options) { providerObservedStatus = (await store.get('child-success'))?.status; providerRecoveryAnchor = options.recoveryAnchor; + assert.equal(lifecycleCalls.length, 1, 'processing response must exist before provider starts'); assert.equal(exposureCalls.length, 1, 'body exposure must be durable before provider starts'); yield { type: 'text', catId: 'codex', content: 'ok', timestamp: Date.now() }; yield { type: 'done', catId: 'codex', timestamp: Date.now() }; @@ -96,14 +98,26 @@ describe('invokeSingleCat durable child execution lifecycle', () => { freshnessSupplementId: 'supplement-1', }, promptMessageIds: ['msg-queued'], - onPromptMessagesExposed: async (input) => { - exposureCalls.push(input); + onLifecycleInvocationStarted: async (input) => { + lifecycleCalls.push(input); + return { responseMessageId: 'response-child-success', priorFrontierMessageId: 'message-before-response' }; }, + onPromptMessagesExposed: async (input) => exposureCalls.push(input), isLastCat: true, }), ); assert.equal(providerObservedStatus, 'running'); + assert.deepEqual(lifecycleCalls, [ + { + threadId: 'thread-1', + userId: 'user-1', + catId: 'codex', + invocationId: 'child-success', + parentInvocationId: 'parent-1', + startedAt: lifecycleCalls[0].startedAt, + }, + ]); assert.deepEqual(providerRecoveryAnchor, { threadId: 'thread-1', invocationId: 'child-success', @@ -127,6 +141,7 @@ describe('invokeSingleCat durable child execution lifecycle', () => { freshnessCarrierCapability: { provider: 'other', carrier: 'other', + activeInvocationGuidance: 'undeclared', deliverySemantics: 'undeclared', }, effectiveStrategy: { @@ -153,6 +168,8 @@ describe('invokeSingleCat durable child execution lifecycle', () => { }); assert.match(createdBody.effectiveStrategy.revision, /^provider_default:[a-f0-9]{64}$/); assert.equal(created.turnInvocationId, 'child-success'); + assert.equal(created.lifecycleResponseMessageId, 'response-child-success'); + assert.equal(created.lifecyclePriorFrontierMessageId, 'message-before-response'); assert.equal(created.turnExecutionStartedAt, createdBody.startedAt); assert.deepEqual(created.extra?.turnExecution, { invocationId: 'child-success', diff --git a/packages/api/test/issue1371-cursor-interruption.test.js b/packages/api/test/issue1371-cursor-interruption.test.js index 16adc088c2..605aeddad6 100644 --- a/packages/api/test/issue1371-cursor-interruption.test.js +++ b/packages/api/test/issue1371-cursor-interruption.test.js @@ -29,7 +29,7 @@ for (const [name, route] of [ assert.equal((await deps.messageStore.getByThread('thread-cursor')).filter((m) => m.catId === 'opus').length, 0); }); - for (const failure of ['append', 'error-coded done', 'commit rejected']) { + for (const failure of ['output commit', 'error-coded done', 'commit rejected']) { test(`#1371: ${name} ${failure} cannot advance a success cursor or leave a proof`, async () => { const { deps, source, options } = await cursorHarness({ opus: { @@ -45,11 +45,9 @@ for (const [name, route] of [ }, }, }); - if (failure === 'append') { - const append = deps.messageStore.append.bind(deps.messageStore); - deps.messageStore.append = (input) => { - if (input.catId === 'opus') throw new Error('injected output persistence failure'); - return append(input); + if (failure === 'output commit') { + deps.freshnessOutputCommitCoordinator.commit = () => { + throw new Error('injected output persistence failure'); }; } if (failure === 'commit rejected') options.beforeOutputCommit = async () => false; diff --git a/packages/api/test/issue1371-direct-witness.test.js b/packages/api/test/issue1371-direct-witness.test.js deleted file mode 100644 index 0d2f2e6a66..0000000000 --- a/packages/api/test/issue1371-direct-witness.test.js +++ /dev/null @@ -1,13 +0,0 @@ -import { test } from 'node:test'; -import { runDirectWitnessScenario } from './helpers/issue1371-direct-witness-harness.js'; - -const cases = ['message', 'retry', 'connector', 'standalone A2A', 'podcast'].flatMap((ingress) => - ['user-1', 'scheduler'].map((messageUserId) => ({ ingress, messageUserId, proof: 'exact' })), -); -for (const proof of ['missing', 'wrong child', 'wrong source', 'wrong task']) { - cases.push({ ingress: 'message', messageUserId: 'scheduler', proof }); -} -for (const { ingress, messageUserId, proof } of cases) { - test(`#1371: ${ingress} adopted custody and automatic review (${messageUserId}, ${proof})`, (t) => - runDirectWitnessScenario(t, { ingress, messageUserId, proof })); -} diff --git a/packages/api/test/issue1371-parallel-cursor.test.js b/packages/api/test/issue1371-parallel-cursor.test.js index 2df58374ed..fe9c43bd16 100644 --- a/packages/api/test/issue1371-parallel-cursor.test.js +++ b/packages/api/test/issue1371-parallel-cursor.test.js @@ -7,6 +7,7 @@ import { FreshnessOutputCommitCoordinator } from '../dist/domains/cats/services/ import { InMemoryTurnExecutionStore } from '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js'; import { DeliveryCursorStore } from '../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { adaptMessageStore } from './helpers/message-from-fixtures.js'; function deferred() { let resolve; @@ -17,7 +18,7 @@ function deferred() { } test('#1371: a completed parallel target commits its delivery boundary before its sibling finishes', async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const deliveryCursorStore = new DeliveryCursorStore(); const source = await messageStore.append({ userId: 'user-1', @@ -132,7 +133,7 @@ async function appendScenarioReply(messageStore, source, scenario) { for (const scenario of [ { name: 'unanswered', replied: false, expectedBaton: true }, { name: 'exact own reply', replied: true, expectedBaton: false }, - { name: 'explicit retry', replied: true, explicitRetry: true, expectedBaton: true }, + { name: 'explicit retry', replied: true, explicitRetry: true, expectedBaton: false }, { name: 'other cat reply', replied: true, catId: 'codex', expectedBaton: true }, { name: 'other tenant reply', replied: true, userId: 'user-2', expectedBaton: true }, { name: 'other thread reply', replied: true, threadId: 'thread-other', expectedBaton: true }, @@ -148,7 +149,7 @@ for (const scenario of [ { name: 'wrong child record source', replied: true, wrongRecordSource: true, expectedBaton: true }, ]) { test(`#1371: cold navigation distinguishes ${scenario.name}`, async () => { - const messageStore = new MessageStore(); + const messageStore = adaptMessageStore(new MessageStore()); const source = await messageStore.append({ userId: 'user-1', threadId: 'thread-restart', @@ -193,7 +194,14 @@ for (const scenario of [ assert.equal( context.navigationHeader?.includes('old source question') ?? false, scenario.expectedBaton, - 'only the exact answered source may leave navigation; explicit retry and independent sources remain reachable', + 'answered sources leave navigation, while the explicit current request is carried in the message delta itself', ); + if (scenario.explicitRetry) { + assert.equal( + context.includesCurrentUserMessage, + true, + 'explicit retry remains present in the exact message delta', + ); + } }); } diff --git a/packages/api/test/issue1371-prestart-recovery.test.js b/packages/api/test/issue1371-prestart-recovery.test.js index 6e88d65940..d95f390add 100644 --- a/packages/api/test/issue1371-prestart-recovery.test.js +++ b/packages/api/test/issue1371-prestart-recovery.test.js @@ -3,13 +3,13 @@ import { mock, test } from 'node:test'; import Fastify from 'fastify'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { + commitLifecycleResponseFromAppendInput, + MessageStore, +} from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { queueRoutes } from '../dist/routes/queue.js'; +import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; async function waitFor(predicate) { const until = Date.now() + 2_000; @@ -23,25 +23,51 @@ async function harness() { const queue = new InvocationQueue(); const tracker = new InvocationTracker(); const store = new MessageStore(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store }); let releaseCreate; const createGate = new Promise((resolve) => { releaseCreate = resolve; }); let created = 0; + const recordMap = new Map(); const records = { - create: mock.fn(async () => { + create: mock.fn(async (input) => { const invocationId = `inv-${++created}`; if (created === 1) await createGate; + const record = { + id: invocationId, + ...input, + userMessageId: null, + status: 'queued', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + recordMap.set(invocationId, record); return { outcome: 'created', invocationId }; }), - update: mock.fn(async () => {}), + get: async (id) => recordMap.get(id) ?? null, + update: async (id, patch) => { + const current = recordMap.get(id); + if (!current) return null; + if (patch.expectedStatus && current.status !== patch.expectedStatus) return null; + const { expectedStatus: _expectedStatus, ...changes } = patch; + const next = { ...current, ...changes, updatedAt: Date.now() }; + recordMap.set(id, next); + return next; + }, listRunningByThread: async () => [], }; const socketManager = { emitToUser: mock.fn(), broadcastToRoom() {}, broadcastAgentMessage() {} }; const routeExecution = mock.fn(async function* (userId, _content, threadId, messageId, targets, _intent, options) { for (const catId of targets) { const childId = `child-${created}-${catId}`; + const lifecycleAdmission = await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId, + invocationId: childId, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); await options.onPromptMessagesExposed({ threadId, userId, @@ -50,61 +76,74 @@ async function harness() { messageIds: [messageId], seenAt: Date.now(), }); - store.append({ - userId, - threadId, - catId, - content: 'handled exact source', - mentions: [], - timestamp: Date.now(), - replyTo: messageId, - extra: { - causal: { kind: 'invocation_reply', triggerMessageId: messageId }, - stream: { invocationId: `inv-${created}`, turnInvocationId: childId }, + await commitLifecycleResponseFromAppendInput( + store, + lifecycleAdmission.responseMessageId, + childId, + { status: 'completed', completedAt: Date.now() }, + { + from: { kind: 'agent', catId }, + userId, + threadId, + content: 'handled exact source', + mentions: [], + timestamp: Date.now(), + replyTo: messageId, + extra: { + causal: { kind: 'invocation_reply', triggerMessageId: messageId }, + stream: { invocationId: `inv-${created}`, turnInvocationId: childId }, + }, }, - }); - yield { type: 'done', catId, invocationId: childId, timestamp: Date.now() }; + ); + yield { type: 'done', catId, invocationId: childId, isFinal: true, timestamp: Date.now() }; } }); const processor = new QueueProcessor({ queue, invocationTracker: tracker, messageStore: store, - queueCustodyCoordinator: coordinator, invocationRecordStore: records, socketManager, - router: { routeExecution, ackCollectedCursors: async () => {} }, + router: { + resolveConversationTargetsAtAdmission: async (targets) => [...targets], + resolveExplicitTargets: async (targets) => [...targets], + routeExecution, + ackCollectedCursors: async () => {}, + }, log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, }); - const source = store.append({ - userId: 'user-1', + const queueInput = canonicalTestQueueInput({ threadId: 'thread-1', - catId: null, - mentions: ['opus', 'codex'], + userId: 'user-1', + kind: 'conversation_input', + sourceId: 'issue1371-prestart-source', content: 'independent targets', - timestamp: Date.now(), - deliveryStatus: 'queued', - }); - const { entry } = queue.enqueue({ - threadId: source.threadId, - userId: source.userId, - messageId: source.id, - content: source.content, - source: 'user', - ownerAuthProvenance: 'strict', targetCats: ['opus', 'codex'], intent: 'execute', }); - store.initializeQueueCustody(source.id, createInitialQueuedMessageCustody(entry)); - await coordinator.persistEntry(entry); + const admitted = await queue.appendAndEnqueueDurable( + store, + canonicalTestMessageInput({ + threadId: queueInput.threadId, + userId: queueInput.userId, + from: queueInput.from, + catId: null, + mentions: queueInput.targetCats, + content: queueInput.content, + timestamp: Date.now(), + deliveryStatus: 'queued', + }), + queueInput, + ); + const source = admitted.message; + const entry = admitted.entry; const app = Fastify(); await app.register(queueRoutes, { - threadStore: { get: async () => ({ createdBy: 'user-1' }) }, + threadStore: { get: async () => ({ id: 'thread-1', createdBy: 'user-1' }) }, invocationQueue: queue, invocationTracker: tracker, queueProcessor: processor, messageStore: store, - queueCustodyCoordinator: coordinator, invocationRecordStore: records, socketManager, }); @@ -112,7 +151,6 @@ async function harness() { queue, tracker, store, - coordinator, processor, records, source, @@ -120,42 +158,80 @@ async function harness() { routeExecution, releaseCreate, app, - socketManager, }; } -for (const entryPoint of ['direct', 'manual', 'automatic']) { - test(`#1371: ${entryPoint} exact Steer consumes only its target and preserves the busy sibling`, async (t) => { +test('#1371: explicit Steer consumes one target and preserves the busy sibling', async (t) => { + const h = await harness(); + t.after(() => h.app.close()); + const busy = h.tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'independent-work'); + + const claimed = await h.queue.claimExactSteerEntryDurable('thread-1', 'user-1', h.entry.id, 'codex'); + assert.equal(claimed.outcome, 'claimed'); + const started = await h.processor.processClaimedSteerEntries( + 'thread-1', + 'user-1', + claimed.entries.map((entry) => entry.id), + 'codex', + ); + assert.equal(started.started, true); + + await waitFor(() => h.records.create.mock.calls.length === 1); + h.releaseCreate(); + await waitFor( + () => h.store.getById(h.source.id).lifecycle?.dispatchRefs?.some((ref) => ref.targetId === 'codex') === true, + ); + assert.equal(busy.signal.aborted, false); + assert.deepEqual( + h.routeExecution.mock.calls.map((call) => call.arguments[4]), + [['codex']], + ); + assert.deepEqual(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id).targets, ['opus']); + + h.tracker.completeAll('thread-1', ['opus'], busy); + await h.processor.processNext('thread-1', 'user-1'); + await waitFor(() => h.queue.list('thread-1', 'user-1').length === 0); + assert.deepEqual( + h.routeExecution.mock.calls.map((call) => call.arguments[4]), + [['codex'], ['opus']], + ); +}); + +for (const entryPoint of ['manual', 'automatic']) { + test(`#1371: ${entryPoint} drain keeps the exact target set queued while one sibling is busy`, async (t) => { const h = await harness(); t.after(() => h.app.close()); const busy = h.tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'independent-work'); - const reservation = h.queue.reserveExactUserEntry('thread-1', 'user-1', h.entry.id, 'codex'); - assert.equal(reservation.outcome, 'reserved'); - h.queue.beginExactSteerPreemption('thread-1', 'user-1', reservation.reservationId); - h.queue.activateExactSteerReservation('thread-1', 'user-1', reservation.reservationId); - if (entryPoint === 'direct') - await h.processor.processExactSteerReservation('thread-1', 'user-1', h.entry.id, reservation.reservationId); - else if (entryPoint === 'manual') await h.processor.processNext('thread-1', 'user-1'); - else await h.processor.onInvocationComplete('thread-1', 'codex', 'succeeded', 'previous-work', []); - h.releaseCreate(); - await waitFor(() => h.store.getById(h.source.id).queueCustody.handledByCatIds.includes('codex')); - assert.equal(busy.signal.aborted, false); - assert.deepEqual( - h.routeExecution.mock.calls.map((call) => call.arguments[4]), - [['codex']], - ); - assert.deepEqual(h.store.getById(h.source.id).queueCustody.pendingTargetCats, ['opus']); - const remaining = h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id); - assert.equal(remaining.status, 'queued'); - assert.deepEqual(remaining.targetCats, ['opus']); + + if (entryPoint === 'manual') { + await h.processor.processNext('thread-1', 'user-1'); + } else { + await h.processor.requestDrain('thread-1'); + } + + assert.equal(h.records.create.mock.calls.length, 0); + assert.equal(h.routeExecution.mock.calls.length, 0); + assert.deepEqual(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id).targets, ['opus', 'codex']); + h.tracker.completeAll('thread-1', ['opus'], busy); await h.processor.processNext('thread-1', 'user-1'); + await waitFor(() => h.records.create.mock.calls.length === 1); + h.releaseCreate(); await waitFor(() => h.queue.list('thread-1', 'user-1').length === 0); assert.deepEqual( h.routeExecution.mock.calls.map((call) => call.arguments[4]), - [['codex'], ['opus']], + [['opus', 'codex']], + ); + assert.deepEqual( + h.store + .getById(h.source.id) + .lifecycle.dispatchRefs.map((ref) => [ref.targetId, ref.phase]) + .sort(), + [ + ['codex', 'settled'], + ['opus', 'settled'], + ], ); - assert.equal(h.store.getById(h.source.id).queueCustody.status, 'terminal'); }); } @@ -164,15 +240,21 @@ test('#1371: a sibling becoming busy during ordinary preflight requeues without t.after(() => h.app.close()); await h.processor.processNext('thread-1', 'user-1'); await waitFor(() => h.records.create.mock.calls.length === 1); + const busy = h.tracker.start('thread-1', 'codex', 'user-1', ['codex'], 'independent-work'); h.releaseCreate(); - await waitFor(() => !h.processor.hasActiveExecutionForCat('thread-1', 'opus')); + await waitFor(() => h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id)?.status === 'queued'); + const remaining = h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id); assert.equal(remaining.status, 'queued'); assert.deepEqual(remaining.queuedFailedByCatIds ?? [], []); - assert.deepEqual(h.store.getById(h.source.id).queueCustody.pendingTargetCats, ['opus', 'codex']); + assert.deepEqual(remaining.targets, ['opus', 'codex']); + assert.deepEqual(h.store.getById(h.source.id).lifecycle?.dispatchRefs ?? [], []); assert.equal(h.routeExecution.mock.calls.length, 0); assert.equal(busy.signal.aborted, false); + assert.equal((await h.records.get('inv-1')).status, 'canceled'); + assert.equal((await h.records.get('inv-1')).error, 'queue_target_became_busy'); + h.tracker.completeAll('thread-1', ['codex'], busy); await h.processor.processNext('thread-1', 'user-1'); await waitFor(() => h.queue.list('thread-1', 'user-1').length === 0); @@ -182,39 +264,32 @@ test('#1371: a sibling becoming busy during ordinary preflight requeues without ); }); -test('#1371: force-reset retires an orphan processing row with no slot, tracker or running record', async (t) => { +test('#1371: force-reset retires a recordless pre-start target without running the provider', async (t) => { const h = await harness(); t.after(() => h.app.close()); - h.queue.markProcessingById('thread-1', h.entry.id); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id)); - for (let attempt = 0; attempt < 2; attempt++) { - const reset = await h.app.inject({ - method: 'POST', - url: '/api/threads/thread-1/force-reset', - headers: { 'x-cat-cafe-user': 'user-1' }, - }); - assert.equal(reset.statusCode, 200, reset.body); - const published = await h.app.inject({ - method: 'GET', - url: '/api/threads/thread-1/queue', - headers: { 'x-cat-cafe-user': 'user-1' }, - }); - assert.deepEqual(published.json().queue, []); - assert.equal(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id), null); - assert.equal(h.store.getById(h.source.id).deliveryStatus, 'canceled'); - assert.equal(h.routeExecution.mock.calls.length, 0); - } + await h.processor.processNext('thread-1', 'user-1'); + await waitFor(() => h.records.create.mock.calls.length === 1); + + const reset = await h.app.inject({ + method: 'POST', + url: '/api/threads/thread-1/force-reset', + headers: { 'x-cat-cafe-user': 'user-1' }, + }); + h.releaseCreate(); + assert.equal(reset.statusCode, 200, reset.body); + assert.equal(h.routeExecution.mock.calls.length, 0); + assert.deepEqual(h.queue.list('thread-1', 'user-1'), []); + assert.equal(h.store.getById(h.source.id).deliveryStatus, 'canceled'); + assert.deepEqual(h.store.getById(h.source.id).lifecycle.dispatchRefs, []); }); -test('#1371: orphan reset fails visibly on durable write failure and succeeds on exact retry', async (t) => { +test('#1371: pre-start terminal write failure is visible and the exact retry succeeds', async (t) => { const h = await harness(); t.after(() => h.app.close()); - h.queue.markProcessingById('thread-1', h.entry.id); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id)); - const persist = h.coordinator.persistEntry.bind(h.coordinator); - h.coordinator.persistEntry = async () => { - throw new Error('durable retirement unavailable'); - }; + await h.processor.processNext('thread-1', 'user-1'); + await waitFor(() => h.records.create.mock.calls.length === 1); + const removeProcessed = h.queue.removeProcessedAcrossUsersDurable.bind(h.queue); + h.queue.removeProcessedAcrossUsersDurable = async () => null; const request = { method: 'POST', url: '/api/threads/thread-1/force-reset', @@ -222,29 +297,33 @@ test('#1371: orphan reset fails visibly on durable write failure and succeeds on }; const failed = await h.app.inject(request); assert.equal(failed.statusCode, 503, failed.body); - assert.equal(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id).status, 'processing'); - assert.equal(h.store.getById(h.source.id).deliveryStatus, 'queued'); - h.coordinator.persistEntry = persist; + h.queue.removeProcessedAcrossUsersDurable = removeProcessed; const retried = await h.app.inject(request); + h.releaseCreate(); assert.equal(retried.statusCode, 200, retried.body); - assert.equal(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id), null); assert.equal(h.routeExecution.mock.calls.length, 0); + assert.deepEqual(h.queue.list('thread-1', 'user-1'), []); }); -test('#1371: orphan recovery never acquires another user live target', async (t) => { +test('#1371: live pre-start recovery never acquires another user live target', async (t) => { const h = await harness(); t.after(() => h.app.close()); - h.queue.markProcessingById('thread-1', h.entry.id); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id)); + await h.processor.processNext('thread-1', 'user-1'); + await waitFor(() => h.records.create.mock.calls.length === 1); const foreign = h.tracker.start('thread-1', 'codex', 'other-user', ['codex'], 'foreign-work'); + const reset = await h.app.inject({ method: 'POST', url: '/api/threads/thread-1/force-reset', headers: { 'x-cat-cafe-user': 'user-1' }, }); + assert.equal(reset.statusCode, 409, reset.body); assert.equal(foreign.signal.aborted, false); assert.equal(h.tracker.getUserId('thread-1', 'codex'), 'other-user'); assert.equal(h.store.getById(h.source.id).deliveryStatus, 'queued'); - assert.equal(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id).status, 'processing'); + assert.equal(h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id).status, 'claimed'); + + h.releaseCreate(); + await waitFor(() => h.queue.getEntrySnapshot('thread-1', 'user-1', h.entry.id)?.status === 'queued'); }); diff --git a/packages/api/test/issue1371-queue-source-boundaries.test.js b/packages/api/test/issue1371-queue-source-boundaries.test.js deleted file mode 100644 index 5d2a4887e2..0000000000 --- a/packages/api/test/issue1371-queue-source-boundaries.test.js +++ /dev/null @@ -1,247 +0,0 @@ -import assert from 'node:assert/strict'; -import { mock, test } from 'node:test'; -import Fastify from 'fastify'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - projectUnconsumedQueueCarrier, - readQueueCarrierMessages, -} from '../dist/domains/cats/services/agents/invocation/QueueCarrierSourceProjection.js'; -import { QueuedMessageCustodyCoordinator } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { queueRoutes } from '../dist/routes/queue.js'; -import { emitQueueUpdated, enrichQueueEntries } from '../dist/utils/queue-enrichment.js'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; - -function appendSource(store, queue, { state = 'handled', crossThread = false, targets = ['opus'] } = {}) { - const message = store.append({ - userId: 'user-1', - catId: null, - content: `work-${state}`, - mentions: targets, - timestamp: Date.now(), - threadId: 'thread-1', - deliveryStatus: 'queued', - }); - const { entry } = queue.enqueue({ - userId: 'user-1', - threadId: 'thread-1', - ownerAuthProvenance: 'unknown', - content: message.content, - messageId: message.id, - source: 'user', - targetCats: targets, - intent: 'execute', - }); - const isClosed = state === 'handled' || state === 'withdrawn'; - store.initializeQueueCustody( - message.id, - makeQueuedMessageCustody({ - entryId: crossThread ? `cross-thread:${message.id}` : entry.id, - ...(crossThread - ? { - receiptScope: 'cross_thread_delivery', - carrierByTargetCatId: Object.fromEntries( - targets.map((catId) => [ - catId, - { - entryId: entry.id, - source: 'agent', - sourceCategory: 'a2a', - a2aTriggerMessageId: message.id, - autoExecute: true, - createdAt: entry.createdAt, - }, - ]), - ), - } - : {}), - intent: 'execute', - status: isClosed || state === 'failed' ? 'terminal' : 'queued', - allTargetCats: targets, - pendingTargetCats: isClosed || state === 'failed' ? [] : targets, - handledByCatIds: state === 'handled' ? targets : [], - withdrawnByCatIds: state === 'withdrawn' ? targets : [], - ...(state === 'withdrawn' - ? { withdrawnAtByCatId: Object.fromEntries(targets.map((catId) => [catId, Date.now()])) } - : {}), - failedByCatIds: state === 'failed' ? targets : [], - }), - ); - return { message: store.getById(message.id), entry }; -} - -for (const crossThread of [false, true]) { - test(`#1371: publication removes consumed sources and retains failed targets (crossThread=${crossThread})`, async () => { - const store = new MessageStore(); - const queue = new InvocationQueue(); - const handled = appendSource(store, queue, { crossThread }); - appendSource(store, queue, { crossThread, state: 'withdrawn' }); - const failed = appendSource(store, queue, { crossThread, state: 'failed' }); - const projected = await enrichQueueEntries(queue.list('thread-1', 'user-1'), store); - assert.deepEqual( - projected.map((entry) => entry.id), - [failed.entry.id], - ); - const emitter = { emitToUser: mock.fn() }; - await emitQueueUpdated(emitter, 'user-1', 'thread-1', queue.list('thread-1', 'user-1'), store, 'test'); - assert.deepEqual( - emitter.emitToUser.mock.calls[0].arguments[2].queue.map((entry) => entry.id), - [failed.entry.id], - ); - assert.equal(store.getById(handled.message.id).queueCustody.status, 'terminal'); - }); -} - -test('#1371: merged carrier drops only handled body and keeps the independent source', async () => { - const store = new MessageStore(); - const queue = new InvocationQueue(); - const first = appendSource(store, queue); - const second = store.append({ - userId: 'user-1', - catId: null, - content: 'still needed', - mentions: ['opus'], - timestamp: Date.now(), - threadId: 'thread-1', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: first.entry.id, - intent: 'execute', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - }), - }); - const merged = { ...first.entry, mergedMessageIds: [second.id], content: 'old body\nstill needed' }; - const projected = projectUnconsumedQueueCarrier(merged, await readQueueCarrierMessages(merged, store)); - assert.equal(projected.messageId, second.id); - assert.deepEqual(projected.mergedMessageIds, []); - assert.equal(projected.content, 'still needed'); - assert.deepEqual(projected.targetCats, ['opus']); -}); - -for (const state of ['pending', 'handled']) { - test(`#1371: owner-bound scheduler custody participates in Queue projection (${state})`, async () => { - const store = new MessageStore(); - const queue = new InvocationQueue(); - const { entry, message } = appendSource(store, queue, { state }); - message.userId = 'scheduler'; - message.source = { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'managed-task', threadId: 'thread-1', catId: 'opus', wakeWhen: true }, - }; - message.queueCustody.ownerUserId = 'user-1'; - assert.deepEqual(await readQueueCarrierMessages(entry, store), [message]); - const projected = await enrichQueueEntries([entry], store); - assert.equal(projected.length, state === 'pending' ? 1 : 0); - }); -} - -for (const boundary of ['foreign owner', 'missing owner', 'foreign thread', 'hidden trigger', 'forged author']) { - test(`#1371: scheduler provenance never bypasses ${boundary}`, async () => { - const store = new MessageStore(); - const queue = new InvocationQueue(); - const { entry, message } = appendSource(store, queue, { state: 'pending' }); - message.userId = 'scheduler'; - message.source = { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'managed-task', threadId: 'thread-1', catId: 'opus', wakeWhen: true }, - }; - message.queueCustody.ownerUserId = 'user-1'; - if (boundary === 'foreign owner') message.queueCustody.ownerUserId = 'user-2'; - if (boundary === 'missing owner') delete message.queueCustody.ownerUserId; - if (boundary === 'foreign thread') message.threadId = 'thread-2'; - if (boundary === 'hidden trigger') message.extra = { scheduler: { hiddenTrigger: true } }; - if (boundary === 'forged author') message.catId = 'opus'; - await assert.rejects(readQueueCarrierMessages(entry, store), /out of scope/); - }); -} - -test('#1371: partial target completion cannot hide an unhandled sibling', async () => { - const store = new MessageStore(); - const queue = new InvocationQueue(); - const { entry, message } = appendSource(store, queue, { targets: ['opus', 'codex-sol'], state: 'pending' }); - store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: { - ...message.queueCustody, - revision: 2, - pendingTargetCats: ['codex-sol'], - handledByCatIds: ['opus'], - updatedAt: Date.now(), - }, - }); - const projected = await enrichQueueEntries([entry], store); - assert.deepEqual(projected[0].targetCats, ['codex-sol']); - assert.equal(projected[0].content, message.content); -}); - -for (const scenario of ['single', 'batch', 'read outage', 'terminal during persistence']) { - test(`#1371: ${scenario} Steer cannot abort unrelated work without durable eligibility`, async () => { - const store = new MessageStore(); - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const first = appendSource(store, queue, { - state: scenario === 'single' || scenario === 'batch' ? 'handled' : 'pending', - }); - const second = appendSource(store, queue, { state: 'pending' }); - const active = tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'unrelated-child'); - const execute = mock.fn(async () => ({ status: 'succeeded' })); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store }); - if (scenario === 'read outage') { - store.getById = async () => { - throw new Error('read unavailable'); - }; - } - if (scenario === 'terminal during persistence') { - const persist = coordinator.persistEntry.bind(coordinator); - coordinator.persistEntry = async (entry) => { - const result = await persist(entry); - if (entry.id === first.entry.id) { - const current = store.getById(first.message.id).queueCustody; - store.transitionQueueCustody(first.message.id, { - expectedRevision: current.revision, - next: { - ...current, - revision: current.revision + 1, - status: 'terminal', - pendingTargetCats: [], - handledByCatIds: ['opus'], - updatedAt: Date.now(), - }, - }); - } - return result; - }; - } - const app = Fastify(); - try { - await app.register(queueRoutes, { - threadStore: { get: async () => ({ createdBy: 'user-1' }) }, - invocationQueue: queue, - invocationTracker: tracker, - queueProcessor: { executeEntry: execute, getPauseState: () => null }, - messageStore: store, - queueCustodyCoordinator: coordinator, - socketManager: { emitToUser() {}, broadcastToRoom() {}, broadcastAgentMessage() {} }, - }); - const response = await app.inject({ - method: 'POST', - url: - scenario === 'batch' - ? '/api/threads/thread-1/queue/steer-batch' - : `/api/threads/thread-1/queue/${first.entry.id}/steer`, - headers: { 'x-cat-cafe-user': 'user-1' }, - payload: scenario === 'batch' ? { entryIds: [first.entry.id, second.entry.id] } : {}, - }); - assert.equal(response.statusCode >= 400, true, response.body); - assert.equal(active.signal.aborted, false); - assert.equal(execute.mock.calls.length, 0); - assert.equal(queue.hasUnsettledExactSteerReservation('thread-1', 'user-1'), false); - } finally { - await app.close(); - } - }); -} diff --git a/packages/api/test/issue1371-redis-convergence.test.js b/packages/api/test/issue1371-redis-convergence.test.js index d04687e189..399afebda7 100644 --- a/packages/api/test/issue1371-redis-convergence.test.js +++ b/packages/api/test/issue1371-redis-convergence.test.js @@ -1,23 +1,15 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { test } from 'node:test'; -import { SessionStore } from '@cat-cafe/shared/utils'; import Fastify from 'fastify'; import Redis from 'ioredis'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { assembleIncrementalContext } from '../dist/domains/cats/services/agents/routing/route-helpers.js'; -import { routeParallel } from '../dist/domains/cats/services/agents/routing/route-parallel.js'; -import { DeliveryCursorStore } from '../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'; +import { RedisQueueLedgerStore } from '../dist/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.js'; import { RedisMessageStore } from '../dist/domains/cats/services/stores/redis/RedisMessageStore.js'; import { queueRoutes } from '../dist/routes/queue.js'; -import { cursorHarness, deferred } from './helpers/issue1371-cursor-harness.js'; -import { runDirectWitnessScenario } from './helpers/issue1371-direct-witness-harness.js'; +import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -37,279 +29,6 @@ async function connect(t) { return redis; } -test( - '#1371 Redis: adopted terminal receipt repairs after a new child re-reads the durable wake', - options, - async (t) => { - const { RedisBallCustodyEventLog } = await import('../dist/domains/ball-custody/BallCustodyEventLog.js'); - const { RedisBallCustodyProjectionStore } = await import( - '../dist/domains/ball-custody/BallCustodyProjectionStore.js' - ); - const { BallCustodyProjector } = await import('../dist/domains/ball-custody/BallCustodyProjector.js'); - const { BallCustodyIngest } = await import('../dist/domains/ball-custody/BallCustodyIngest.js'); - const { buildHeldEvent, buildHandedEvent, buildWakeConditionMetEvent } = await import( - '../dist/domains/ball-custody/ball-custody-events.js' - ); - const { ManagedHoldDispositionService } = await import( - '../dist/domains/ball-custody/ManagedHoldDispositionService.js' - ); - const { ManagedHoldReceiptService } = await import('../dist/domains/ball-custody/ManagedHoldReceiptService.js'); - const { turnCustodyAdoptionRegistry } = await import('../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js'); - const redis = await connect(t); - const messageStore = new RedisMessageStore(redis, { ttlSeconds: 0 }); - const eventLog = new RedisBallCustodyEventLog(redis); - const projectionStore = new RedisBallCustodyProjectionStore(redis); - const projector = new BallCustodyProjector(eventLog, projectionStore); - const ingest = new BallCustodyIngest(eventLog, projector); - const queue = new InvocationQueue(); - const threadId = 'thread-adopted-restart'; - const userId = 'user-1'; - const catId = 'codex'; - const taskId = 'task-adopted-restart'; - const entry = queue.enqueue({ - threadId, - userId, - ownerAuthProvenance: 'unknown', - content: '[定时任务] gate passed', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: [catId], - intent: 'execute', - }).entry; - const userSource = await messageStore.append({ - threadId, - userId, - catId: null, - content: 'Finish this work', - mentions: [], - timestamp: Date.now(), - }); - const source = await messageStore.append({ - threadId, - userId: 'scheduler', - catId: null, - content: entry.content, - mentions: [catId], - timestamp: Date.now(), - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - icon: 'timer', - meta: { threadId, catId, taskId, wakeWhen: true }, - }, - queueCustody: createInitialQueuedMessageCustody(entry), - }); - queue.backfillMessageId(threadId, userId, entry.id, source.id); - queue.markProcessing(threadId, userId); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - queue.markProcessingSeen(threadId, userId, entry.id, [catId], 'before-crash', Date.now()); - await coordinator.persistEntry(queue.getEntrySnapshot(threadId, userId, entry.id)); - await ingest.record(buildHeldEvent({ threadId, catId, fireAt: Date.now(), at: Date.now() })); - await ingest.record( - buildWakeConditionMetEvent({ - threadId, - catId, - taskId, - command: 'pnpm test', - exitCode: 0, - timedOut: false, - durationMs: 1, - at: Date.now(), - }), - ); - await ingest.record(buildHandedEvent({ threadId, toCatId: catId, messageId: source.id, at: Date.now() })); - const wake = { - kind: 'structured', - protocol: 'hold', - subjectKey: `ball:thread:${threadId}`, - holderCatId: catId, - sourceMessageId: source.id, - taskId, - }; - const auth = { invocationId: 'before-crash', userId, threadId, catId, originTriggerMessageId: userSource.id }; - let currentChild = auth.invocationId; - const registry = { isLatest: async (invocationId) => invocationId === currentChild }; - const before = new ManagedHoldDispositionService({ - registry, - dynamicTaskStore: { getById: () => null }, - messageStore, - ballCustodyEventLog: eventLog, - ballCustodyProjectionStore: projectionStore, - ballCustody: ingest, - receiptService: { - async complete() { - throw new Error('crash after terminal append'); - }, - }, - }); - const unregister = turnCustodyAdoptionRegistry.register(auth.invocationId, async () => {}); - await turnCustodyAdoptionRegistry.adopt(auth.invocationId, [wake]); - await assert.rejects(before.complete(auth, 'completed'), /crash after terminal append/); - await unregister(); - const rebuilt = new RedisMessageStore(redis, { ttlSeconds: 0 }); - const recoveredQueue = new InvocationQueue(); - recoveredQueue.restoreDurableEntry(queue.getEntrySnapshot(threadId, userId, entry.id)); - const recoveredCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: rebuilt }); - currentChild = 'after-restart'; - const nextAuth = { ...auth, invocationId: currentChild }; - const after = new ManagedHoldDispositionService({ - registry, - dynamicTaskStore: { getById: () => null }, - messageStore: rebuilt, - ballCustodyEventLog: new RedisBallCustodyEventLog(redis), - ballCustodyProjectionStore: new RedisBallCustodyProjectionStore(redis), - ballCustody: ingest, - receiptService: new ManagedHoldReceiptService({ - queue: recoveredQueue, - messageStore: rebuilt, - coordinator: recoveredCoordinator, - }), - }); - await assert.rejects(after.complete(auth, 'completed'), /stale_invocation/); - t.after(turnCustodyAdoptionRegistry.register(currentChild, async () => {})); - await turnCustodyAdoptionRegistry.adopt(currentChild, [wake]); - await assert.rejects(after.complete(nextAuth, 'completed'), /adopted_source_mismatch/); - recoveredQueue.markProcessingSeen(threadId, userId, entry.id, [catId], currentChild, Date.now()); - await recoveredCoordinator.persistEntry(recoveredQueue.getEntrySnapshot(threadId, userId, entry.id)); - const result = await after.complete(nextAuth, 'completed'); - assert.equal(result.outcome, 'replayed'); - assert.equal(result.sourceMessageId, source.id); - assert.equal( - (await eventLog.read(wake.subjectKey)).filter((event) => event.kind === 'ball.hold_dispositioned').length, - 1, - ); - assert.equal(recoveredQueue.list(threadId, userId).length, 0); - assert.deepEqual((await new RedisMessageStore(redis).getById(source.id)).queueCustody.handledByCatIds, [catId]); - assert.equal((await projectionStore.get(wake.subjectKey)).state, 'resolved'); - }, -); - -test( - '#1371 Redis: target append-to-ack crash repairs cold cursors before its failed sibling finishes', - options, - async (t) => { - const redis = await connect(t); - const blocked = deferred(); - const firstDone = deferred(); - let providerCalls = 0; - const { - deps, - source, - boundaries, - options: routeOptions, - } = await cursorHarness( - { - opus: { - supportsToolExecutionPolicy: () => true, - async *invoke() { - providerCalls++; - yield { type: 'text', catId: 'opus', content: 'persisted exact target answer', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - supportsToolExecutionPolicy: () => true, - async *invoke() { - providerCalls++; - await blocked.promise; - yield { type: 'error', catId: 'codex', error: 'sibling provider quota', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }, - new RedisMessageStore(redis, { ttlSeconds: 0 }), - ); - deps.deliveryCursorStore.ackCursor = async () => { - throw new Error('crash window: cursor not committed'); - }; - const execution = (async () => { - for await (const event of routeParallel( - deps, - ['opus', 'codex'], - source.content, - 'user-1', - 'thread-cursor', - routeOptions, - )) { - if (event.type === 'done' && event.catId === 'opus') firstDone.resolve(event.messageId); - } - })(); - try { - const replyId = await Promise.race([ - firstDone.promise, - execution.then(() => { - throw new Error('no completed target'); - }), - ]); - const reply = await deps.messageStore.getById(replyId); - assert.equal( - reply.extra.deliveryBoundary.cursor, - boundaries.get('opus'), - 'Redis parser retains the append proof', - ); - await Promise.all([ - deps.messageStore.updateExtra(replyId, { - deliveryBoundary: { ...reply.extra.deliveryBoundary, cursor: 'forged' }, - }), - deps.messageStore.augmentStreamMetadata(replyId, { - extra: { stream: { invocationId: 'child-1', turnInvocationId: 'child-1' } }, - }), - ]); - const rebuilt = new RedisMessageStore(redis, { ttlSeconds: 0 }); - assert.deepEqual((await rebuilt.getById(replyId)).extra.deliveryBoundary, reply.extra.deliveryBoundary); - const cursors = () => - new DeliveryCursorStore(new SessionStore(redis), (id, threadId) => rebuilt.canonicalizeCursor(id, threadId)); - const [workerA, workerB] = [cursors(), cursors()]; - const contexts = await Promise.all( - [workerA, workerB].map((deliveryCursorStore) => - assembleIncrementalContext( - { - messageStore: rebuilt, - deliveryCursorStore, - invocationDeps: {}, - }, - 'user-1', - 'thread-cursor', - 'opus', - ), - ), - ); - assert.equal(await cursors().getCursor('user-1', 'opus', 'thread-cursor'), boundaries.get('opus')); - assert.equal(await cursors().getCursor('user-1', 'codex', 'thread-cursor'), undefined); - for (const context of contexts) { - assert.ok(!context.navigationHeader.includes('independently answer')); - assert.ok(!context.contextText.includes('independently answer')); - } - assert.equal(providerCalls, 2, 'recovery reads do not re-invoke either provider'); - } finally { - blocked.resolve(); - await execution; - } - }, -); - -test( - '#1371 Redis/HTTP: direct completion consumes adopted scheduler wakes before automatic review', - options, - async (t) => { - const redis = await connect(t); - const messageStore = new RedisMessageStore(redis, { ttlSeconds: 0 }); - const result = await runDirectWitnessScenario(t, { messageStore, useHttp: true }); - const restarted = new RedisMessageStore(redis, { ttlSeconds: 0 }); - for (const sourceId of result.sourceMessageIds) { - const source = await restarted.getById(sourceId); - assert.equal(source.queueCustody.status, 'terminal'); - assert.equal(source.queueCustody.targetOutcomeByCatId.codex.consumption.sourceMessageId, sourceId); - } - assert.deepEqual(await restarted.scanByDeliveryStatus('queued'), []); - assert.deepEqual(result.calls, [['codex'], ['opus']]); - t.diagnostic( - 'HTTP user message -> two scheduler receipts consumed -> automatic review once -> Queue 0; new Redis store stays settled', - ); - }, -); - test( '#1371 Redis: terminal recovery has bounded stable pages and survives a new store instance', options, @@ -318,28 +37,32 @@ test( const store = new RedisMessageStore(redis, { ttlSeconds: 0 }); const ids = []; for (let index = 0; index < 101; index++) { - const source = await store.append({ - userId: 'user-1', - threadId: 'thread-1', - catId: 'opus', - content: 'completed reply', - mentions: [], - timestamp: index, - extra: { coordination: { id: `coord-${index}`, phase: 'terminal', hop: 2, subjectRef: `work-${index}` } }, - }); + const source = await store.append( + canonicalTestMessageInput({ + userId: 'user-1', + threadId: 'thread-1', + catId: 'opus', + content: 'completed reply', + mentions: [], + timestamp: index, + extra: { coordination: { id: `coord-${index}`, phase: 'terminal', hop: 2, subjectRef: `work-${index}` } }, + }), + ); ids.push(source.id); } const first = await store.scanCoordinationTerminalMessageIds(); assert.equal(first.messageIds.length, 100); assert.deepEqual(first.nextCursor, { offset: 100, upperBound: 101 }); - await store.append({ - userId: 'user-1', - threadId: 'thread-1', - catId: null, - content: 'new work', - mentions: [], - timestamp: 999, - }); + await store.append( + canonicalTestMessageInput({ + userId: 'user-1', + threadId: 'thread-1', + catId: null, + content: 'new work', + mentions: [], + timestamp: 999, + }), + ); const restarted = new RedisMessageStore(redis, { ttlSeconds: 0 }); const second = await restarted.scanCoordinationTerminalMessageIds(first.nextCursor); assert.deepEqual([...first.messageIds, ...second.messageIds], ids); @@ -348,54 +71,63 @@ test( ); test( - '#1371 dogfood: HTTP orphan recovery clears Redis custody and remains settled after rebuilding stores', + '#1371 dogfood: HTTP pre-start recovery clears Queue and remains canceled after rebuilding Redis stores', options, async (t) => { const redis = await connect(t); const store = new RedisMessageStore(redis, { ttlSeconds: 0 }); - const queue = new InvocationQueue(); + const queue = new InvocationQueue(new RedisQueueLedgerStore(redis)); const tracker = new InvocationTracker(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store }); - const source = await store.append({ - userId: 'user-1', + const queueInput = canonicalTestQueueInput({ threadId: 'thread-1', - catId: null, - content: 'orphan dogfood fixture', - mentions: ['opus', 'codex'], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); - const { entry } = queue.enqueue({ userId: 'user-1', - threadId: 'thread-1', - content: source.content, - messageId: source.id, + kind: 'conversation_input', + sourceId: 'issue1371-redis-prestart-source', + content: 'orphan dogfood fixture', targetCats: ['opus', 'codex'], - source: 'user', - ownerAuthProvenance: 'strict', intent: 'execute', }); - await store.initializeQueueCustody(source.id, createInitialQueuedMessageCustody(entry)); - queue.markProcessingById('thread-1', entry.id); - await coordinator.persistEntry(queue.getEntrySnapshot('thread-1', 'user-1', entry.id)); + const admitted = await queue.appendAndEnqueueDurable( + store, + canonicalTestMessageInput({ + threadId: queueInput.threadId, + userId: queueInput.userId, + from: queueInput.from, + catId: null, + content: queueInput.content, + mentions: queueInput.targetCats, + timestamp: Date.now(), + deliveryStatus: 'queued', + }), + queueInput, + ); + const source = admitted.message; + let releaseCreate; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + let createCalls = 0; let providerCalls = 0; const records = { listRunningByThread: async () => [], update: async () => {}, create: async () => { - throw new Error('no invocation allowed'); + createCalls++; + await createGate; + return { outcome: 'created', invocationId: 'inv-redis-prestart' }; }, }; const socketManager = { emitToUser() {}, broadcastToRoom() {}, broadcastAgentMessage() {} }; const processor = new QueueProcessor({ queue, invocationTracker: tracker, - queueCustodyCoordinator: coordinator, messageStore: store, invocationRecordStore: records, socketManager, log: { info() {}, warn() {}, error() {} }, router: { + resolveConversationTargetsAtAdmission: async (targets) => [...targets], + resolveExplicitTargets: async (targets) => [...targets], routeExecution: async function* () { providerCalls++; yield { type: 'done', catId: 'opus', timestamp: Date.now() }; @@ -410,26 +142,26 @@ test( invocationTracker: tracker, queueProcessor: processor, messageStore: store, - queueCustodyCoordinator: coordinator, invocationRecordStore: records, socketManager, }); - const address = await app.listen({ host: '127.0.0.1', port: 0 }); t.after(() => app.close()); const headers = { 'x-cat-cafe-user': 'user-1' }; - const before = await (await fetch(`${address}/api/threads/thread-1/queue`, { headers })).json(); + await processor.processNext('thread-1', 'user-1'); + while (createCalls === 0) await new Promise((resolve) => setTimeout(resolve, 1)); + const beforeResponse = await app.inject({ method: 'GET', url: '/api/threads/thread-1/queue', headers }); + const before = beforeResponse.json(); assert.equal(before.queue.length, 1); - assert.equal(before.queue[0].recoveryActions[0].kind, 'force_reset'); - const reset = await fetch(`${address}/api/threads/thread-1/force-reset`, { method: 'POST', headers }); - assert.equal(reset.status, 200); - const after = await (await fetch(`${address}/api/threads/thread-1/queue`, { headers })).json(); + const reset = await app.inject({ method: 'POST', url: '/api/threads/thread-1/force-reset', headers }); + releaseCreate(); + assert.equal(reset.statusCode, 200, reset.body); + const afterResponse = await app.inject({ method: 'GET', url: '/api/threads/thread-1/queue', headers }); + const after = afterResponse.json(); assert.deepEqual(after.queue, []); const restarted = new RedisMessageStore(redis, { ttlSeconds: 0 }); assert.equal((await restarted.getById(source.id)).deliveryStatus, 'canceled'); assert.deepEqual(await restarted.scanByDeliveryStatus('queued'), []); assert.equal(providerCalls, 0); - t.diagnostic( - `Dogfood HTTP ${address}: Queue 1 -> 0; reset 200; Redis rebuilt store canceled; provider calls ${providerCalls}`, - ); + t.diagnostic('Dogfood HTTP inject: Queue 1 -> 0; reset 200; Redis rebuilt store canceled; provider calls 0'); }, ); diff --git a/packages/api/test/issue1371-retirement-recovery.test.js b/packages/api/test/issue1371-retirement-recovery.test.js deleted file mode 100644 index 510229fb53..0000000000 --- a/packages/api/test/issue1371-retirement-recovery.test.js +++ /dev/null @@ -1,178 +0,0 @@ -import assert from 'node:assert/strict'; -import { mock, test } from 'node:test'; -import { A2ADispatchDispositionService } from '../dist/domains/ball-custody/A2ADispatchDispositionService.js'; -import { BallCustodyProjector } from '../dist/domains/ball-custody/BallCustodyProjector.js'; -import { CoordinationTerminalRetirement } from '../dist/domains/ball-custody/CoordinationTerminalRetirement.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { RedisMessageStore } from '../dist/domains/cats/services/stores/redis/RedisMessageStore.js'; -import { runTerminalQueueHarness } from './helpers/issue1371-terminal-queue-harness.js'; - -const log = () => ({ info: mock.fn(), warn: mock.fn() }); - -test('#1371: crash after message commit reconstructs exact retirement without provider replay', async () => { - const { h, terminal, deps, queue } = await runTerminalQueueHarness('retirement unavailable'); - assert.equal(queue.list(terminal.threadId, terminal.userId).length, 0); - assert.deepEqual(h.messageStore.getById(terminal.id).queueCustody.handledByCatIds, ['fable5']); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.dispatch_dispositioned').length, 0); - // Discard process-local recovery state. Reopen serialized message facts, the - // existing event log and its projector; the old invocation is no longer latest. - const records = new Map(structuredClone(h.messageStore.getRecent(100)).map((message) => [message.id, message])); - const store = { - getById: async (id) => structuredClone(records.get(id) ?? null), - scanCoordinationTerminalMessageIds: async () => ({ - messageIds: [...records.values()] - .filter((message) => message.extra?.coordination?.phase === 'terminal') - .map((message) => message.id), - }), - }; - const projector = new BallCustodyProjector(h.eventLog, h.projectionStore); - const service = new A2ADispatchDispositionService({ - registry: { isLatest: async () => false }, - messageStore: store, - ballCustodyEventLog: h.eventLog, - ballCustodyProjectionStore: h.projectionStore, - ballCustody: h.ingest, - repairProjection: (key) => projector.rebuild(key), - }); - const recovery = new CoordinationTerminalRetirement({ messageStore: store, service, log: log() }); - assert.deepEqual(await recovery.runPage(), { inspected: 1, applied: 1, failed: 0 }); - assert.deepEqual(await recovery.runPage(), { inspected: 1, applied: 0, failed: 0 }); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.dispatch_dispositioned').length, 1); - assert.equal(deps.router.routeExecution.mock.calls.length, 1, 'recovery must never run the source body again'); - assert.deepEqual(records.get(terminal.id).queueCustody, h.messageStore.getById(terminal.id).queueCustody); -}); - -test('#1371: one failing source does not starve later pages and is retried on the next pass', async () => { - const { h, terminal } = await runTerminalQueueHarness(); - let fail = true; - const seenCursors = []; - const store = { - getById: (id) => h.messageStore.getById(id), - scanCoordinationTerminalMessageIds: async (cursor) => { - seenCursors.push(cursor); - return cursor - ? { messageIds: [terminal.id] } - : { - messageIds: [terminal.id], - nextCursor: { offset: 100, upperBound: 200 }, - }; - }, - }; - const service = { - completeFromCoordinationTerminal: async () => { - if (fail) throw new Error('unavailable'); - return { outcome: 'replayed' }; - }, - }; - const recovery = new CoordinationTerminalRetirement({ messageStore: store, service, log: log() }); - assert.equal((await recovery.runPage()).failed, 1); - fail = false; - assert.equal((await recovery.runPage()).failed, 0); - assert.equal((await recovery.runPage()).failed, 0); - assert.deepEqual(seenCursors, [undefined, { offset: 100, upperBound: 200 }, undefined]); -}); - -test('#1371: concurrent ticks share a page and a scan failure retains its cursor', async () => { - let release; - let fail = true; - const gate = new Promise((resolve) => { - release = resolve; - }); - const scan = mock.fn(async () => { - await gate; - if (fail) throw new Error('read outage'); - return { messageIds: [] }; - }); - const recovery = new CoordinationTerminalRetirement({ - messageStore: { - getById: async () => null, - scanCoordinationTerminalMessageIds: scan, - }, - service: {}, - log: log(), - }); - const first = recovery.runPage(); - const second = recovery.runPage(); - assert.equal(first, second); - release(); - await assert.rejects(first, /read outage/); - fail = false; - assert.deepEqual(await recovery.runPage(), { inspected: 0, applied: 0, failed: 0 }); - assert.deepEqual( - scan.mock.calls.map((call) => call.arguments), - [[undefined], [undefined]], - ); -}); - -test('#1371: memory scan reads bounded pages and freezes the current pass boundary', () => { - const store = new MessageStore(); - const append = (index) => - store.append({ - userId: 'u', - threadId: 't', - catId: 'opus', - mentions: [], - content: 'terminal', - timestamp: index + 1, - extra: { coordination: { id: `c-${index}`, phase: 'terminal', hop: 2 } }, - }); - for (let i = 0; i < 205; i++) append(i); - const first = store.scanCoordinationTerminalMessageIds(); - assert.equal(first.messageIds.length, 100); - for (let i = 205; i < 215; i++) append(i); - const second = store.scanCoordinationTerminalMessageIds(first.nextCursor); - const third = store.scanCoordinationTerminalMessageIds(second.nextCursor); - assert.equal(second.messageIds.length, 100); - assert.equal(third.messageIds.length, 5); - assert.equal(third.nextCursor, undefined); - assert.equal(new Set([...first.messageIds, ...second.messageIds, ...third.messageIds]).size, 205); - assert.equal(store.scanCoordinationTerminalMessageIds().nextCursor.upperBound, 215); -}); - -test('#1371: Redis page bounds hydration and uses its raw timeline, including queued sources', async () => { - const ranges = []; - const requested = []; - const ids = Array.from({ length: 205 }, (_, i) => `message-${i}`); - const redis = { - options: { keyPrefix: 'isolated:' }, - zcard: async () => ids.length, - zrange: async (key, start, end) => { - ranges.push([key, start, end]); - return ids.slice(start, end + 1); - }, - pipeline: () => { - const batch = []; - return { - hget: (key, field) => { - requested.push([key, field]); - batch.push(key); - }, - exec: async () => - batch.map((key) => [ - null, - JSON.stringify({ - coordination: { - id: key, - phase: key.endsWith('-0') ? 'active' : 'terminal', - hop: 2, - }, - }), - ]), - }; - }, - }; - const store = new RedisMessageStore(redis); - const first = await store.scanCoordinationTerminalMessageIds(); - assert.equal(first.messageIds.length, 99); - assert.equal(requested.length, 100); - assert.deepEqual(ranges, [['msg:timeline', 0, 99]]); - const second = await store.scanCoordinationTerminalMessageIds(first.nextCursor); - const third = await store.scanCoordinationTerminalMessageIds(second.nextCursor); - assert.equal(third.messageIds.length, 5); - assert.equal(third.nextCursor, undefined); - assert.equal(requested.length, 205); - assert.equal( - requested.every(([key, field]) => key.startsWith('msg:message-') && field === 'extra'), - true, - ); -}); diff --git a/packages/api/test/issue1371-terminal-convergence-review.test.js b/packages/api/test/issue1371-terminal-convergence-review.test.js deleted file mode 100644 index 5b6f673118..0000000000 --- a/packages/api/test/issue1371-terminal-convergence-review.test.js +++ /dev/null @@ -1,177 +0,0 @@ -import assert from 'node:assert/strict'; -import { mock, test } from 'node:test'; -import Fastify from 'fastify'; -import { buildHandedCvoEvent, buildHandedEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { QueuedMessageCustodyStartupReconciler } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyStartupReconciler.js'; -import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { queueRoutes } from '../dist/routes/queue.js'; -import { createA2ADispositionAuth, createA2ADispositionHarness } from './helpers/a2a-dispatch-disposition-harness.js'; -import { runTerminalQueueHarness } from './helpers/issue1371-terminal-queue-harness.js'; - -for (const intent of ['done_notify', 'handoff']) { - test(`#1371: exact dispatch can retire after unrelated thread ${intent}`, async () => { - const h = await createA2ADispositionHarness(); - await h.ingest.record( - buildHandedCvoEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - messageId: 'unrelated-cvo-message', - intent, - at: 1_500, - }), - ); - const before = await h.projectionStore.get('ball:thread:thread-1'); - const result = await h.service.complete(createA2ADispositionAuth(h), 'completed'); - assert.equal(result.outcome, 'applied'); - assert.equal(result.retired, true); - const after = await h.projectionStore.get('ball:thread:thread-1'); - assert.equal(after.state, before.state, 'exact retirement cannot alter unrelated thread state'); - assert.equal(after.holder, before.holder); - }); -} - -test('#1371: a newer unrelated dispatch to the same cat keeps its active thread projection', async () => { - const h = await createA2ADispositionHarness(); - const unrelated = h.messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'Independent task', - mentions: ['codex-sol'], - timestamp: 1_500, - threadId: 'thread-1', - }); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'opus', - toCatId: 'codex-sol', - messageId: unrelated.id, - at: 1_500, - }), - ); - const result = await h.service.complete(createA2ADispositionAuth(h), 'completed'); - assert.equal(result.retired, true); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'active'); -}); - -// Real dispatch service, projector, message custody coordinator and QueueProcessor. -// The provider is the only execution stub; it persists a source-bound reply. -for (const scenario of [ - 'active control', - 'done_notify', - 'steer completed residue', - 'GET completed residue', - 'retirement unavailable', - 'restart after success', -]) { - test(`#1371: replied cross-thread terminal has no live carrier (${scenario})`, async () => { - const { h, terminal, queue, deps, entry, queued, processor, result, custody } = - await runTerminalQueueHarness(scenario); - assert.equal(result.status, 'succeeded'); - if (scenario === 'steer completed residue' || scenario === 'GET completed residue') { - assert.equal(custody.status, 'terminal', 'the durable source was already handled'); - // Independent stale process-local snapshot: this fence must still be tested - // after source-first settlement stops producing residue on the happy path. - queue.restoreDurableEntry(queued); - const activeController = deps.invocationTracker.start( - terminal.threadId, - 'fable5', - terminal.userId, - ['fable5'], - 'unrelated-current-work', - ); - const app = Fastify(); - try { - await app.register(queueRoutes, { - threadStore: { get: async () => ({ id: terminal.threadId, createdBy: terminal.userId }) }, - invocationQueue: queue, - queueProcessor: processor, - invocationTracker: deps.invocationTracker, - socketManager: deps.socketManager, - messageStore: h.messageStore, - queueCustodyCoordinator: deps.queueCustodyCoordinator, - }); - if (scenario === 'GET completed residue') { - const response = await app.inject({ - method: 'GET', - url: `/api/threads/${terminal.threadId}/queue`, - headers: { 'x-cat-cafe-user': terminal.userId }, - }); - assert.equal(response.statusCode, 200); - assert.deepEqual(response.json().queue, [], 'completed custody cannot be published as pending work'); - return; - } - const response = await app.inject({ - method: 'POST', - url: `/api/threads/${terminal.threadId}/queue/${entry.id}/steer`, - headers: { 'x-cat-cafe-user': terminal.userId }, - payload: {}, - }); - assert.equal( - deps.router.routeExecution.mock.calls.length, - 1, - 'the existing provider no-reentry fence still holds', - ); - assert.equal( - activeController.signal.aborted, - false, - `already-consumed Queue residue must be rejected before preempting current work (HTTP ${response.statusCode})`, - ); - } finally { - await app.close(); - } - return; - } - const observed = { - liveCarriers: queue.list(terminal.threadId, terminal.userId).length, - durableStatus: custody.status, - handledTargets: custody.handledByCatIds, - providerCalls: deps.router.routeExecution.mock.calls.length, - }; - assert.deepEqual( - observed, - { - liveCarriers: 0, - durableStatus: 'terminal', - handledTargets: ['fable5'], - providerCalls: 1, - }, - 'a persisted exact-source response must settle carrier and receipt together', - ); - if (scenario === 'restart after success') { - // Serialize and reload durable records; no Queue or invocation state survives. - const records = new Map(structuredClone(h.messageStore.getRecent(100)).map((message) => [message.id, message])); - const recoveredStore = { - getById: async (id) => structuredClone(records.get(id) ?? null), - scanByDeliveryStatus: async (status) => - [...records.values()].filter((message) => message.deliveryStatus === status).map((message) => message.id), - }; - const recoveredQueue = new InvocationQueue(); - const restored = await new QueuedMessageCustodyStartupReconciler({ - messageStore: recoveredStore, - invocationQueue: recoveredQueue, - invocationRecordStore: { get: async () => null }, - log: { info() {}, warn() {} }, - }).reconcile(); - const provider = mock.fn(async function* () {}); - const recoveredProcessor = new QueueProcessor({ - ...deps, - queue: recoveredQueue, - messageStore: recoveredStore, - router: { routeExecution: provider }, - }); - for (const scope of restored.resumeScopes) { - const next = recoveredQueue.markProcessing(scope.threadId, scope.userId); - if (next) await recoveredProcessor.executeEntry(next); - } - assert.equal(restored.entriesRestored, 0); - assert.equal(provider.mock.calls.length, 0); - } - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.dispatch_dispositioned') - .length, - scenario === 'retirement unavailable' ? 0 : 1, - ); - }); -} diff --git a/packages/api/test/lifecycle-append-projection.test.js b/packages/api/test/lifecycle-append-projection.test.js new file mode 100644 index 0000000000..b8560dd898 --- /dev/null +++ b/packages/api/test/lifecycle-append-projection.test.js @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { projectLifecycleAppendAction } = await import( + '../dist/domains/cats/services/agents/invocation/lifecycle-append-projection.js' +); + +function entry(overrides = {}) { + return { + version: 2, + id: 'entry-1', + threadId: 'thread-1', + owner: { kind: 'user', userId: 'user-1' }, + from: { kind: 'user', userId: 'user-1' }, + kind: 'conversation_input', + targets: ['codex'], + payload: { sourceRecordId: 'message-1', content: 'please continue', messageId: 'message-1' }, + execution: { intent: 'execute', ownerAuthProvenance: 'strict', autoExecute: false }, + delivery: {}, + status: 'queued', + enqueuedAt: 1, + priority: 'normal', + ...overrides, + }; +} + +function tracker({ dispatcher = true, activeRun = true, userId = 'user-1' } = {}) { + const run = { + threadId: 'thread-1', + targetId: 'codex', + invocationId: 'turn-1', + responseMessageId: 'response-1', + inputEntryIds: ['old-entry'], + inputMessageIds: ['old-message'], + privateInputEntryIds: [], + startedAt: 1, + }; + return { + has: () => true, + getUserId: () => userId, + cancel: () => ({ cancelled: false, catIds: [] }), + getActiveSlots: () => [{ catId: 'codex', startedAt: 1, ...(activeRun ? { activeRun: run } : {}) }], + getAgentClientActiveRunDispatcher: () => + dispatcher + ? { + invocationId: 'turn-1', + capabilities: { append: true, steer: true }, + handle: { provider: 'openai_codex', carrier: 'codex_app_server', threadId: 'native-1', turnId: 'turn-1' }, + dispatch: async () => ({ accepted: true, handle: {} }), + } + : undefined, + }; +} + +function mixedTargetTracker() { + const codexRun = { + threadId: 'thread-1', + targetId: 'codex', + invocationId: 'turn-codex', + responseMessageId: 'response-codex', + inputEntryIds: ['old-entry'], + inputMessageIds: ['old-message'], + privateInputEntryIds: [], + startedAt: 1, + }; + return { + has: () => true, + getUserId: () => 'user-1', + cancel: () => ({ cancelled: false, catIds: [] }), + getActiveSlots: () => [{ catId: 'codex', startedAt: 1, activeRun: codexRun }], + getAgentClientActiveRunDispatcher: (_threadId, targetId) => + targetId === 'codex' + ? { + invocationId: 'turn-codex', + capabilities: { append: true, steer: true }, + handle: { provider: 'openai_codex', carrier: 'codex_app_server' }, + dispatch: async () => ({ accepted: true, handle: {} }), + } + : undefined, + }; +} + +describe('lifecycle Append action projection', () => { + it('projects the exact Queue and Active Run fences only from a live supporting dispatcher', () => { + const result = projectLifecycleAppendAction({ + threadId: 'thread-1', + userId: 'user-1', + queueRevision: 'revision-1', + entry: entry(), + invocationTracker: tracker(), + }); + assert.deepEqual(result, { + available: true, + action: { + kind: 'append', + expectedQueueRevision: 'revision-1', + expectedRuns: [{ targetId: 'codex', invocationId: 'turn-1', responseMessageId: 'response-1' }], + }, + }); + }); + + it('fails closed for ACP/unsupported clients, missing Active Runs, or another owner', () => { + for (const [expected, invocationTracker] of [ + ['client_unsupported', tracker({ dispatcher: false })], + ['active_run_missing', tracker({ activeRun: false })], + ['owner_mismatch', tracker({ userId: 'other-user' })], + ]) { + assert.deepEqual( + projectLifecycleAppendAction({ + threadId: 'thread-1', + userId: 'user-1', + queueRevision: 'revision-1', + entry: entry(), + invocationTracker, + }), + { available: false, reason: expected }, + ); + } + }); + + it('projects each available target independently when a sibling has no live Append carrier', () => { + const result = projectLifecycleAppendAction({ + threadId: 'thread-1', + userId: 'user-1', + queueRevision: 'revision-1', + entry: entry({ targets: ['codex', 'opus'] }), + invocationTracker: mixedTargetTracker(), + }); + + assert.deepEqual(result, { + available: true, + action: { + kind: 'append', + expectedQueueRevision: 'revision-1', + expectedRuns: [{ targetId: 'codex', invocationId: 'turn-codex', responseMessageId: 'response-codex' }], + }, + }); + }); +}); diff --git a/packages/api/test/lifecycle-response-context.test.js b/packages/api/test/lifecycle-response-context.test.js new file mode 100644 index 0000000000..fa3023a9b4 --- /dev/null +++ b/packages/api/test/lifecycle-response-context.test.js @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +const { assembleIncrementalContext: assembleRaw } = await import( + '../dist/domains/cats/services/agents/routing/route-helpers.js' +); + +function assemble(messages, viewerCatId = 'codex') { + return assembleRaw( + { + services: {}, + invocationDeps: {}, + messageStore: { getByThreadAfter: async () => messages }, + deliveryCursorStore: { getCursor: async () => undefined }, + }, + 'user-1', + 'thread-1', + viewerCatId, + undefined, + 'debug', + { effectiveMaxContextTokens: 500_000 }, + ); +} + +function emptyTerminalResponse(status, reason) { + return { + id: `response-${status}`, + threadId: 'thread-1', + userId: 'user-1', + from: { kind: 'agent', catId: 'opus' }, + catId: 'opus', + content: '', + mentions: [], + timestamp: 100, + deliveryStatus: status === 'canceled' ? 'canceled' : 'delivered', + lifecycle: { + kind: 'response', + orderKey: '100:turn-1', + invocationId: 'turn-1', + targetId: 'opus', + inputEntryIds: ['entry-1'], + inputMessageIds: ['source-1'], + status, + startedAt: 50, + completedAt: 100, + ...(reason ? { reason } : {}), + }, + }; +} + +describe('lifecycle response incremental context', () => { + test('projects an empty canceled peer response as explicit readable context', async () => { + const result = await assemble([emptyTerminalResponse('canceled')]); + + assert.ok(result.contextText.includes('已停止回复。'), result.contextText); + assert.deepEqual(result.projectedMessageIds, ['response-canceled']); + }); + + test('#1398: explains that a user-canceled own response was stopped by the user', async () => { + const result = await assemble([emptyTerminalResponse('canceled', 'user_cancel')], 'opus'); + + assert.ok(result.contextText.includes('用户已取消该回复'), result.contextText); + assert.equal(result.contextText.includes('上一轮'), false, result.contextText); + assert.equal(result.contextText.includes('harness'), false, result.contextText); + }); + + test('projects an empty interrupted peer response as explicit readable context', async () => { + const result = await assemble([emptyTerminalResponse('interrupted')]); + + assert.ok(result.contextText.includes('回复已中断。'), result.contextText); + }); + + test('#1398: projects the current cat own empty interrupted response as lifecycle context', async () => { + const result = await assemble([emptyTerminalResponse('interrupted')], 'opus'); + + assert.ok(result.contextText.includes('回复已中断。'), result.contextText); + assert.deepEqual(result.projectedMessageIds, ['response-interrupted']); + }); + + test('#1398: explains that a preempted response was interrupted by a later message', async () => { + const result = await assemble([emptyTerminalResponse('interrupted', 'preempted')], 'opus'); + + assert.ok(result.contextText.includes('该回复已被后续消息中断'), result.contextText); + }); + + test('#1398: does not replay the current cat own rich response as an empty lifecycle marker', async () => { + const message = emptyTerminalResponse('completed'); + message.extra = { + rich: { + blocks: [{ id: 'card-1', v: 1, kind: 'card', title: '已呈现的卡片' }], + }, + }; + + const result = await assemble([message], 'opus'); + + assert.equal(result.contextText.includes('已呈现的卡片'), false, result.contextText); + assert.deepEqual(result.projectedMessageIds, []); + }); +}); diff --git a/packages/api/test/limb-transcript-cat-delivery.test.js b/packages/api/test/limb-transcript-cat-delivery.test.js index c8442ef760..41ef066103 100644 --- a/packages/api/test/limb-transcript-cat-delivery.test.js +++ b/packages/api/test/limb-transcript-cat-delivery.test.js @@ -44,7 +44,7 @@ test('appends transcript idempotently and invokes only the bound cat', async () return { async trigger(...args) { triggered.push(args); - return 'dispatched'; + return 'enqueued'; }, }; }, @@ -57,6 +57,7 @@ test('appends transcript idempotently and invokes only the bound cat', async () assert.equal(appended.length, 1); assert.equal(appended[0].idempotencyKey, 'limb:stackchan-home:observation-1'); assert.equal(appended[0].content, '大猫猫,你在吗?'); + assert.equal(appended[0].deliveryStatus, 'queued'); assert.deepEqual(appended[0].mentions, ['codex-sol']); assert.equal(appended[0].source.meta.interactionId, 'interaction-1'); assert.deepEqual(triggered, [['thread-stackchan', 'codex-sol', 'default-user', '大猫猫,你在吗?', 'message-1']]); @@ -75,7 +76,7 @@ test('fails before persistence when binding cat or invocation runtime is unavail const unknownCat = new LimbTranscriptCatDelivery({ ...base, isKnownCat: () => false, - invokeTriggerProvider: { get: () => ({ trigger: async () => 'dispatched' }) }, + invokeTriggerProvider: { get: () => ({ trigger: async () => 'enqueued' }) }, }); await assert.rejects(unknownCat.deliverTranscript({ binding, observation }), /unknown bound cat/); diff --git a/packages/api/test/live-carrier-agent-services.test.js b/packages/api/test/live-carrier-agent-services.test.js new file mode 100644 index 0000000000..86d0e6595b --- /dev/null +++ b/packages/api/test/live-carrier-agent-services.test.js @@ -0,0 +1,700 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { ClaudeSdkAgentService } from '../dist/domains/cats/services/agents/providers/ClaudeSdkAgentService.js'; + +class AsyncInbox { + #values = []; + #waiters = []; + #closed = false; + + push(value) { + const waiter = this.#waiters.shift(); + if (waiter) waiter({ value, done: false }); + else this.#values.push(value); + } + + close() { + this.#closed = true; + for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true }); + } + + [Symbol.asyncIterator]() { + return { + next: () => { + const value = this.#values.shift(); + if (value !== undefined) return Promise.resolve({ value, done: false }); + if (this.#closed) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve) => this.#waiters.push(resolve)); + }, + }; + } +} + +function activeRunRegistration() { + return { + invocationId: 'inv-live-carrier', + dispatcher: undefined, + released: false, + register(dispatcher) { + this.dispatcher = dispatcher; + return () => { + this.released = true; + }; + }, + }; +} + +describe('live member carriers', () => { + it('Claude SDK streams append into the active query and interrupts only for explicit steer', async () => { + let sdkOptions; + let sdkInput; + let interruptCount = 0; + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt, options }) => { + sdkOptions = options; + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => { + interruptCount += 1; + }, + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + systemPrompt: 'route identity', + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const firstPending = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + const initialInput = await sdkInput.next(); + assert.equal(initialInput.value.message.content[0].text, 'initial body'); + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-session-1' }); + const first = await firstPending; + assert.equal(first.value.type, 'session_init'); + assert.equal(registration.dispatcher.capabilities.append, true); + assert.equal(registration.dispatcher.capabilities.steer, true); + assert.match(sdkOptions.systemPrompt, /compiled L0/); + assert.match(sdkOptions.systemPrompt, /route identity/); + assert.equal(sdkOptions.permissionMode, 'plan'); + assert.equal(sdkOptions.extraArgs, undefined, 'the SDK carrier must not synthesize unsupported Claude CLI flags'); + assert.equal(sdkOptions.effort, 'max'); + assert.equal( + sdkOptions.env.CLAUDE_CODE_EFFORT_LEVEL, + undefined, + 'the current Agent SDK must receive effort through its typed option, not a process-wide environment side channel', + ); + + const appended = await registration.dispatcher.dispatch( + { text: 'append body' }, + { expectedInvocationId: registration.invocationId, force: false }, + ); + assert.equal(appended.accepted, true); + assert.equal((await sdkInput.next()).value.message.content[0].text, 'append body'); + assert.equal(interruptCount, 0); + + const steered = await registration.dispatcher.dispatch( + { text: 'steer body' }, + { expectedInvocationId: registration.invocationId, force: true }, + ); + assert.equal(steered.accepted, true); + assert.equal(interruptCount, 1); + assert.equal((await sdkInput.next()).value.message.content[0].text, 'steer body'); + + events.push({ + type: 'stream_event', + session_id: 'sdk-session-1', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'done' } }, + }); + assert.equal((await output.next()).value.content, 'done'); + events.close(); + assert.equal((await output.next()).value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK surfaces sanitized stderr instead of replacing it with a generic process-exit message', async () => { + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ options }) => ({ + interrupt: async () => {}, + async *[Symbol.asyncIterator]() { + options.stderr?.('error: provider initialization failed\n'); + throw new Error('Claude Code process exited with code 1'); + }, + }), + }); + + const messages = []; + for await (const message of service.invoke('initial body')) messages.push(message); + + const failure = messages.find((message) => message.type === 'error'); + assert.equal(failure.error, 'error: provider initialization failed'); + }); + + it('Claude SDK sanitizes secrets before truncating the stderr window', async () => { + const secretBody = 'a'.repeat(30); + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ options }) => ({ + interrupt: async () => {}, + async *[Symbol.asyncIterator]() { + options.stderr?.(`sk-${secretBody}${'x'.repeat(3967)}`); + options.stderr?.('yy'); + throw new Error('Claude Code process exited with code 1'); + }, + }), + }); + + const messages = []; + for await (const message of service.invoke('initial body')) messages.push(message); + + const failure = messages.find((message) => message.type === 'error'); + assert.ok(failure); + assert.doesNotMatch(failure.error, new RegExp(secretBody)); + assert.match(failure.error, /REDACTED/); + }); + + it('Claude SDK closes a streaming-input query when the provider emits its result terminal', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + let sdkInput; + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt }) => { + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => {}, + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const firstPending = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + await sdkInput.next(); + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-result-terminal' }); + assert.equal((await firstPending).value.type, 'session_init'); + + const terminal = output.next(); + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-result-terminal', + usage: { input_tokens: 10, output_tokens: 2 }, + }); + const settled = await Promise.race([ + terminal, + new Promise((_, reject) => setTimeout(() => reject(new Error('SDK result did not close the invocation')), 500)), + ]); + + assert.equal(settled.value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK consumes the response for an Append accepted before the current result terminal', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + let sdkInput; + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt }) => { + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => {}, + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const initialized = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + const initialInput = (await sdkInput.next()).value; + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-append-result-race' }); + assert.equal((await initialized).value.type, 'session_init'); + + assert.deepEqual( + await registration.dispatcher.dispatch( + { text: 'accepted follow-up' }, + { expectedInvocationId: registration.invocationId, force: false }, + ), + { + accepted: true, + handle: { + provider: 'anthropic', + carrier: 'claude_agent_sdk', + threadId: 'sdk-append-result-race', + turnId: registration.dispatcher.handle.turnId, + }, + }, + ); + const appendedInput = (await sdkInput.next()).value; + + const secondTurnOutput = output.next(); + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-append-result-race', + user_message_uuid: initialInput.uuid, + usage: { input_tokens: 10, output_tokens: 2 }, + }); + events.push({ + type: 'assistant', + session_id: 'sdk-append-result-race', + message: { id: 'second-turn', content: [{ type: 'text', text: 'follow-up response' }] }, + }); + const secondTurn = await secondTurnOutput; + assert.equal(secondTurn.value.type, 'text'); + assert.equal(secondTurn.value.content, 'follow-up response'); + + const terminal = output.next(); + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-append-result-race', + user_message_uuid: appendedInput.uuid, + usage: { input_tokens: 12, output_tokens: 3 }, + }); + assert.equal((await terminal).value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK does not apply an older result queue snapshot to a locally buffered Append', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + let sdkInput; + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt }) => { + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => {}, + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const initialized = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + const initialInput = (await sdkInput.next()).value; + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-buffered-append' }); + assert.equal((await initialized).value.type, 'session_init'); + + // The provider produced this snapshot before the local Append was accepted, + // but the service has not consumed the result event yet. + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-buffered-append', + user_message_uuid: initialInput.uuid, + user_message_uuids: [initialInput.uuid], + queued_turn_count: 0, + }); + assert.equal( + ( + await registration.dispatcher.dispatch( + { text: 'accepted but not yet dequeued' }, + { expectedInvocationId: registration.invocationId, force: false }, + ) + ).accepted, + true, + ); + + const pendingOutput = output.next(); + const appendedInput = await sdkInput.next(); + assert.equal(appendedInput.done, false); + assert.equal(appendedInput.value.message.content[0].text, 'accepted but not yet dequeued'); + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-buffered-append', + user_message_uuid: appendedInput.value.uuid, + queued_turn_count: 0, + }); + assert.equal((await pendingOutput).value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK does not apply an older result queue snapshot to a newer provider-dequeued Append', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + let sdkInput; + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt }) => { + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => {}, + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const initialized = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + const initialInput = (await sdkInput.next()).value; + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-dequeued-append' }); + assert.equal((await initialized).value.type, 'session_init'); + + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-dequeued-append', + user_message_uuid: initialInput.uuid, + queued_turn_count: 0, + }); + assert.equal( + ( + await registration.dispatcher.dispatch( + { text: 'accepted and already dequeued' }, + { expectedInvocationId: registration.invocationId, force: false }, + ) + ).accepted, + true, + ); + const appendedInput = (await sdkInput.next()).value; + const pendingOutput = output.next(); + let settled = false; + void pendingOutput.then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(settled, false, 'the older result must not terminalize a newer dequeued input'); + + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-dequeued-append', + user_message_uuid: appendedInput.uuid, + queued_turn_count: 0, + }); + assert.equal((await pendingOutput).value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK settles coalesced accepted inputs from one result identity set', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + let sdkInput; + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt }) => { + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => ({ still_queued: [] }), + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const initialized = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + const initialInput = (await sdkInput.next()).value; + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-coalesced-inputs' }); + assert.equal((await initialized).value.type, 'session_init'); + + assert.equal( + ( + await registration.dispatcher.dispatch( + { text: 'first accepted follow-up' }, + { expectedInvocationId: registration.invocationId, force: false }, + ) + ).accepted, + true, + ); + assert.equal( + ( + await registration.dispatcher.dispatch( + { text: 'second accepted follow-up' }, + { expectedInvocationId: registration.invocationId, force: false }, + ) + ).accepted, + true, + ); + const firstAppend = (await sdkInput.next()).value; + const secondAppend = (await sdkInput.next()).value; + + const terminal = output.next(); + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-coalesced-inputs', + user_message_uuid: secondAppend.uuid, + user_message_uuids: [initialInput.uuid, firstAppend.uuid, secondAppend.uuid], + usage: { input_tokens: 14, output_tokens: 3 }, + }); + assert.equal((await terminal).value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK keeps the stream open while an explicit Steer is awaiting its interrupt receipt', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + let sdkInput; + let interruptStarted; + const started = new Promise((resolve) => { + interruptStarted = resolve; + }); + let releaseInterrupt; + const interrupted = new Promise((resolve) => { + releaseInterrupt = resolve; + }); + let resultObserved; + const observed = new Promise((resolve) => { + resultObserved = resolve; + }); + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt }) => { + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => { + interruptStarted(); + await interrupted; + return { still_queued: [] }; + }, + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const initialized = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + const initialInput = (await sdkInput.next()).value; + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-steer-result-race' }); + assert.equal((await initialized).value.type, 'session_init'); + + const dispatch = registration.dispatcher.dispatch( + { text: 'interrupt and follow up' }, + { expectedInvocationId: registration.invocationId, force: true }, + ); + await started; + const secondTurnOutput = output.next(); + events.push({ + get type() { + resultObserved(); + return 'result'; + }, + subtype: 'success', + session_id: 'sdk-steer-result-race', + user_message_uuid: initialInput.uuid, + usage: { input_tokens: 10, output_tokens: 2 }, + }); + await observed; + releaseInterrupt(); + assert.equal((await dispatch).accepted, true); + const steeredInput = (await sdkInput.next()).value; + + events.push({ + type: 'assistant', + session_id: 'sdk-steer-result-race', + message: { id: 'steered-turn', content: [{ type: 'text', text: 'steered response' }] }, + }); + assert.equal((await secondTurnOutput).value.content, 'steered response'); + + const terminal = output.next(); + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-steer-result-race', + user_message_uuid: steeredInput.uuid, + usage: { input_tokens: 12, output_tokens: 3 }, + }); + assert.equal((await terminal).value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK closes a forced Steer from the provider queue snapshot without requiring an interrupted result', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + let sdkInput; + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ prompt }) => { + sdkInput = prompt[Symbol.asyncIterator](); + return { + interrupt: async () => ({ still_queued: [] }), + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }; + }, + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const initialized = output.next(); + while (!sdkInput) await new Promise((resolve) => setImmediate(resolve)); + await sdkInput.next(); + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-steer-queue-snapshot' }); + assert.equal((await initialized).value.type, 'session_init'); + + assert.equal( + ( + await registration.dispatcher.dispatch( + { text: 'replacement turn' }, + { expectedInvocationId: registration.invocationId, force: true }, + ) + ).accepted, + true, + ); + const steeredInput = (await sdkInput.next()).value; + + const terminal = output.next(); + events.push({ + type: 'result', + subtype: 'success', + session_id: 'sdk-steer-queue-snapshot', + user_message_uuid: steeredInput.uuid, + queued_turn_count: 0, + usage: { input_tokens: 12, output_tokens: 3 }, + }); + assert.equal((await terminal).value.type, 'done'); + assert.equal(registration.released, true); + }); + + it('Claude SDK injects the invocation MCP map through the native SDK option', async () => { + const root = mkdtempSync(join(tmpdir(), 'cat-cafe-claude-sdk-mcp-')); + mkdirSync(join(root, '.cat-cafe')); + writeFileSync( + join(root, '.mcp.json'), + JSON.stringify({ mcpServers: { 'user-tool': { command: 'user-tool', args: ['serve'] } } }), + ); + let sdkOptions; + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + mcpServerPath: join(root, 'missing-dist', 'index.js'), + l0CompilerFn: async () => 'compiled L0', + queryFn: ({ options }) => { + sdkOptions = options; + return { + interrupt: async () => {}, + async *[Symbol.asyncIterator]() { + yield { type: 'system', subtype: 'init', session_id: 'sdk-session-mcp' }; + }, + }; + }, + }); + + for await (const _message of service.invoke('initial body', { + workingDirectory: root, + callbackEnv: { CAT_CAFE_CAT_ID: 'opus', CAT_CAFE_INVOCATION_ID: 'inv-sdk-mcp' }, + })) { + // Drain the injected query. + } + + assert.equal(sdkOptions.strictMcpConfig, true); + assert.deepEqual(sdkOptions.mcpServers['user-tool'], { command: 'user-tool', args: ['serve'] }); + }); + + it('Claude SDK rejects a steer whose provider interrupt never acknowledges', async () => { + const events = new AsyncInbox(); + const registration = activeRunRegistration(); + const service = new ClaudeSdkAgentService({ + catId: 'opus', + model: 'claude-test', + activeRunControlTimeoutMs: 5, + l0CompilerFn: async () => 'compiled L0', + queryFn: () => ({ + interrupt: async () => await new Promise(() => {}), + [Symbol.asyncIterator]: () => events[Symbol.asyncIterator](), + }), + }); + + const output = service + .invoke('initial body', { + invocationId: registration.invocationId, + activeRunDispatch: registration, + toolExecutionPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + }) + [Symbol.asyncIterator](); + const firstPending = output.next(); + events.push({ type: 'system', subtype: 'init', session_id: 'sdk-session-timeout' }); + assert.equal((await firstPending).value.type, 'session_init'); + + const rejected = await registration.dispatcher.dispatch( + { text: 'must not be appended after an unacknowledged interrupt' }, + { expectedInvocationId: registration.invocationId, force: true }, + ); + assert.deepEqual(rejected, { accepted: false, reason: 'provider_rejected' }); + + events.close(); + assert.equal((await output.next()).value.type, 'done'); + }); +}); diff --git a/packages/api/test/live-invocation-projection.test.js b/packages/api/test/live-invocation-projection.test.js new file mode 100644 index 0000000000..1db1eaff49 --- /dev/null +++ b/packages/api/test/live-invocation-projection.test.js @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { resolveActiveInvocationsStrict } = await import( + '../dist/domains/cats/services/agents/invocation/live-invocation-projection.js' +); + +describe('live invocation projection', () => { + it('reads an Active Run after the asynchronous canonical liveness snapshot', async () => { + const activeRun = { + threadId: 'thread-1', + targetId: 'opus', + invocationId: 'child-1', + responseMessageId: 'response-1', + inputEntryIds: ['entry-1'], + inputMessageIds: ['source-1'], + privateInputEntryIds: [], + startedAt: 110, + }; + let activeSlotReads = 0; + const invocationTracker = { + has: () => true, + getUserId: () => 'user-1', + getExecutionId: () => 'parent-1', + cancel: () => ({ cancelled: false, catIds: [] }), + getActiveSlots: () => { + activeSlotReads += 1; + return [ + { + catId: 'opus', + startedAt: 100, + // The child admission binds its exact Active Run while the canonical + // record/child stores are being read asynchronously. + ...(activeSlotReads >= 2 ? { activeRun } : {}), + }, + ]; + }, + }; + const recordStore = { + listRunningByThread: async () => [ + { + id: 'parent-1', + threadId: 'thread-1', + userId: 'user-1', + targetCats: ['opus'], + status: 'running', + createdAt: 100, + updatedAt: 100, + }, + ], + }; + const draftStore = { getByThread: async () => [] }; + const turnExecutionStore = { + listByParent: async () => [ + { + invocationId: 'child-1', + parentInvocationId: 'parent-1', + threadId: 'thread-1', + userId: 'user-1', + catId: 'opus', + executionKind: 'ordinary', + status: 'running', + startedAt: 110, + updatedAt: 110, + }, + ], + }; + const log = { info: () => {}, warn: () => {} }; + + const projected = await resolveActiveInvocationsStrict( + 'thread-1', + 'user-1', + invocationTracker, + recordStore, + draftStore, + turnExecutionStore, + log, + ); + + assert.equal(activeSlotReads, 2); + assert.equal(projected.length, 1); + assert.equal(projected[0].executionId, 'parent-1'); + assert.equal(projected[0].turnInvocationId, 'child-1'); + assert.deepEqual(projected[0].activeRun, activeRun); + }); +}); diff --git a/packages/api/test/managed-command-wake-carrier-adapter.test.js b/packages/api/test/managed-command-wake-carrier-adapter.test.js new file mode 100644 index 0000000000..eba7ddb53e --- /dev/null +++ b/packages/api/test/managed-command-wake-carrier-adapter.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createManagedCommandWakeCarrierAdapter } from '../dist/domains/ball-custody/managed-command-wake-carrier-adapter.js'; + +const threadId = 'thread-managed-adapter'; +const userId = 'user-owner'; +const catId = 'codex-sol'; +const sourceId = 'message-managed-wake'; +const responseId = 'response-managed-wake'; + +function source(overrides = {}) { + return { + id: sourceId, + threadId, + userId, + deliveryStatus: 'delivered', + lifecycle: { + kind: 'input', + orderKey: sourceId, + dispatchRefs: [{ targetId: catId, phase: 'settled', statusMessageId: responseId, dispatchedAt: 200 }], + }, + ...overrides, + }; +} + +function response(overrides = {}) { + return { + id: responseId, + threadId, + userId, + lifecycle: { + kind: 'response', + orderKey: responseId, + invocationId: 'invocation-failed', + targetId: catId, + inputEntryIds: ['queue:managed-wake'], + inputMessageIds: [sourceId], + status: 'failed', + startedAt: 200, + completedAt: 300, + reason: 'provider_execution_failed', + }, + ...overrides, + }; +} + +describe('managed command wake History carrier adapter', () => { + it('projects one exact failed response with provider evidence', async () => { + const messages = new Map([ + [sourceId, source()], + [responseId, response()], + ]); + const adapter = createManagedCommandWakeCarrierAdapter({ + messageStore: { getById: async (id) => messages.get(id) ?? null }, + invocationRecordStore: { + get: async (id) => + id === 'invocation-failed' ? { id, status: 'failed', error: 'provider_execution_failed' } : null, + }, + invocationQueue: { getDurableEntriesForMessages: async () => new Map() }, + }); + + assert.deepEqual(await adapter.getEventCarrier({ threadId, userId, catId, messageId: sourceId }), { + state: 'failed', + attemptId: `${sourceId}:${catId}:invocation-failed`, + attemptSequence: 1, + invocationId: 'invocation-failed', + errorCode: 'provider_execution_failed', + }); + }); + + it('uses Queue only to prove that an undispatched target is still pending', async () => { + const pendingSource = source({ + deliveryStatus: 'queued', + lifecycle: { kind: 'input', orderKey: sourceId, dispatchRefs: [] }, + }); + const adapter = createManagedCommandWakeCarrierAdapter({ + messageStore: { getById: async (id) => (id === sourceId ? pendingSource : null) }, + invocationRecordStore: { get: async () => null }, + invocationQueue: { + getDurableEntriesForMessages: async () => + new Map([[sourceId, [{ threadId, owner: { kind: 'user', userId }, targets: [catId] }]]]), + }, + }); + + assert.deepEqual(await adapter.getEventCarrier({ threadId, userId, catId, messageId: sourceId }), { + state: 'pending', + }); + }); + + it('does not expose a carrier across its durable owner boundary', async () => { + const adapter = createManagedCommandWakeCarrierAdapter({ + messageStore: { getById: async (id) => (id === sourceId ? source() : response()) }, + invocationRecordStore: { get: async () => null }, + invocationQueue: { getDurableEntriesForMessages: async () => new Map() }, + }); + + assert.deepEqual(await adapter.getEventCarrier({ threadId, userId: 'user-foreign', catId, messageId: sourceId }), { + state: 'missing', + }); + }); +}); diff --git a/packages/api/test/managed-command-wake-queue-adapter.test.js b/packages/api/test/managed-command-wake-queue-adapter.test.js deleted file mode 100644 index c27516d016..0000000000 --- a/packages/api/test/managed-command-wake-queue-adapter.test.js +++ /dev/null @@ -1,155 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { createManagedCommandWakeQueueAdapter } from '../dist/domains/ball-custody/managed-command-wake-queue-adapter.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; - -test('managed wake adapter retries one exact failed disposition attempt under its durable owner', async () => { - const threadId = 'thread-managed-adapter'; - const userId = 'user-owner'; - const catId = 'codex-sol'; - const taskId = 'hold-ball-managed-adapter'; - const invocationId = 'invocation-missing-disposition'; - const startedAt = Date.now(); - const queue = new InvocationQueue(); - const messageStore = new MessageStore(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore, now: () => startedAt + 3_000 }); - const admission = queue.enqueue({ - threadId, - userId, - ownerAuthProvenance: 'strict', - content: '[managed wake] command complete', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - }); - assert.equal(admission.outcome, 'enqueued'); - const message = messageStore.append({ - threadId, - userId: 'scheduler', - catId: null, - content: admission.entry.content, - mentions: [catId], - timestamp: startedAt, - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: 'managed wake', - icon: '⏱️', - meta: { wakeWhen: true, taskId }, - }, - }); - queue.backfillMessageId(threadId, userId, admission.entry.id, message.id); - let entry = queue.getEntrySnapshot(threadId, userId, admission.entry.id); - assert.equal( - messageStore.initializeQueueCustody(message.id, createInitialQueuedMessageCustody(entry)).kind, - 'initialized', - ); - queue.markQueuedSeen(threadId, userId, entry.id, catId, invocationId, startedAt + 2_000); - await coordinator.persistEntry(queue.getEntrySnapshot(threadId, userId, entry.id)); - queue.markQueuedFailedForCatAcrossUsers( - threadId, - catId, - invocationId, - new Set([entry.id]), - 'invocation_failed', - startedAt + 2_100, - ); - entry = queue.getEntrySnapshot(threadId, userId, entry.id); - await coordinator.persistEntry(entry); - - const task = { - id: taskId, - templateId: 'reminder', - trigger: { type: 'once', fireAt: startedAt + 99_000 }, - params: { - triggerUserId: userId, - holdLifecycle: { - mode: 'wake_when', - status: 'active', - createdBy: `hold-ball:${catId}`, - managedCommand: { - state: 'enqueued', - command: 'pnpm gate', - startedAt, - messageId: message.id, - }, - }, - }, - display: { label: 'hold', category: 'system', description: 'hold' }, - deliveryThreadId: threadId, - enabled: true, - createdBy: `hold-ball:${catId}`, - createdAt: new Date(startedAt).toISOString(), - }; - const queueProcessor = { - async retryFailedTarget(expectedThreadId, expectedUserId, entryId, targetCatId, expectedAttemptId, commit) { - const current = queue.getEntrySnapshot(expectedThreadId, expectedUserId, entryId); - if (!current) return { outcome: 'not_retryable' }; - const retried = await coordinator.retryFailedTarget(current, targetCatId, expectedAttemptId, commit); - if (retried.outcome !== 'retried') return retried; - const queueRetry = queue.retryFailedTarget(expectedThreadId, expectedUserId, entryId, targetCatId); - if (!queueRetry) return { outcome: 'not_retryable' }; - queue.bindRetryAttemptId(expectedThreadId, expectedUserId, entryId, targetCatId, retried.attempt.id); - return { outcome: 'retried', attemptId: retried.attempt.id }; - }, - }; - const adapter = createManagedCommandWakeQueueAdapter({ - dynamicTaskStore: { getById: (id) => (id === taskId ? task : null) }, - messageStore, - invocationRecordStore: { - async get(id) { - return id === invocationId ? { id, status: 'failed', error: 'managed_hold_disposition_missing' } : null; - }, - }, - invocationQueue: queue, - queueProcessor, - }); - - const failedCarrier = await adapter.getEventCarrier({ threadId, userId, catId, messageId: message.id }); - assert.deepEqual(failedCarrier, { - state: 'failed', - attemptId: `${entry.id}:${catId}:1`, - attemptSequence: 1, - invocationId, - errorCode: 'managed_hold_disposition_missing', - }); - assert.equal( - await adapter.retryEventCarrier({ - taskId, - threadId, - userId, - catId, - messageId: message.id, - attemptId: failedCarrier.attemptId, - }), - 'retried', - ); - const retriedCustody = messageStore.getById(message.id).queueCustody; - assert.equal(retriedCustody.ownerUserId, userId); - assert.deepEqual( - retriedCustody.targetAttempts.map(({ id, state }) => ({ id, state })), - [ - { id: `${entry.id}:${catId}:1`, state: 'failed' }, - { id: `${entry.id}:${catId}:2`, state: 'queued' }, - ], - ); - assert.equal( - await adapter.retryEventCarrier({ - taskId, - threadId, - userId: 'user-foreign', - catId, - messageId: message.id, - attemptId: failedCarrier.attemptId, - }), - 'not_retryable', - ); -}); diff --git a/packages/api/test/managed-command-wake-recovery-sweep.test.js b/packages/api/test/managed-command-wake-recovery-sweep.test.js index f9db4498af..de4cb69d99 100644 --- a/packages/api/test/managed-command-wake-recovery-sweep.test.js +++ b/packages/api/test/managed-command-wake-recovery-sweep.test.js @@ -102,6 +102,13 @@ function makeHarness(options = {}) { appended.push(stored); return stored; }, + async markCanceled(messageId) { + const message = messagesById.get(messageId); + if (!message) return null; + const canceled = { ...message, deliveryStatus: 'canceled' }; + messagesById.set(messageId, canceled); + return canceled; + }, }, socketManager: { broadcastToRoom() {} }, taskRunner: { unregister: (id) => unregistered.push(id) }, @@ -127,6 +134,7 @@ function makeHarness(options = {}) { : {}), now: () => now, dispatchedCarrierGraceMs: 1_000, + ...(options.isCommandRunnerActive ? { isCommandRunnerActive: options.isCommandRunnerActive } : {}), }; return { @@ -154,7 +162,7 @@ async function loadSweep() { } describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { - test('dispatch restores strict owner auth from the durable private hold carrier', async () => { + test('dispatches strict-owner holds through canonical urgent Queue ingress', async () => { const { ManagedCommandWakeRecoverySweep } = await loadSweep(); const h = makeHarness({ ownerAuthProvenance: 'strict' }); const sweep = new ManagedCommandWakeRecoverySweep(h.deps); @@ -168,12 +176,11 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.equal(h.triggerCalls.length, 1); assert.deepEqual(h.triggerCalls[0][6], { sourceCategory: 'scheduled', - forceQueue: true, - ownerAuthProvenance: 'strict', + priority: 'urgent', }); }); - test('legacy holds without private owner auth dispatch as unknown', async () => { + test('dispatches legacy holds through the same canonical urgent Queue ingress', async () => { const { ManagedCommandWakeRecoverySweep } = await loadSweep(); const h = makeHarness(); const sweep = new ManagedCommandWakeRecoverySweep(h.deps); @@ -185,10 +192,13 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { }); assert.equal(h.triggerCalls.length, 1); - assert.equal(h.triggerCalls[0][6].ownerAuthProvenance, 'unknown'); + assert.deepEqual(h.triggerCalls[0][6], { + sourceCategory: 'scheduled', + priority: 'urgent', + }); }); - test('compatibility-fallback holds cannot be promoted during recovery', async () => { + test('does not copy compatibility owner provenance into Queue ingress options', async () => { const { ManagedCommandWakeRecoverySweep } = await loadSweep(); const h = makeHarness({ ownerAuthProvenance: 'compatibility_fallback' }); const sweep = new ManagedCommandWakeRecoverySweep(h.deps); @@ -200,7 +210,10 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { }); assert.equal(h.triggerCalls.length, 1); - assert.equal(h.triggerCalls[0][6].ownerAuthProvenance, 'compatibility_fallback'); + assert.deepEqual(h.triggerCalls[0][6], { + sourceCategory: 'scheduled', + priority: 'urgent', + }); }); test('persists the terminal result before attempting thread delivery', async () => { @@ -372,10 +385,10 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.equal(h.tasks.get('hold-ball-task-1').params.holdLifecycle.managedCommand.state, 'dispatch_pending'); assert.equal(h.appended.length, 1); assert.equal(h.appended[0].deliveryStatus, 'queued', 'managed wake stays under F264 receipt custody'); - assert.equal( - h.triggerCalls[0][6].forceQueue, - true, - 'managed event always uses one Queue carrier even when the thread is idle', + assert.deepEqual( + h.triggerCalls[0][6], + { sourceCategory: 'scheduled', priority: 'urgent' }, + 'managed event uses urgent canonical Queue ingress for its same-member continuation', ); const graceAttempt = await sweep.runOnce(); @@ -405,9 +418,18 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { userMessageId: messageId, status: 'queued', }; - h.invocationRecords.set(`connector-${messageId}`, invocation); + h.invocationRecords.set(`connector-${messageId}:codex-sol`, invocation); + h.invocationRecords.set(`connector-${messageId}`, { + id: 'legacy-source-wide-invocation', + userMessageId: messageId, + status: 'succeeded', + }); assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 0, pending: 1 }); - assert.equal(h.tasks.get('hold-ball-task-1').enabled, true, 'bare queued metadata is not recoverable execution'); + assert.equal( + h.tasks.get('hold-ball-task-1').enabled, + true, + 'target-scoped queued metadata must win over an obsolete source-wide success record', + ); invocation.status = 'failed'; h.setNow(16_000); @@ -424,9 +446,9 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.deepEqual(h.unregistered, ['hold-ball-task-1']); }); - test('does not retire a dispatched wake until its InvocationRecord completed successfully', async () => { + test('does not retire an enqueued wake until its InvocationRecord completed successfully', async () => { const { ManagedCommandWakeRecoverySweep } = await loadSweep(); - const h = makeHarness({ triggerOutcomes: ['dispatched'] }); + const h = makeHarness({ triggerOutcomes: ['enqueued'] }); const sweep = new ManagedCommandWakeRecoverySweep(h.deps); assert.equal( @@ -439,7 +461,7 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { ); const first = h.tasks.get('hold-ball-task-1'); assert.equal(first.enabled, true); - assert.equal(first.params.holdLifecycle.managedCommand.state, 'dispatched'); + assert.equal(first.params.holdLifecycle.managedCommand.state, 'enqueued'); const messageId = first.params.holdLifecycle.managedCommand.messageId; const invocation = { @@ -458,7 +480,7 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.equal(h.tasks.get('hold-ball-task-1').params.holdLifecycle.managedCommand.invocationId, 'invocation-1'); }); - test('force-queued event carrier is not duplicated and retires only from exact F264 handled truth', async () => { + test('event carrier is not duplicated and retires only from exact F264 handled truth', async () => { const { ManagedCommandWakeRecoverySweep } = await loadSweep(); const h = makeHarness({ triggerOutcomes: ['enqueued', 'enqueued'], eventCarrier: { state: 'missing' } }); const sweep = new ManagedCommandWakeRecoverySweep(h.deps); @@ -482,7 +504,7 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.equal(h.tasks.get('hold-ball-task-1').params.holdLifecycle.managedCommand.invocationId, 'child-exact-1'); }); - test('retries one exact missing-disposition attempt once, then escalates on its failed successor', async () => { + test('consumes a failed wake from its canonical response terminal without manual disposition', async () => { const { ManagedCommandWakeRecoverySweep } = await loadSweep(); const task = makeTask(); task.params.holdLifecycle.managedCommand = { @@ -505,48 +527,22 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { attemptId: 'entry-managed:codex-sol:1', attemptSequence: 1, invocationId: 'invocation-missing-1', - errorCode: 'managed_hold_disposition_missing', + errorCode: 'provider_execution_failed', }, retryEventCarrierOutcomes: ['retried'], }); const sweep = new ManagedCommandWakeRecoverySweep(h.deps); - assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 0, pending: 1 }); - assert.deepEqual(h.retryEventCarrierCalls, [ - { - taskId: task.id, - threadId: 'thread-1', - userId: 'user-1', - catId: 'codex-sol', - messageId: 'message-managed', - attemptId: 'entry-managed:codex-sol:1', - }, - ]); - let managed = h.tasks.get(task.id).params.holdLifecycle.managedCommand; - assert.equal(managed.dispositionRetryCount, 1); - assert.equal(managed.lastDispositionFailedAttemptId, 'entry-managed:codex-sol:1'); - - assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 0, pending: 1 }); - assert.equal(h.retryEventCarrierCalls.length, 1, 'the same failed attempt must not hot-loop'); - - h.setEventCarrier({ - state: 'failed', - attemptId: 'entry-managed:codex-sol:2', - attemptSequence: 2, - invocationId: 'invocation-missing-2', - errorCode: 'managed_hold_disposition_missing', - }); assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 1, pending: 0 }); - const escalated = h.tasks.get(task.id); - managed = escalated.params.holdLifecycle.managedCommand; - assert.equal(escalated.enabled, false); - assert.equal(escalated.params.holdLifecycle.status, 'escalated'); - assert.equal(managed.state, 'escalated'); - assert.equal(managed.dispositionEscalationReason, 'managed_hold_disposition_missing'); - assert.equal(managed.dispositionEscalatedAttemptId, 'entry-managed:codex-sol:2'); - assert.equal(managed.dispositionEscalatedAt, 10_000); + const consumed = h.tasks.get(task.id); + const managed = consumed.params.holdLifecycle.managedCommand; + assert.equal(consumed.enabled, false); + assert.equal(consumed.params.holdLifecycle.status, 'fired'); + assert.equal(managed.state, 'consumed'); + assert.equal(managed.invocationId, 'invocation-missing-1'); + assert.equal(managed.carrierTerminalReason, 'failed'); assert.deepEqual(h.unregistered, [task.id]); - assert.equal(h.retryEventCarrierCalls.length, 1, 'exhaustion escalates instead of adding another attempt'); + assert.equal(h.retryEventCarrierCalls.length, 0, 'a terminal ledger row is never retried in place'); }); test('uses durable Queue attempt sequence when restart loses the task-side retry audit', async () => { @@ -567,7 +563,7 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { attemptId: 'entry-managed:codex-sol:2', attemptSequence: 2, invocationId: 'invocation-missing-2', - errorCode: 'managed_hold_disposition_missing', + errorCode: 'provider_execution_failed', }, retryEventCarrierOutcomes: ['retried'], }); @@ -575,10 +571,10 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 1, pending: 0 }); assert.equal(h.retryEventCarrierCalls.length, 0, 'a durable successor attempt consumes the bounded retry'); - assert.equal(h.tasks.get(task.id).params.holdLifecycle.status, 'escalated'); + assert.equal(h.tasks.get(task.id).params.holdLifecycle.status, 'fired'); }); - test('does not retry a failed managed carrier without the missing-disposition error code', async () => { + test('consumes every failed managed carrier regardless of provider error code', async () => { const { ManagedCommandWakeRecoverySweep } = await loadSweep(); const task = makeTask(); task.params.holdLifecycle.managedCommand = { @@ -602,9 +598,11 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { }); const sweep = new ManagedCommandWakeRecoverySweep(h.deps); - assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 0, pending: 1 }); + assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 1, pending: 0 }); assert.equal(h.retryEventCarrierCalls.length, 0); - assert.equal(h.tasks.get(task.id).enabled, true); + assert.equal(h.tasks.get(task.id).enabled, false); + assert.equal(h.tasks.get(task.id).params.holdLifecycle.status, 'fired'); + assert.equal(h.tasks.get(task.id).params.holdLifecycle.managedCommand.carrierTerminalReason, 'failed'); }); test('terminal F264 carrier retires the managed producer and cannot revive after restart', async () => { @@ -645,99 +643,99 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.equal(h.triggerCalls.length, 0, 'restart must not revive the retired producer'); }); - test('projects canceled, withdrawn, and terminal F264 receipts without crossing source thread', async () => { + test('projects canceled and terminal response bubbles without crossing source scope', async () => { const { resolveManagedCommandWakeEventCarrier } = await loadSweep(); - const expected = { threadId: 'thread-1', catId: 'codex-sol' }; - const custody = { - status: 'queued', - handledByCatIds: [], - pendingTargetCats: ['codex-sol'], - }; + const expected = { threadId: 'thread-1', userId: 'user-1', catId: 'codex-sol' }; + const source = (overrides = {}) => ({ + id: 'message-managed', + threadId: 'thread-1', + userId: 'user-1', + deliveryStatus: 'delivered', + lifecycle: { + kind: 'input', + orderKey: 'message-managed', + dispatchRefs: [ + { + targetId: 'codex-sol', + phase: 'settled', + statusMessageId: 'response-managed', + dispatchedAt: 1_500, + }, + ], + }, + ...overrides, + }); + const response = (status, overrides = {}) => ({ + id: 'response-managed', + threadId: 'thread-1', + userId: 'user-1', + lifecycle: { + kind: 'response', + orderKey: 'response-managed', + invocationId: 'invocation-managed', + targetId: 'codex-sol', + inputEntryIds: ['entry-managed'], + inputMessageIds: ['message-managed'], + status, + startedAt: 1_500, + ...(status === 'processing' ? {} : { completedAt: 2_000 }), + }, + ...overrides, + }); assert.deepEqual( resolveManagedCommandWakeEventCarrier( - { threadId: 'thread-1', userId: 'scheduler', deliveryStatus: 'canceled' }, + source({ deliveryStatus: 'canceled', lifecycle: undefined }), + null, + false, expected, ), { state: 'terminal', reason: 'canceled' }, ); + assert.deepEqual(resolveManagedCommandWakeEventCarrier(source(), response('completed'), false, expected), { + state: 'handled', + invocationId: 'invocation-managed', + }); + assert.deepEqual(resolveManagedCommandWakeEventCarrier(source(), response('interrupted'), false, expected), { + state: 'terminal', + reason: 'terminal', + }); assert.deepEqual( resolveManagedCommandWakeEventCarrier( - { - threadId: 'thread-1', - userId: 'scheduler', - deliveryStatus: 'queued', - queueCustody: { ...custody, withdrawnByCatIds: ['codex-sol'] }, - }, - expected, - ), - { state: 'terminal', reason: 'withdrawn' }, - ); - assert.deepEqual( - resolveManagedCommandWakeEventCarrier( - { - threadId: 'thread-1', - userId: 'scheduler', - deliveryStatus: 'queued', - queueCustody: { ...custody, status: 'terminal', pendingTargetCats: [] }, - }, - expected, - ), - { state: 'terminal', reason: 'terminal' }, - ); - assert.deepEqual( - resolveManagedCommandWakeEventCarrier( - { - threadId: 'thread-foreign', - userId: 'scheduler', - deliveryStatus: 'queued', - queueCustody: { ...custody, status: 'terminal', pendingTargetCats: [] }, - }, + source({ threadId: 'thread-foreign' }), + response('completed'), + false, expected, ), { state: 'missing' }, ); assert.deepEqual( resolveManagedCommandWakeEventCarrier( - { - threadId: 'thread-1', - userId: 'scheduler', + source({ deliveryStatus: 'queued', - queueCustody: { ...custody, entryId: 'entry-old' }, - }, - { ...expected, activeQueueEntryId: null }, + lifecycle: { kind: 'input', orderKey: 'message-managed', dispatchRefs: [] }, + }), + null, + true, + expected, ), - { state: 'orphaned' }, + { state: 'pending' }, ); assert.deepEqual( resolveManagedCommandWakeEventCarrier( - { - threadId: 'thread-1', - userId: 'scheduler', - deliveryStatus: 'queued', - queueCustody: { - ...custody, - entryId: 'entry-failed', - failedByCatIds: ['codex-sol'], - targetAttempts: [ - { - id: 'entry-failed:codex-sol:1', - targetCatId: 'codex-sol', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 2_000, - invocationId: 'invocation-missing-disposition', - terminalReason: 'invocation_failed', - }, - ], + source(), + response('failed', { + lifecycle: { + ...response('failed').lifecycle, + invocationId: 'invocation-missing-disposition', }, - }, - { ...expected, activeQueueEntryId: 'entry-failed' }, + }), + false, + expected, ), { state: 'failed', - attemptId: 'entry-failed:codex-sol:1', + attemptId: 'message-managed:codex-sol:invocation-missing-disposition', attemptSequence: 1, invocationId: 'invocation-missing-disposition', }, @@ -986,4 +984,72 @@ describe('F167 S.1-c ManagedCommandWakeRecoverySweep', () => { assert.equal(firstObservedAt, 10_000); assert.equal(h.tasks.get(task.id).params.holdLifecycle.managedCommand.slaBreachObservedAt, firstObservedAt); }); + + test('startup sweep terminalizes a non-durable command whose process-local runner was lost', async () => { + const { ManagedCommandWakeRecoverySweep } = await loadSweep(); + const h = makeHarness({ isCommandRunnerActive: () => false }); + const sweep = new ManagedCommandWakeRecoverySweep(h.deps); + + const result = await sweep.runOnce(); + + assert.deepEqual(result, { scanned: 1, recovered: 1, pending: 0 }); + const task = h.tasks.get('hold-ball-task-1'); + assert.equal(task.enabled, false); + assert.equal(task.params.holdLifecycle.status, 'fired'); + assert.equal(task.params.holdLifecycle.managedCommand.state, 'consumed'); + assert.equal(task.params.holdLifecycle.managedCommand.carrierTerminalReason, 'failed'); + assert.equal(h.appended.length, 1); + assert.equal(h.appended[0].idempotencyKey, 'hold-ball-lost:hold-ball-task-1'); + assert.equal(h.appended[0].source.meta.phase, 'status'); + assert.match(h.appended[0].content, /服务重启/); + }); + + test('startup sweep retains terminal evidence for a retired non-durable command whose runner was lost', async () => { + const { ManagedCommandWakeRecoverySweep } = await loadSweep(); + const task = makeTask(); + task.enabled = false; + task.params.holdLifecycle.status = 'retired_by_replacement'; + const h = makeHarness({ task, isCommandRunnerActive: () => false }); + const sweep = new ManagedCommandWakeRecoverySweep(h.deps); + + const result = await sweep.runOnce(); + + assert.deepEqual(result, { scanned: 1, recovered: 1, pending: 0 }); + const retired = h.tasks.get(task.id); + assert.equal(retired.enabled, false); + assert.equal(retired.params.holdLifecycle.status, 'retired_by_replacement'); + assert.equal(retired.params.holdLifecycle.managedCommand.state, 'consumed'); + assert.equal(h.appended.length, 1); + assert.equal(h.appended[0].idempotencyKey, `hold-ball-completion:${task.id}`); + assert.match(h.appended[0].content, /执行归属已丢失/); + assert.equal(h.triggerCalls.length, 0, 'retired custody must publish terminal evidence without waking the cat'); + }); + + test('periodic sweep leaves a live process-local command runner alone', async () => { + const { ManagedCommandWakeRecoverySweep } = await loadSweep(); + const h = makeHarness({ isCommandRunnerActive: () => true }); + const sweep = new ManagedCommandWakeRecoverySweep(h.deps); + + assert.deepEqual(await sweep.runOnce(), { scanned: 0, recovered: 0, pending: 0 }); + assert.equal(h.tasks.get('hold-ball-task-1').enabled, true); + assert.equal(h.appended.length, 0); + }); + + test('lost-command status append retries from durable lost state without duplicating History', async () => { + const { ManagedCommandWakeRecoverySweep } = await loadSweep(); + const h = makeHarness({ + appendError: new Error('message plane unavailable'), + isCommandRunnerActive: () => false, + }); + const sweep = new ManagedCommandWakeRecoverySweep(h.deps); + + assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 0, pending: 1 }); + assert.equal(h.tasks.get('hold-ball-task-1').params.holdLifecycle.managedCommand.state, 'lost'); + assert.equal(h.tasks.get('hold-ball-task-1').enabled, true); + + h.setAppendError(null); + assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 1, pending: 0 }); + assert.equal(h.appended.length, 1); + assert.equal(h.appended[0].idempotencyKey, 'hold-ball-lost:hold-ball-task-1'); + }); }); diff --git a/packages/api/test/managed-force-queue-autoexecute.test.js b/packages/api/test/managed-force-queue-autoexecute.test.js deleted file mode 100644 index d2a8da5529..0000000000 --- a/packages/api/test/managed-force-queue-autoexecute.test.js +++ /dev/null @@ -1,563 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - ManagedCommandWakeRecoverySweep, - resolveManagedCommandWakeEventCarrier, -} from '../dist/domains/ball-custody/ManagedCommandWakeRecoverySweep.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; -import { ConnectorInvokeTrigger } from '../dist/infrastructure/email/ConnectorInvokeTrigger.js'; - -const noop = () => {}; -const log = { info: noop, warn: noop, error: noop, debug: noop, trace: noop, fatal: noop }; - -test('managed forceQueue starts the exact connector carrier through the real QueueProcessor', async () => { - const queue = new InvocationQueue(); - const providerStarts = []; - let releaseManaged; - const managedRunning = new Promise((resolve) => { - releaseManaged = resolve; - }); - const invocationTracker = { - start: () => new AbortController(), - startAll: () => new AbortController(), - complete: noop, - completeAll: noop, - has: () => false, - }; - const invocationRecordStore = { - async create() { - return { outcome: 'created', invocationId: 'inv-managed-force-queue' }; - }, - async get() { - return null; - }, - async update() { - return {}; - }, - }; - const router = { - async *routeExecution(...args) { - providerStarts.push(args); - if (args[1] === '[managed wake] command complete') await managedRunning; - yield { type: 'done', catId: 'codex-sol', timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }; - const socketManager = { - broadcastAgentMessage: noop, - broadcastToRoom: noop, - emitToUser: noop, - }; - const messageStore = { - async getById() { - return null; - }, - async getByIdempotencyKey() { - return null; - }, - }; - const processor = new QueueProcessor({ - queue, - invocationTracker, - invocationRecordStore, - router, - socketManager, - messageStore, - log, - }); - const trigger = new ConnectorInvokeTrigger({ - router, - socketManager, - invocationRecordStore, - invocationTracker, - invocationQueue: queue, - queueProcessor: processor, - log, - }); - - const unrelated = queue.enqueue({ - threadId: 'thread-managed-force-queue', - userId: 'user-original', - ownerAuthProvenance: 'strict', - content: 'unrelated automatic work', - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - }); - - const args = [ - 'thread-managed-force-queue', - 'codex-sol', - 'user-original', - '[managed wake] command complete', - 'message-managed-force-queue', - undefined, - { sourceCategory: 'scheduled', forceQueue: true }, - ]; - const outcomes = await Promise.all([trigger.trigger(...args), trigger.trigger(...args)]); - await new Promise((resolve) => setTimeout(resolve, 25)); - - assert.deepEqual(outcomes, ['enqueued', 'enqueued']); - assert.equal( - providerStarts.length, - 1, - `forceQueue must start once; starts=${JSON.stringify(providerStarts.map((args) => args.slice(0, 5)))}`, - ); - const remaining = queue.list('thread-managed-force-queue', 'user-original'); - assert.equal( - remaining.filter((entry) => entry.messageId === 'message-managed-force-queue').length, - 1, - 'concurrent replay must reuse one exact managed carrier', - ); - assert.equal( - remaining.find((entry) => entry.id === unrelated.entry.id)?.status, - 'queued', - 'the forceQueue bypass must not start unrelated automatic work while the managed carrier is running', - ); - - releaseManaged(); - await new Promise((resolve) => setTimeout(resolve, 25)); - assert.equal(providerStarts.length, 2, 'normal queue progression may start unrelated work after managed completion'); -}); - -test('withdrawn managed carrier retires on sweep while a later Continue still starts the provider', async () => { - const threadId = 'thread-terminal-managed-wake'; - const userId = 'user-original'; - const catId = 'codex-sol'; - const queue = new InvocationQueue(); - const messageStore = new MessageStore(); - const oldAdmission = queue.enqueue({ - threadId, - userId, - ownerAuthProvenance: 'strict', - content: '[managed wake] old command complete', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - }); - assert.equal(oldAdmission.outcome, 'enqueued'); - const oldMessage = messageStore.append({ - threadId, - userId: 'scheduler', - catId: null, - content: oldAdmission.entry.content, - mentions: [catId], - timestamp: oldAdmission.entry.createdAt, - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: 'managed wake', - icon: '⏱️', - meta: { wakeWhen: true, taskId: 'managed-task-terminal' }, - }, - }); - queue.backfillMessageId(threadId, userId, oldAdmission.entry.id, oldMessage.id); - const oldCarrier = queue.getEntrySnapshot(threadId, userId, oldAdmission.entry.id); - assert.ok(oldCarrier); - assert.equal( - messageStore.initializeQueueCustody(oldMessage.id, createInitialQueuedMessageCustody(oldCarrier)).kind, - 'initialized', - ); - assert.ok(queue.remove(threadId, userId, oldCarrier.id)); - const custodyCoordinator = new QueuedMessageCustodyCoordinator({ - messageStore, - now: () => oldCarrier.createdAt + 100, - }); - assert.equal(await custodyCoordinator.withdrawEntry(oldCarrier), true); - assert.equal(messageStore.getById(oldMessage.id)?.queueCustody?.status, 'terminal'); - - const task = { - id: 'managed-task-terminal', - templateId: 'reminder', - trigger: { type: 'once', fireAt: 99_000 }, - params: { - message: 'fallback', - targetCatId: catId, - triggerUserId: userId, - holdLifecycle: { - mode: 'wake_when', - status: 'active', - wakeAt: 99_000, - createdBy: `hold-ball:${catId}`, - managedCommand: { - state: 'enqueued', - command: 'pnpm gate', - startedAt: 1_000, - conditionMetAt: 8_000, - wakeContent: 'old command complete', - messageId: oldMessage.id, - messageWrittenAt: 8_100, - dispatchAttemptCount: 1, - lastDispatchAt: 8_200, - lastDispatchOutcome: 'enqueued', - }, - }, - }, - display: { label: 'hold', category: 'system', description: 'hold' }, - deliveryThreadId: threadId, - enabled: true, - createdBy: `hold-ball:${catId}`, - createdAt: new Date(1_000).toISOString(), - }; - const tasks = new Map([[task.id, task]]); - const taskStore = { - getAll: () => [...tasks.values()], - getById: (id) => tasks.get(id) ?? null, - updateParamsIfCurrent(id, expected, params) { - const current = tasks.get(id); - if (!current || current.params !== expected) return false; - tasks.set(id, { ...current, params }); - return true; - }, - setEnabled(id, enabled) { - const current = tasks.get(id); - if (!current) return false; - tasks.set(id, { ...current, enabled }); - return true; - }, - }; - - const providerStarts = []; - const invocationTracker = { - start: () => new AbortController(), - startAll: () => new AbortController(), - complete: noop, - completeAll: noop, - has: () => false, - }; - let invocationSequence = 0; - const invocationRecordStore = { - async create() { - invocationSequence += 1; - return { outcome: 'created', invocationId: `inv-terminal-recovery-${invocationSequence}` }; - }, - async get() { - return null; - }, - async update() { - return {}; - }, - async getByIdempotencyKey() { - return null; - }, - }; - const router = { - async *routeExecution(...args) { - providerStarts.push(args); - yield { type: 'done', catId, timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }; - const socketManager = { - broadcastAgentMessage: noop, - broadcastToRoom: noop, - emitToUser: noop, - }; - const processor = new QueueProcessor({ - queue, - invocationTracker, - invocationRecordStore, - router, - socketManager, - messageStore, - queueCustodyCoordinator: custodyCoordinator, - log, - }); - const trigger = new ConnectorInvokeTrigger({ - router, - socketManager, - invocationRecordStore, - invocationTracker, - invocationQueue: queue, - queueProcessor: processor, - messageStore, - log, - }); - const recoveryDeps = { - dynamicTaskStore: taskStore, - messageStore, - socketManager, - taskRunner: { unregister: noop }, - invocationRecordStore, - getInvokeTrigger: () => trigger, - getEventCarrier: async ({ - threadId: expectedThreadId, - userId: expectedUserId, - catId: expectedCatId, - messageId, - }) => { - const message = await messageStore.getById(messageId); - const entryId = message?.queueCustody?.entryId; - return resolveManagedCommandWakeEventCarrier(message, { - threadId: expectedThreadId, - catId: expectedCatId, - activeQueueEntryId: entryId - ? (queue.getEntrySnapshot(expectedThreadId, expectedUserId, entryId)?.id ?? null) - : null, - }); - }, - now: () => 10_000, - dispatchedCarrierGraceMs: 1, - }; - - const sweep = new ManagedCommandWakeRecoverySweep(recoveryDeps); - assert.deepEqual(await sweep.runOnce(), { scanned: 1, recovered: 1, pending: 0 }); - assert.equal(tasks.get(task.id).enabled, false); - assert.equal(tasks.get(task.id).params.holdLifecycle.managedCommand.carrierTerminalReason, 'withdrawn'); - assert.equal( - queue.findEntryWithMessageId(threadId, oldMessage.id), - null, - 'terminal source must not mint a successor', - ); - assert.equal(providerStarts.length, 0, 'terminal recovery must not call the provider'); - - assert.equal( - await trigger.trigger(threadId, catId, userId, 'Continue', 'message-continue-after-terminal', undefined, { - sourceCategory: 'scheduled', - forceQueue: true, - }), - 'enqueued', - ); - await new Promise((resolve) => setTimeout(resolve, 25)); - assert.equal(providerStarts.length, 1); - assert.equal(providerStarts[0][1], 'Continue'); - - const restartedSweep = new ManagedCommandWakeRecoverySweep(recoveryDeps); - assert.deepEqual(await restartedSweep.runOnce(), { scanned: 0, recovered: 0, pending: 0 }); - await new Promise((resolve) => setTimeout(resolve, 25)); - assert.equal(providerStarts.length, 1, 'restart cannot revive the terminal managed producer'); -}); - -test('managed recovery rebinds stale custody once, starts one provider, and stays callable without restart', async () => { - const threadId = 'thread-stale-managed-custody'; - const userId = 'user-original'; - const catId = 'codex-sol'; - const queue = new InvocationQueue(); - const messageStore = new MessageStore(); - const oldEntry = queue.enqueue({ - threadId, - userId, - ownerAuthProvenance: 'strict', - content: '[managed wake] recovered command', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - }).entry; - const message = messageStore.append({ - threadId, - userId: 'scheduler', - catId: null, - content: oldEntry.content, - mentions: [catId], - timestamp: oldEntry.createdAt, - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: 'managed wake', - icon: '⏱️', - meta: { wakeWhen: true, taskId: 'managed-task-stale-custody' }, - }, - }); - queue.backfillMessageId(threadId, userId, oldEntry.id, message.id); - const boundOld = queue.getEntrySnapshot(threadId, userId, oldEntry.id); - assert.equal( - messageStore.initializeQueueCustody(message.id, createInitialQueuedMessageCustody(boundOld)).kind, - 'initialized', - ); - assert.ok(queue.remove(threadId, userId, oldEntry.id), 'simulate the retired in-memory row'); - - const task = { - id: 'managed-task-stale-custody', - templateId: 'reminder', - trigger: { type: 'once', fireAt: 99_000 }, - params: { - message: 'fallback', - targetCatId: catId, - triggerUserId: userId, - holdLifecycle: { - mode: 'wake_when', - status: 'active', - wakeAt: 99_000, - createdBy: `hold-ball:${catId}`, - managedCommand: { - state: 'enqueued', - command: 'pnpm gate', - startedAt: 1_000, - conditionMetAt: 8_000, - wakeContent: 'recovered command', - messageId: message.id, - messageWrittenAt: 8_100, - dispatchAttemptCount: 1, - lastDispatchAt: 8_200, - lastDispatchOutcome: 'enqueued', - }, - }, - }, - display: { label: 'hold', category: 'system', description: 'hold' }, - deliveryThreadId: threadId, - enabled: true, - createdBy: `hold-ball:${catId}`, - createdAt: new Date(1_000).toISOString(), - }; - const tasks = new Map([[task.id, task]]); - const taskStore = { - getAll: () => [...tasks.values()], - getById: (id) => tasks.get(id) ?? null, - updateParamsIfCurrent(id, expected, params) { - const current = tasks.get(id); - if (!current || current.params !== expected) return false; - tasks.set(id, { ...current, params }); - return true; - }, - setEnabled(id, enabled) { - const current = tasks.get(id); - if (!current) return false; - tasks.set(id, { ...current, enabled }); - return true; - }, - }; - const providerStarts = []; - const invocationTracker = { - start: () => new AbortController(), - startAll: () => new AbortController(), - complete: noop, - completeAll: noop, - has: () => false, - }; - let invocationSequence = 0; - const records = new Map(); - const invocationRecordStore = { - async create(input) { - const key = input.idempotencyKey; - const existing = records.get(key); - if (existing) return { outcome: 'duplicate', invocationId: existing.id }; - invocationSequence += 1; - const record = { ...input, id: `inv-stale-${invocationSequence}`, status: 'queued', userMessageId: null }; - records.set(key, record); - return { outcome: 'created', invocationId: record.id }; - }, - async get(id) { - return [...records.values()].find((record) => record.id === id) ?? null; - }, - async update(id, patch) { - const record = [...records.values()].find((candidate) => candidate.id === id); - if (!record) return null; - if (patch.expectedStatus && record.status !== patch.expectedStatus) return null; - Object.assign(record, patch); - delete record.expectedStatus; - return record; - }, - async getByIdempotencyKey(_threadId, _userId, key) { - return records.get(key) ?? null; - }, - }; - const router = { - async *routeExecution(...args) { - providerStarts.push(args); - const childInvocationId = `child-stale-${providerStarts.length}`; - await args[6].onPromptMessagesExposed({ - threadId, - userId, - catId, - invocationId: childInvocationId, - messageIds: [args[3]], - seenAt: Date.now(), - }); - yield { - type: 'done', - catId, - invocationId: childInvocationId, - turnCustodyTerminalWitness: { - kind: 'managed_hold_continued', - sourceMessageId: args[3], - taskId: task.id, - transition: 'reheld', - }, - timestamp: Date.now(), - }; - }, - async ackCollectedCursors() {}, - }; - const socketManager = { broadcastAgentMessage: noop, broadcastToRoom: noop, emitToUser: noop }; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - const processor = new QueueProcessor({ - queue, - invocationTracker, - invocationRecordStore, - router, - socketManager, - messageStore, - queueCustodyCoordinator: coordinator, - log, - }); - const trigger = new ConnectorInvokeTrigger({ - router, - socketManager, - invocationRecordStore, - invocationTracker, - invocationQueue: queue, - queueProcessor: processor, - queueCustodyCoordinator: coordinator, - messageStore, - log, - }); - const sweep = new ManagedCommandWakeRecoverySweep({ - dynamicTaskStore: taskStore, - messageStore, - socketManager, - taskRunner: { unregister: noop }, - invocationRecordStore, - getInvokeTrigger: () => trigger, - getEventCarrier: async ({ - threadId: expectedThreadId, - userId: expectedUserId, - catId: expectedCatId, - messageId, - }) => { - const message = await messageStore.getById(messageId); - const entryId = message?.queueCustody?.entryId; - return resolveManagedCommandWakeEventCarrier(message, { - threadId: expectedThreadId, - catId: expectedCatId, - activeQueueEntryId: entryId - ? (queue.getEntrySnapshot(expectedThreadId, expectedUserId, entryId)?.id ?? null) - : null, - }); - }, - now: () => 10_000, - dispatchedCarrierGraceMs: 1, - }); - - const concurrentRecovery = await Promise.all([sweep.runOnce(), sweep.runOnce()]); - assert.ok(concurrentRecovery.every((result) => result.scanned === 1)); - await new Promise((resolve) => setTimeout(resolve, 100)); - assert.equal(providerStarts.length, 1, 'the repaired carrier must reach one real provider start'); - const live = queue.findEntryWithMessageId(threadId, message.id); - const rebound = messageStore.getById(message.id); - assert.equal(rebound.queueCustody.status, 'terminal'); - assert.deepEqual(rebound.queueCustody.handledByCatIds, [catId]); - assert.equal(live, null, 'the successful replacement must be consumed exactly once'); - - await sweep.runOnce(); - await sweep.runOnce(); - await new Promise((resolve) => setTimeout(resolve, 30)); - assert.equal(providerStarts.length, 1, 'periodic recovery must not replay after terminal settlement'); - - await trigger.trigger(threadId, catId, userId, 'Continue', 'message-after-repair', undefined, { - sourceCategory: 'scheduled', - forceQueue: true, - }); - await new Promise((resolve) => setTimeout(resolve, 30)); - assert.equal(providerStarts.length, 2, 'the same runtime remains callable after repair'); -}); diff --git a/packages/api/test/managed-hold-disposition.test.js b/packages/api/test/managed-hold-disposition.test.js deleted file mode 100644 index 9af4b9527c..0000000000 --- a/packages/api/test/managed-hold-disposition.test.js +++ /dev/null @@ -1,1549 +0,0 @@ -/** - * F167 × F254 — exact managed-hold disposition. - * - * Uses the real event log/projector/service and real Queue receipt coordinator; - * no sequence-shaped fake projection is allowed in this regression. - */ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { createCatId } from '@cat-cafe/shared'; -import { BallCustodyIngest } from '../dist/domains/ball-custody/BallCustodyIngest.js'; -import { BallCustodyProjector } from '../dist/domains/ball-custody/BallCustodyProjector.js'; -import { - buildHandedEvent, - buildHeldEvent, - buildInvocationHeartbeatEvent, - buildWakeConditionMetEvent, -} from '../dist/domains/ball-custody/ball-custody-events.js'; -import { - ManagedHoldDispositionError, - ManagedHoldDispositionService, -} from '../dist/domains/ball-custody/ManagedHoldDispositionService.js'; -import { ManagedHoldReceiptService } from '../dist/domains/ball-custody/ManagedHoldReceiptService.js'; -import { TurnCustodyProjectionService } from '../dist/domains/ball-custody/TurnCustodyProjectionService.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; - -class MemoryEventLog { - events = []; - failNextDispositionAppend = false; - async append(event) { - if (event.kind === 'ball.hold_dispositioned' && this.failNextDispositionAppend) { - this.failNextDispositionAppend = false; - throw new Error('event append failed'); - } - if (this.events.some((candidate) => candidate.sourceEventId === event.sourceEventId)) { - return { appended: false, sequence: -1 }; - } - this.events.push(structuredClone(event)); - return { appended: true, sequence: this.events.length - 1 }; - } - async appendFenced(event, expectedSequence) { - if (event.kind === 'ball.hold_dispositioned' && this.failNextDispositionAppend) { - this.failNextDispositionAppend = false; - throw new Error('event append failed'); - } - if (this.events.some((candidate) => candidate.sourceEventId === event.sourceEventId)) { - return { outcome: 'duplicate' }; - } - const actualSequence = this.events.filter((candidate) => candidate.subjectKey === event.subjectKey).length; - if (actualSequence !== expectedSequence) { - return { outcome: 'conflict', actualSequence }; - } - this.events.push(structuredClone(event)); - return { outcome: 'appended', sequence: expectedSequence }; - } - async read(subjectKey, fromSequence = 0) { - return this.events.filter((event) => event.subjectKey === subjectKey).slice(fromSequence); - } - async listSubjects() { - return [...new Set(this.events.map((event) => event.subjectKey))]; - } -} - -class MemoryProjectionStore { - projections = new Map(); - failNextResolvedSave = false; - async get(subjectKey) { - return structuredClone(this.projections.get(subjectKey) ?? null); - } - async save(projection) { - if (projection.state === 'resolved' && this.failNextResolvedSave) { - this.failNextResolvedSave = false; - throw new Error('projection write failed'); - } - this.projections.set(projection.subjectKey, structuredClone(projection)); - } - async listSubjectKeys() { - return [...this.projections.keys()]; - } - async delete(subjectKey) { - this.projections.delete(subjectKey); - } -} - -function managedTask(overrides = {}) { - return { - id: 'task-1', - templateId: 'reminder', - trigger: { type: 'once', fireAt: 99_000 }, - params: { - message: 'fallback', - targetCatId: 'codex-sol', - triggerUserId: 'user-1', - holdLifecycle: { - mode: 'wake_when', - status: 'active', - wakeAt: 99_000, - managedCommand: { - state: 'enqueued', - command: 'pnpm test', - startedAt: 1_000, - conditionMetAt: 2_000, - wakeContent: 'tests passed', - result: { exitCode: 0, timedOut: false, durationMs: 1_000 }, - messageId: 'message-1', - messageWrittenAt: 2_100, - }, - }, - }, - display: { label: 'hold', category: 'system', description: 'hold' }, - deliveryThreadId: 'thread-1', - enabled: true, - createdBy: 'hold-ball:codex-sol', - createdAt: new Date(1_000).toISOString(), - ...overrides, - }; -} - -async function harness({ - failDispositionAppendOnce = false, - failDispositionProjectionOnce = false, - beforeDispositionRecord, - beforeLatestCheck, - invocationRegistry, -} = {}) { - const now = Date.now() + 1_000; - const eventLog = new MemoryEventLog(); - const projectionStore = new MemoryProjectionStore(); - const projector = new BallCustodyProjector(eventLog, projectionStore); - const ingest = new BallCustodyIngest(eventLog, projector); - await ingest.record(buildHeldEvent({ threadId: 'thread-1', catId: 'codex-sol', fireAt: 99_000, at: 1_000 })); - await ingest.record( - buildWakeConditionMetEvent({ - threadId: 'thread-1', - catId: 'codex-sol', - taskId: 'task-1', - command: 'pnpm test', - exitCode: 0, - timedOut: false, - durationMs: 1_000, - at: 2_000, - }), - ); - eventLog.failNextDispositionAppend = failDispositionAppendOnce; - projectionStore.failNextResolvedSave = failDispositionProjectionOnce; - - const messageStore = new MessageStore(); - const queue = new InvocationQueue(); - const enqueue = queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - ownerAuthProvenance: 'unknown', - content: '[定时任务] tests passed', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: ['codex-sol'], - intent: 'execute', - priority: 'normal', - }); - assert.ok(enqueue.entry); - const stored = messageStore.append({ - id: 'ignored-by-store', - userId: 'scheduler', - catId: null, - content: '[定时任务] tests passed', - mentions: [], - timestamp: 2_100, - threadId: 'thread-1', - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-1', threadId: 'thread-1', catId: 'codex-sol', wakeWhen: true }, - }, - }); - // route-serial persists this exact receiver-boundary handoff before the - // managed wake invocation can call the disposition producer. - await ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - toCatId: 'codex-sol', - messageId: stored.id, - at: 2_200, - }), - ); - // The production id is server-minted; bind every exact source below to it. - const task = managedTask(); - task.params.holdLifecycle.managedCommand.messageId = stored.id; - queue.backfillMessageId('thread-1', 'user-1', enqueue.entry.id, stored.id); - const queued = queue.getEntrySnapshot('thread-1', 'user-1', enqueue.entry.id); - messageStore.initializeQueueCustody(stored.id, createInitialQueuedMessageCustody(queued)); - const processing = queue.markProcessing('thread-1', 'user-1'); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore, now: () => now }); - await coordinator.persistEntry(queue.getEntrySnapshot('thread-1', 'user-1', processing.id)); - queue.markProcessingSeen('thread-1', 'user-1', processing.id, ['codex-sol'], 'inv-1', 3_000); - await coordinator.persistEntry(queue.getEntrySnapshot('thread-1', 'user-1', processing.id)); - - const warnings = []; - const tasks = new Map([['task-1', task]]); - let latest = true; - const receiptService = new ManagedHoldReceiptService({ queue, messageStore, coordinator, now: () => now }); - const fencedIngest = beforeDispositionRecord - ? { - record: (event) => ingest.record(event), - async recordFenced(event, expectedSequence) { - if (event.kind === 'ball.hold_dispositioned') { - await beforeDispositionRecord({ event, ingest }); - } - return ingest.recordFenced(event, expectedSequence); - }, - } - : ingest; - const service = new ManagedHoldDispositionService({ - registry: invocationRegistry ?? { - isLatest: async () => { - await beforeLatestCheck?.(); - return latest; - }, - }, - dynamicTaskStore: { getById: (id) => tasks.get(id) ?? null }, - messageStore, - ballCustodyEventLog: eventLog, - ballCustodyProjectionStore: projectionStore, - ballCustody: fencedIngest, - receiptService, - log: { warn: (fields) => warnings.push(fields) }, - repairProjection: (subjectKey) => projector.rebuild(subjectKey), - now: () => now, - }); - return { - service, - warnings, - eventLog, - projectionStore, - queue, - coordinator, - ingest, - messageStore, - task, - tasks, - stored, - setLatest(value) { - latest = value; - }, - }; -} - -async function enqueueManagedWake(h, { taskId, invocationId, fireAt, at }) { - const command = `pnpm test:${taskId}`; - const enqueue = h.queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - ownerAuthProvenance: 'unknown', - content: `[定时任务] ${taskId} passed`, - source: 'connector', - sourceCategory: 'scheduled', - targetCats: ['codex-sol'], - intent: 'execute', - priority: 'normal', - }); - assert.ok(enqueue.entry); - const stored = h.messageStore.append({ - id: 'ignored-by-store', - userId: 'scheduler', - catId: null, - content: `[定时任务] ${taskId} passed`, - mentions: [], - timestamp: at + 100, - threadId: 'thread-1', - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId, threadId: 'thread-1', catId: 'codex-sol', wakeWhen: true }, - }, - }); - const task = managedTask({ id: taskId, trigger: { type: 'once', fireAt } }); - task.params.holdLifecycle.wakeAt = fireAt; - task.params.holdLifecycle.managedCommand = { - state: 'enqueued', - command, - startedAt: at - 1_000, - conditionMetAt: at, - wakeContent: `${taskId} passed`, - result: { exitCode: 0, timedOut: false, durationMs: 1_000 }, - messageId: stored.id, - messageWrittenAt: at + 100, - }; - h.tasks.set(taskId, task); - h.queue.backfillMessageId('thread-1', 'user-1', enqueue.entry.id, stored.id); - const queued = h.queue.getEntrySnapshot('thread-1', 'user-1', enqueue.entry.id); - h.messageStore.initializeQueueCustody(stored.id, createInitialQueuedMessageCustody(queued)); - const processing = h.queue.markProcessing('thread-1', 'user-1'); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', processing.id)); - h.queue.markProcessingSeen('thread-1', 'user-1', processing.id, ['codex-sol'], invocationId, at + 200); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', processing.id)); - - await h.ingest.record(buildHeldEvent({ threadId: 'thread-1', catId: 'codex-sol', fireAt, at })); - await h.ingest.record( - buildWakeConditionMetEvent({ - threadId: 'thread-1', - catId: 'codex-sol', - taskId, - command, - exitCode: 0, - timedOut: false, - durationMs: 1_000, - at: at + 1, - }), - ); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - toCatId: 'codex-sol', - messageId: stored.id, - at: at + 2, - }), - ); - return { stored, task }; -} - -function auth(h, overrides = {}) { - return { - invocationId: 'inv-1', - callbackToken: 'token', - userId: 'user-1', - ownerAuthProvenance: 'unknown', - catId: createCatId('codex-sol'), - threadId: 'thread-1', - originTriggerMessageId: h.stored.id, - clientMessageIds: new Set(), - createdAt: 1, - expiresAt: 99_000, - ...overrides, - }; -} - -async function adoptForUserTurn(t, h, wakes = [h.stored]) { - const { turnCustodyAdoptionRegistry } = await import('../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js'); - const source = h.messageStore.append({ - userId: 'user-1', - catId: null, - threadId: 'thread-1', - content: 'Continue the current work', - mentions: [], - timestamp: 1500, - }); - const caller = auth(h, { originTriggerMessageId: source.id }); - t.after(turnCustodyAdoptionRegistry.register(caller.invocationId, async () => {})); - await turnCustodyAdoptionRegistry.adopt( - caller.invocationId, - wakes.map((message) => ({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: message.id, - taskId: message.source.meta.taskId, - })), - ); - return { caller, bridge: turnCustodyAdoptionRegistry }; -} - -describe('F167 × F254 managed hold disposition', () => { - for (const source of ['primary', 'adopted']) { - test(`#1371 ${source} completion survives one heartbeat CAS race`, async (t) => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - if (attempts === 1) - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: 'inv-1', - catId: 'codex-sol', - draftUpdatedAt: 2_500, - }), - ); - }, - }); - const caller = source === 'primary' ? auth(h) : (await adoptForUserTurn(t, h, [h.stored])).caller; - const result = await h.service.complete(caller, 'handled'); - assert.equal(result.outcome, 'applied'); - assert.equal(result.sourceMessageId, h.stored.id); - assert.equal(result.retired, false); - assert.equal(attempts, 2); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'resolved'); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - assert.equal(h.eventLog.events.filter((e) => e.kind === 'ball.hold_dispositioned').length, 1); - assert.equal((await h.service.complete(caller, 'handled')).outcome, 'replayed'); - assert.equal(attempts, 2); - }); - } - - test('#1371 production wires managed conflict diagnostics to the runtime logger', async () => { - const { readFileSync } = await import('node:fs'); - const index = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8'); - const producer = index.match( - /managedHoldDispositionService = new ManagedHoldDispositionService\(\{([\s\S]*?)^ {4}\}\);/m, - )?.[1]; - assert.ok(producer); - assert.match(producer, /log:\s*app\.log,/); - }); - - test('#1371 retry never switches to another receipt repair candidate', async (t) => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ event, ingest }) => { - if (event.payload.taskId !== 'task-2') return; - attempts += 1; - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: 'inv-1', - catId: 'codex-sol', - draftUpdatedAt: 4_500, - }), - ); - // A changed receipt read must not redirect an in-flight completion to the - // primary source that has a durable terminal awaiting receipt repair. - h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds = []; - }, - }); - await h.service.complete(auth(h), 'handled'); - const next = await enqueueManagedWake(h, { taskId: 'task-2', invocationId: 'inv-1', fireAt: 101000, at: 4000 }); - const { caller } = await adoptForUserTurn(t, h, [next.stored]); - caller.originTriggerMessageId = h.stored.id; - await assert.rejects(() => h.service.complete(caller, 'handled'), /managed_hold_disposition_source_mismatch/); - assert.equal(attempts, 1); - assert.equal(h.eventLog.events.filter((e) => e.kind === 'ball.hold_dispositioned').length, 1); - assert.deepEqual(h.messageStore.getById(next.stored.id).queueCustody.handledByCatIds, []); - }); - - test('#1371 sustained heartbeat contention fails closed after two attempts', async () => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: 'inv-1', - catId: 'codex-sol', - draftUpdatedAt: 2_500 + ++attempts, - }), - ); - }, - }); - await assert.rejects(() => h.service.complete(auth(h), 'handled'), /managed_hold_disposition_fence_conflict/); - assert.equal(attempts, 2); - assert.equal(h.warnings.length, 2); - assert.equal(h.eventLog.events.filter((e) => e.kind === 'ball.hold_dispositioned').length, 0); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - }); - - for (const change of ['latest', 'withdrawn', 'owner']) { - test(`#1371 heartbeat retry revalidates ${change} authority`, async () => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: 'inv-1', - catId: 'codex-sol', - draftUpdatedAt: 2_500, - }), - ); - if (change === 'latest') h.setLatest(false); - if (change === 'withdrawn') h.messageStore.getById(h.stored.id).deliveryStatus = 'canceled'; - if (change === 'owner') h.task.params.triggerUserId = 'foreign-owner'; - }, - }); - const error = - change === 'latest' ? 'stale_invocation' : change === 'withdrawn' ? 'no_obligation' : 'task_mismatch'; - await assert.rejects( - () => h.service.complete(auth(h), 'handled'), - new RegExp(`managed_hold_disposition_${error}`), - ); - assert.equal(attempts, 1); - assert.equal(h.eventLog.events.filter((e) => e.kind === 'ball.hold_dispositioned').length, 0); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - }); - } - - test('#1371 conflict diagnostics are bounded and exclude event payloads', async () => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - if (++attempts !== 1) return; - for (let n = 0; n < 12; n += 1) - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: 'inv-1', - catId: 'codex-sol', - draftUpdatedAt: 2_500 + n, - }), - ); - }, - }); - await h.service.complete(auth(h), 'handled'); - assert.equal(h.warnings.length, 1); - const warning = h.warnings[0]; - assert.equal(warning.expectedSequence, 3); - assert.equal(warning.actualSequence, 15); - assert.equal(warning.heartbeatOnly, true); - assert.equal(warning.interveningEvents.length, 8); - assert.equal(warning.omittedEventCount, 4); - assert.ok(warning.interveningEvents.every((e) => Object.keys(e).sort().join() === 'at,kind,sourceEventId')); - }); - - test('#1371 heartbeat mixed with handoff does not grant a retry', async () => { - let attempts = 0; - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - attempts += 1; - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: 'inv-1', - catId: 'codex-sol', - draftUpdatedAt: 2_500, - }), - ); - await ingest.record( - buildHandedEvent({ threadId: 'thread-1', toCatId: 'opus', messageId: 'successor', at: 2_501 }), - ); - }, - }); - await assert.rejects(() => h.service.complete(auth(h), 'handled'), /managed_hold_disposition_fence_conflict/); - assert.equal(attempts, 1); - assert.equal(h.warnings[0].heartbeatOnly, false); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).holder, 'opus'); - assert.equal(h.eventLog.events.filter((e) => e.kind === 'ball.hold_dispositioned').length, 0); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - }); - - for (const historyState of ['settled', 'withdrawn', 'detached']) { - for (const origin of ['managed', 'user']) { - test(`#1371 ${historyState} hold history cannot poison the next ${origin} turn's exact completion`, async (t) => { - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { InvocationTracker } = await import( - '../dist/domains/cats/services/agents/invocation/InvocationTracker.js' - ); - const { InvocationRecordStore } = await import( - '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js' - ); - const { turnCustodyAdoptionRegistry } = await import( - '../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js' - ); - const h = await harness(); - if (historyState === 'settled') { - await h.service.complete(auth(h), 'handled'); - } else { - const oldEntry = h.queue.list('thread-1', 'user-1')[0]; - if (historyState === 'withdrawn') await h.coordinator.withdrawEntry(oldEntry); - h.queue.remove('thread-1', 'user-1', oldEntry.id); - } - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - const priorCustody = structuredClone(h.messageStore.getById(h.stored.id).queueCustody); - const invocationId = 'inv-after-settled-history'; - const next = await enqueueManagedWake(h, { taskId: 'task-2', invocationId, fireAt: 101000, at: 4000 }); - const userSource = h.messageStore.append({ - userId: 'user-1', - catId: null, - threadId: 'thread-1', - content: 'Finish the new result', - mentions: [], - timestamp: 4300, - }); - const caller = auth(h, { - invocationId, - originTriggerMessageId: origin === 'managed' ? next.stored.id : userSource.id, - }); - const projections = new TurnCustodyProjectionService({ - ballCustodyEventLog: h.eventLog, - ballCustodyProjectionStore: h.projectionStore, - }); - const baselines = []; - t.after( - turnCustodyAdoptionRegistry.register(invocationId, async (wakes) => { - for (const wake of wakes) baselines.push(await projections.open(wake)); - }), - ); - const processor = new QueueProcessor({ - queue: h.queue, - messageStore: h.messageStore, - queueCustodyCoordinator: h.coordinator, - invocationTracker: new InvocationTracker(), - invocationRecordStore: new InvocationRecordStore(), - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }, - log: { info() {}, warn() {}, error() {} }, - router: { - routeExecution() { - assert.fail('prompt exposure must not start a provider'); - }, - }, - }); - // A real prompt contains both readable history and the new command body. - // Only the latter has a live Queue carrier and this child's receipt. - const wakes = await processor.markPromptMessagesSeen({ - threadId: 'thread-1', - userId: 'user-1', - catId: 'codex-sol', - invocationId, - messageIds: [h.stored.id, next.stored.id], - seenAt: 4500, - }); - await turnCustodyAdoptionRegistry.adopt(invocationId, wakes); - const result = await h.service.complete(caller, 'completed'); - assert.equal(result.sourceMessageId, next.stored.id); - assert.deepEqual( - wakes.map((wake) => wake.sourceMessageId), - [next.stored.id], - ); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody, priorCustody); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - assert.equal((await projections.close(baselines[0])).shouldBlock, false); - assert.equal( - h.eventLog.events.filter( - (event) => event.kind === 'ball.hold_dispositioned' && event.payload.sourceMessageId === next.stored.id, - ).length, - 1, - ); - }); - } - } - - for (const readSurface of ['drill', 'window']) { - test(`#1371 ${readSurface} guidance and completion agree on a pending primary receipt repair`, async (t) => { - const { default: Fastify } = await import('fastify'); - const { InvocationRegistry } = await import( - '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' - ); - const { InvocationTracker } = await import( - '../dist/domains/cats/services/agents/invocation/InvocationTracker.js' - ); - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { InvocationRecordStore } = await import( - '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js' - ); - const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); - const { InMemoryTurnExecutionStore } = await import( - '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' - ); - const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); - const { turnCustodyAdoptionRegistry } = await import( - '../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js' - ); - const registry = new InvocationRegistry(); - const h = await harness({ invocationRegistry: registry }); - const credentials = await registry.create( - 'user-1', - 'codex-sol', - 'thread-1', - undefined, - undefined, - undefined, - h.stored.id, - ); - const headers = { 'x-invocation-id': credentials.invocationId, 'x-callback-token': credentials.callbackToken }; - const turnExecutionStore = new InMemoryTurnExecutionStore(); - await turnExecutionStore.createRunning({ - invocationId: credentials.invocationId, - parentInvocationId: credentials.invocationId, - threadId: 'thread-1', - userId: 'user-1', - catId: 'codex-sol', - executionKind: 'ordinary', - startedAt: Date.now(), - }); - const primaryEntry = h.queue.list('thread-1', 'user-1')[0]; - h.queue.markProcessingSeen( - 'thread-1', - 'user-1', - primaryEntry.id, - ['codex-sol'], - credentials.invocationId, - Date.now(), - ); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', primaryEntry.id)); - t.after(turnCustodyAdoptionRegistry.register(credentials.invocationId, async () => {})); - const commitReceipt = h.coordinator.commitSuccessfulTargetForMessage.bind(h.coordinator); - let failReceipt = true; - h.coordinator.commitSuccessfulTargetForMessage = async (...args) => { - if (failReceipt) { - failReceipt = false; - throw new Error('receipt storage unavailable'); - } - return commitReceipt(...args); - }; - const app = Fastify(); - t.after(() => app.close()); - const socketManager = { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }; - const queueProcessor = new QueueProcessor({ - queue: h.queue, - messageStore: h.messageStore, - queueCustodyCoordinator: h.coordinator, - invocationTracker: new InvocationTracker(), - invocationRecordStore: new InvocationRecordStore(), - turnExecutionStore, - socketManager, - log: app.log, - router: { - async *routeExecution() { - assert.fail('reading guidance must not start a provider'); - }, - }, - }); - await app.register(callbacksRoutes, { - registry, - messageStore: h.messageStore, - threadStore: new ThreadStore(), - socketManager, - invocationQueue: h.queue, - queueCustodyCoordinator: h.coordinator, - queueProcessor, - turnExecutionStore, - holdBallDeps: { registry, managedHoldDispositionService: h.service }, - evidenceStore: { - async search() { - return []; - }, - }, - reflectionService: {}, - markerQueue: {}, - }); - await app.listen({ host: '127.0.0.1', port: 0 }); - const baseUrl = `http://127.0.0.1:${app.server.address().port}`; - const complete = () => - fetch(`${baseUrl}/api/callbacks/complete-managed-hold`, { - method: 'POST', - headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify({ disposition: 'handled' }), - }); - const first = await complete(); - assert.equal(first.status, 500, await first.text()); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 1); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - const next = await enqueueManagedWake(h, { - taskId: 'task-next', - invocationId: 'before-current-read', - fireAt: 101000, - at: 4000, - }); - const nextEntry = h.queue.list('thread-1', 'user-1').find((entry) => entry.messageId === next.stored.id); - assert.equal(h.queue.rollbackProcessing('thread-1', nextEntry.id), true); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', nextEntry.id)); - const readUrl = - readSurface === 'drill' - ? `${baseUrl}/api/callbacks/get-message?messageId=${next.stored.id}&mode=full&originTriggerMessageId=${next.stored.id}` - : `${baseUrl}/api/callbacks/thread-context?limit=1&responseMode=full&originTriggerMessageId=${next.stored.id}`; - const read = await fetch(readUrl, { headers }); - const guidance = await read.json(); - assert.equal(read.status, 200, JSON.stringify(guidance)); - assert.equal(JSON.stringify(guidance).includes(credentials.callbackToken), false); - if (readSurface === 'drill') assert.equal(guidance.message.id, next.stored.id); - else - assert.deepEqual( - guidance.messages.map((message) => message.id), - [next.stored.id], - ); - assert.deepEqual( - guidance.managedHoldDisposition.candidates, - [{ sourceMessageId: h.stored.id, taskId: 'task-1' }], - 'the same authenticated primary receipt repair must be visible to guidance and completion', - ); - const repaired = await complete(); - const repair = await repaired.json(); - assert.equal(repaired.status, 200, JSON.stringify(repair)); - assert.equal(repair.outcome, 'replayed'); - assert.equal(repair.sourceMessageId, guidance.managedHoldDisposition.candidates[0].sourceMessageId); - assert.deepEqual(h.messageStore.getById(next.stored.id).queueCustody.handledByCatIds, []); - const nextRead = await fetch(readUrl, { headers }); - assert.deepEqual((await nextRead.json()).managedHoldDisposition.candidates, [ - { sourceMessageId: next.stored.id, taskId: 'task-next' }, - ]); - const finished = await complete(); - assert.equal(finished.status, 200); - assert.equal((await finished.json()).sourceMessageId, next.stored.id); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - }); - } - - test('#1371 real callback full reads expose and settle successive exact managed wakes', async (t) => { - const { default: Fastify } = await import('fastify'); - const { InvocationRegistry } = await import( - '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' - ); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const { InvocationRecordStore } = await import( - '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js' - ); - const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); - const { InMemoryTurnExecutionStore } = await import( - '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' - ); - const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); - const { turnCustodyAdoptionRegistry } = await import('../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js'); - const registry = new InvocationRegistry(); - let httpAttempts = 0; - const h = await harness({ - invocationRegistry: registry, - beforeDispositionRecord: async ({ event, ingest }) => { - if (++httpAttempts === 1) - await ingest.record( - buildInvocationHeartbeatEvent({ - threadId: 'thread-1', - invocationId: event.payload.invocationId, - catId: 'codex-sol', - draftUpdatedAt: 2_500, - }), - ); - }, - }); - const userSource = h.messageStore.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: null, - content: 'Finish this work', - mentions: [], - timestamp: 1500, - }); - const credentials = await registry.create( - 'user-1', - 'codex-sol', - 'thread-1', - undefined, - undefined, - undefined, - userSource.id, - ); - const headers = { 'x-invocation-id': credentials.invocationId, 'x-callback-token': credentials.callbackToken }; - const turnExecutionStore = new InMemoryTurnExecutionStore(); - await turnExecutionStore.createRunning({ - invocationId: credentials.invocationId, - parentInvocationId: credentials.invocationId, - threadId: 'thread-1', - userId: 'user-1', - catId: 'codex-sol', - executionKind: 'ordinary', - startedAt: Date.now(), - }); - const entry = h.queue.list('thread-1', 'user-1')[0]; - assert.equal(h.queue.rollbackProcessing('thread-1', entry.id), true); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', entry.id)); - const app = Fastify(); - t.after(() => app.close()); - const socketManager = { broadcastAgentMessage() {}, broadcastToRoom() {}, emitToUser() {} }; - const queueProcessor = new QueueProcessor({ - queue: h.queue, - messageStore: h.messageStore, - queueCustodyCoordinator: h.coordinator, - invocationTracker: new InvocationTracker(), - invocationRecordStore: new InvocationRecordStore(), - turnExecutionStore, - socketManager, - log: app.log, - router: { - async *routeExecution() { - assert.fail('reading or completing a notification must not invoke a provider'); - }, - }, - }); - await app.register(callbacksRoutes, { - registry, - messageStore: h.messageStore, - threadStore: new ThreadStore(), - socketManager, - invocationQueue: h.queue, - queueCustodyCoordinator: h.coordinator, - queueProcessor, - turnExecutionStore, - holdBallDeps: { registry, managedHoldDispositionService: h.service }, - evidenceStore: { - async search() { - return []; - }, - }, - reflectionService: {}, - markerQueue: {}, - }); - const projectionService = new TurnCustodyProjectionService({ - ballCustodyEventLog: h.eventLog, - ballCustodyProjectionStore: h.projectionStore, - }); - const baselines = []; - t.after( - turnCustodyAdoptionRegistry.register(credentials.invocationId, async (wakes) => { - for (const wake of wakes) baselines.push(await projectionService.open(wake)); - }), - ); - await app.listen({ host: '127.0.0.1', port: 0 }); - const baseUrl = `http://127.0.0.1:${app.server.address().port}`; - const read = await fetch(`${baseUrl}/api/callbacks/thread-context?responseMode=full`, { headers }); - const body = await read.json(); - assert.equal(read.status, 200, JSON.stringify(body)); - assert.equal(body.managedHoldDisposition.state, 'single_canonical_pending'); - assert.deepEqual(body.managedHoldDisposition.candidates, [{ sourceMessageId: h.stored.id, taskId: 'task-1' }]); - assert.match(body.managedHoldDisposition.instruction, /cat_cafe_complete_managed_hold/); - assert.equal((await projectionService.close(baselines[0])).shouldBlock, true, 'full read is not completion'); - const completed = await fetch(`${baseUrl}/api/callbacks/complete-managed-hold`, { - method: 'POST', - headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify({ disposition: 'completed' }), - }); - const result = await completed.json(); - assert.equal(completed.status, 200, JSON.stringify(result)); - assert.equal(result.sourceMessageId, h.stored.id); - assert.equal((await projectionService.close(baselines[0])).shouldBlock, false); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - - assert.equal(httpAttempts, 2, 'real HTTP callback revalidates once after heartbeat contention'); - const replay = await fetch(`${baseUrl}/api/callbacks/complete-managed-hold`, { - method: 'POST', - headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify({ disposition: 'completed' }), - }); - assert.equal(replay.status, 200); - assert.equal((await replay.json()).outcome, 'replayed'); - assert.equal(httpAttempts, 2); - const completedCustody = structuredClone(h.messageStore.getById(h.stored.id).queueCustody); - assert.equal( - completedCustody.seenInvocationIdByCatId['codex-sol'], - undefined, - 'completion removes the live binding', - ); - const next = await enqueueManagedWake(h, { - taskId: 'task-next', - invocationId: 'before-current-read', - fireAt: 101000, - at: 4000, - }); - const nextEntry = h.queue.list('thread-1', 'user-1')[0]; - assert.equal(h.queue.rollbackProcessing('thread-1', nextEntry.id), true); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', nextEntry.id)); - const secondRead = await fetch(`${baseUrl}/api/callbacks/thread-context?responseMode=full`, { headers }); - const secondBody = await secondRead.json(); - assert.equal(secondRead.status, 200, JSON.stringify(secondBody)); - assert.ok(secondBody.messages.some((message) => message.id === next.stored.id && !message.truncated)); - assert.deepEqual(secondBody.managedHoldDisposition.candidates, [ - { sourceMessageId: next.stored.id, taskId: 'task-next' }, - ]); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody, completedCustody); - assert.equal((await projectionService.close(baselines[1])).shouldBlock, true); - const secondCompletion = await fetch(`${baseUrl}/api/callbacks/complete-managed-hold`, { - method: 'POST', - headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify({ disposition: 'handled' }), - }); - const secondResult = await secondCompletion.json(); - assert.equal(secondCompletion.status, 200, JSON.stringify(secondResult)); - assert.equal(secondResult.sourceMessageId, next.stored.id); - assert.equal((await projectionService.close(baselines[1])).shouldBlock, false); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 2); - }); - - test('#1371 an adopted completion replays after its live exposure binding is retired', async (t) => { - const h = await harness(); - const { caller } = await adoptForUserTurn(t, h); - await h.service.complete(caller, 'handled'); - const receipt = structuredClone(h.messageStore.getById(h.stored.id).queueCustody); - assert.equal(receipt.seenInvocationIdByCatId['codex-sol'], undefined); - assert.equal((await h.service.complete(caller, 'handled')).outcome, 'replayed'); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody, receipt); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 1); - }); - - for (const [name, invalidate] of [ - [ - 'missing exposure', - (custody) => { - custody.bodyExposures = []; - }, - ], - [ - 'other child outcome', - (custody) => { - custody.targetOutcomeByCatId['codex-sol'].invocationId = 'other-child'; - }, - ], - [ - 'missing outcome', - (custody) => { - delete custody.targetOutcomeByCatId['codex-sol']; - }, - ], - [ - 'still pending target', - (custody) => { - custody.pendingTargetCats = ['codex-sol']; - }, - ], - [ - 'other owner', - (custody) => { - custody.ownerUserId = 'other-user'; - }, - ], - ]) { - test(`#1371 completed adoption with ${name} cannot use terminal state as authority`, async (t) => { - const h = await harness(); - const { caller } = await adoptForUserTurn(t, h); - await h.service.complete(caller, 'handled'); - const original = structuredClone(h.messageStore.getById(h.stored.id).queueCustody); - const get = h.messageStore.getById.bind(h.messageStore); - h.messageStore.getById = (id) => { - const message = structuredClone(get(id)); - if (id === h.stored.id) invalidate(message.queueCustody); - return message; - }; - await assert.rejects(h.service.complete(caller, 'handled'), /adopted_source_mismatch/); - assert.deepEqual(get(h.stored.id).queueCustody, original); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 1); - }); - } - - test('#1371 adopts the canonical successor without completing the earlier reheld wake', async (t) => { - const h = await harness(); - const next = await enqueueManagedWake(h, { taskId: 'task-2', invocationId: 'inv-1', fireAt: 101000, at: 4000 }); - const { caller } = await adoptForUserTurn(t, h, [h.stored, next.stored]); - const result = await h.service.complete(caller, 'completed'); - assert.equal(result.sourceMessageId, next.stored.id); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - assert.deepEqual(h.messageStore.getById(next.stored.id).queueCustody.handledByCatIds, ['codex-sol']); - }); - - test('#1371 replays the same adopted source after its durable terminal precedes a failed receipt', async (t) => { - const h = await harness(); - const { caller } = await adoptForUserTurn(t, h); - const commit = h.coordinator.commitSuccessfulTargetForMessage.bind(h.coordinator); - h.coordinator.commitSuccessfulTargetForMessage = async () => { - throw new Error('receipt unavailable'); - }; - await assert.rejects(h.service.complete(caller, 'completed'), /receipt unavailable/); - h.coordinator.commitSuccessfulTargetForMessage = commit; - const result = await h.service.complete(caller, 'completed'); - assert.equal(result.outcome, 'replayed'); - assert.equal(result.sourceMessageId, h.stored.id); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 1); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - }); - - test('#1371 a completion snapshots its source before new adoption during preflight', async (t) => { - let enter; - let release; - const entered = new Promise((resolve) => { - enter = resolve; - }); - const released = new Promise((resolve) => { - release = resolve; - }); - const h = await harness({ - beforeLatestCheck: async () => { - enter(); - await released; - }, - }); - const { caller, bridge } = await adoptForUserTurn(t, h); - const completion = h.service.complete(caller, 'completed'); - await entered; - const next = await enqueueManagedWake(h, { taskId: 'task-2', invocationId: 'inv-1', fireAt: 101000, at: 4000 }); - await bridge.adopt(caller.invocationId, [ - { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: next.stored.id, - taskId: 'task-2', - }, - ]); - release(); - await assert.rejects( - completion, - /no_obligation/, - 'the old snapshot was superseded; never switch this call to the new source', - ); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - assert.deepEqual(h.messageStore.getById(next.stored.id).queueCustody.handledByCatIds, []); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'active'); - assert.equal((await h.service.complete(caller, 'completed')).sourceMessageId, next.stored.id); - }); - - test('#1371 ambiguous durable wakes stay pending until an existing withdrawal removes one', async (t) => { - const h = await harness(); - const next = await enqueueManagedWake(h, { taskId: 'task-2', invocationId: 'inv-1', fireAt: 101000, at: 4000 }); - // Characterize conservative legacy/corrupt history: two condition facts, - // but no canonical later hold/handoff ordering to disambiguate them. - h.eventLog.events = h.eventLog.events.filter( - (event) => event.at < 4000 || (event.kind !== 'ball.held' && event.kind !== 'ball.handed'), - ); - const { caller } = await adoptForUserTurn(t, h, [h.stored, next.stored]); - const guidance = await h.service.describe(caller); - assert.equal(guidance.state, 'ambiguous_multiple_pending'); - assert.equal(guidance.candidates.length, 2); - await assert.rejects(h.service.complete(caller, 'completed'), /ambiguous_multiple_pending/); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 0); - const entry = h.queue.list('thread-1', 'user-1').find((item) => item.messageId === h.stored.id); - assert.equal(await h.coordinator.withdrawEntry(entry), true); - assert.equal((await h.service.describe(caller)).state, 'single_canonical_pending'); - assert.equal((await h.service.complete(caller, 'completed')).sourceMessageId, next.stored.id); - }); - - test('#1371 unregister removes discovery and a restarted child must prove its own exposure', async (t) => { - const h = await harness(); - const { caller, bridge } = await adoptForUserTurn(t, h); - bridge.resetForTest(); - await assert.rejects(h.service.complete(caller, 'completed'), /no_obligation/); - const nextCaller = { ...caller, invocationId: 'restarted-child' }; - t.after(bridge.register(nextCaller.invocationId, async () => {})); - const wakes = [ - { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: h.stored.id, - taskId: 'task-1', - }, - ]; - await bridge.adopt(nextCaller.invocationId, wakes); - await assert.rejects(h.service.complete(nextCaller, 'completed'), /adopted_source_mismatch/); - const entry = h.queue.list('thread-1', 'user-1')[0]; - h.queue.markProcessingSeen('thread-1', 'user-1', entry.id, ['codex-sol'], nextCaller.invocationId, 5000); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', entry.id)); - assert.equal((await h.service.complete(nextCaller, 'completed')).sourceMessageId, h.stored.id); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - }); - - test('#1371 a durable source read failure cannot fall through to another adopted source', async (t) => { - const h = await harness(); - const { caller } = await adoptForUserTurn(t, h); - const get = h.messageStore.getById.bind(h.messageStore); - h.messageStore.getById = (id) => { - if (id === h.stored.id) throw new Error('source read outage'); - return get(id); - }; - await assert.rejects(h.service.complete(caller, 'completed'), /source read outage/); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 0); - }); - - for (const [name, mutate] of [ - [ - 'other child', - (message) => { - message.queueCustody.seenInvocationIdByCatId['codex-sol'] = 'other-child'; - }, - ], - [ - 'anchor only', - (message) => { - message.queueCustody.bodyExposures = []; - }, - ], - [ - 'other owner', - (message) => { - message.queueCustody.ownerUserId = 'foreign-user'; - }, - ], - [ - 'hidden trigger', - (message) => { - message.extra = { scheduler: { hiddenTrigger: true } }; - }, - ], - [ - 'other task', - (message) => { - message.source.meta.taskId = 'other-task'; - }, - ], - [ - 'other thread', - (message) => { - message.threadId = 'other-thread'; - }, - ], - [ - 'other target', - (message) => { - message.source.meta.catId = 'opus'; - }, - ], - [ - 'raw source', - (message) => { - message.source = undefined; - }, - ], - [ - 'future exposure', - (message) => { - message.queueCustody.bodyExposures[0].seenAt = Date.now() + 100000; - }, - ], - ]) { - test(`#1371 adopted ${name} cannot write a disposition`, async (t) => { - const h = await harness(); - const { caller } = await adoptForUserTurn(t, h); - const get = h.messageStore.getById.bind(h.messageStore); - h.messageStore.getById = (id) => { - const message = structuredClone(get(id)); - if (id === h.stored.id) mutate(message); - return message; - }; - await assert.rejects(h.service.complete(caller, 'completed'), ManagedHoldDispositionError); - assert.equal(h.eventLog.events.filter((event) => event.kind === 'ball.hold_dispositioned').length, 0); - assert.deepEqual(get(h.stored.id).queueCustody.handledByCatIds, []); - }); - } - - test('#1371 a direct user invocation can explicitly complete its exact adopted managed wake', async (t) => { - const { turnCustodyAdoptionRegistry } = await import('../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js'); - const h = await harness(); - const userSource = h.messageStore.append({ - userId: 'user-1', - catId: null, - threadId: 'thread-1', - content: 'Finish this change', - mentions: [], - timestamp: 1_500, - }); - const caller = auth(h, { originTriggerMessageId: userSource.id }); - const unregister = turnCustodyAdoptionRegistry.register(caller.invocationId, async () => {}); - t.after(unregister); - const adopted = await turnCustodyAdoptionRegistry.adopt(caller.invocationId, [ - { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: h.stored.id, - taskId: 'task-1', - }, - ]); - assert.equal(adopted, true); - const result = await h.service.complete(caller, 'completed'); - assert.equal(result.sourceMessageId, h.stored.id); - assert.equal(result.taskId, 'task-1'); - assert.equal(caller.originTriggerMessageId, userSource.id, 'adoption must not rewrite invocation origin'); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'resolved'); - }); - - test('a new managed hold reopens the same thread after a prior disposition and can complete', async () => { - const h = await harness(); - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'applied'); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'resolved'); - - const second = await enqueueManagedWake(h, { - taskId: 'task-2', - invocationId: 'inv-2', - fireAt: 199_000, - at: 4_000, - }); - - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'active'); - assert.equal( - ( - await h.service.complete( - auth(h, { invocationId: 'inv-2', originTriggerMessageId: second.stored.id }), - 'completed', - ) - ).outcome, - 'applied', - ); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.hold_dispositioned') - .length, - 2, - ); - }); - - test('a later A2A handoff to the holder replaces the older managed wake', async () => { - const h = await harness(); - await h.ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'fable5', - toCatId: 'codex-sol', - messageId: 'replacement-message', - at: 2_500, - }), - ); - - // clowder-ai#1366 contract change: a replaced wake used to be a bare 409 with - // NO custody event, which left the F167 stop gate with nothing to recognize - // and made it reinject the same wake forever. It now reaches a durable - // *retired* terminal that is inert on the subject plane. - const result = await h.service.complete(auth(h), 'completed'); - assert.equal(result.retired, true); - const dispositioned = (await h.eventLog.read('ball:thread:thread-1')).filter( - (event) => event.kind === 'ball.hold_dispositioned', - ); - assert.equal(dispositioned.length, 1); - assert.equal(dispositioned[0].payload.retired, true); - // The replacement holder keeps the ball; retiring the old wake must not resolve it. - const projection = await h.projectionStore.get('ball:thread:thread-1'); - assert.equal(projection.holder, 'codex-sol'); - assert.notEqual(projection.state, 'resolved'); - }); - - test('only the fenced producer writes one receipt + terminal event and releases the real stop gate', async () => { - const h = await harness(); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: h.projectionStore, - ballCustodyEventLog: h.eventLog, - }); - const opened = await gate.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: h.stored.id, - taskId: 'task-1', - }); - - assert.equal((await gate.close(opened)).shouldBlock, true); - const first = await h.service.complete(auth(h), 'completed'); - assert.equal(first.outcome, 'applied'); - assert.equal((await gate.close(opened)).shouldBlock, false); - - const receipt = h.messageStore.getById(h.stored.id).queueCustody; - assert.deepEqual(receipt.handledByCatIds, ['codex-sol']); - assert.equal(receipt.targetOutcomeByCatId['codex-sol'].invocationId, 'inv-1'); - assert.equal(receipt.targetOutcomeByCatId['codex-sol'].disposition, 'managed_hold_disposition'); - assert.equal(h.queue.list('thread-1', 'user-1').length, 0); - - const replay = await h.service.complete(auth(h), 'completed'); - assert.equal(replay.outcome, 'replayed'); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.hold_dispositioned') - .length, - 1, - ); - }); - - test('generic Queue success cannot write the managed-hold F264 terminal receipt', async () => { - const h = await harness(); - const entry = h.queue.list('thread-1', 'user-1')[0]; - - await assert.rejects( - () => - h.coordinator.commitSuccessfulTargets(entry, ['codex-sol'], 'inv-1', Date.now(), { - 'codex-sol': { - invocationId: 'inv-1', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-1' }, - handledAt: Date.now(), - }, - }), - /managed hold receipt requires its invocation-bound disposition/, - ); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.hold_dispositioned'), - false, - ); - }); - - test('concurrent conflicting dispositions linearize to one event and reject the loser', async () => { - const h = await harness(); - const results = await Promise.allSettled([ - h.service.complete(auth(h), 'handled'), - h.service.complete(auth(h), 'completed'), - ]); - - assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); - assert.equal(results.filter((result) => result.status === 'rejected').length, 1); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.hold_dispositioned') - .length, - 1, - ); - }); - - test('a stale disposition cannot resolve a successor holder after the holder check', async () => { - const h = await harness({ - beforeDispositionRecord: async ({ ingest }) => { - await ingest.record( - buildHandedEvent({ - threadId: 'thread-1', - fromCatId: 'codex-sol', - toCatId: 'opus', - messageId: 'successor-message', - at: 2_500, - }), - ); - }, - }); - - await assert.rejects( - () => h.service.complete(auth(h), 'completed'), - /^ManagedHoldDispositionError: managed_hold_disposition_fence_conflict$/, - ); - const projection = await h.projectionStore.get('ball:thread:thread-1'); - assert.equal(projection.state, 'active'); - assert.equal(projection.holder, 'opus'); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.hold_dispositioned'), - false, - ); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - }); - - test('repairs projection when the exact event append wins before projection persistence fails', async () => { - const h = await harness({ failDispositionProjectionOnce: true }); - - const result = await h.service.complete(auth(h), 'completed'); - - assert.equal(result.outcome, 'applied'); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).state, 'resolved'); - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, ['codex-sol']); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.hold_dispositioned') - .length, - 1, - ); - }); - - test('does not consume the exact receipt when the custody event was not appended', async () => { - const h = await harness({ failDispositionAppendOnce: true }); - - await assert.rejects(() => h.service.complete(auth(h), 'completed'), /event append failed/); - - assert.deepEqual(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds, []); - assert.equal(h.queue.list('thread-1', 'user-1').length, 1); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.hold_dispositioned'), - false, - ); - }); - - test('wrong source/task/invocation/thread/holder and stale/replaced attempts fail closed', async () => { - for (const mutate of [ - (h) => auth(h, { originTriggerMessageId: 'other-message' }), - (h) => auth(h, { invocationId: 'other-invocation' }), - (h) => auth(h, { threadId: 'other-thread' }), - (h) => auth(h, { catId: createCatId('opus') }), - (h) => { - h.task.id = 'replacement-task'; - return auth(h); - }, - (h) => { - h.setLatest(false); - return auth(h); - }, - ]) { - const h = await harness(); - await assert.rejects(() => h.service.complete(mutate(h), 'completed'), ManagedHoldDispositionError); - assert.equal(h.messageStore.getById(h.stored.id).queueCustody.handledByCatIds.length, 0); - } - }); - - test('F264 failure restoration preserves one original carrier for a successor', async () => { - const h = await harness(); - const entry = h.queue.list('thread-1', 'user-1')[0]; - - assert.equal(h.queue.rollbackProcessing('thread-1', entry.id), true); - const failed = h.queue.markQueuedFailedForCatAcrossUsers('thread-1', 'codex-sol', 'inv-1', new Set([entry.id])); - assert.deepEqual(failed, [{ entryId: entry.id, userId: 'user-1' }]); - await h.coordinator.persistEntry(h.queue.getEntrySnapshot('thread-1', 'user-1', entry.id)); - - const failedEntry = h.queue.getEntrySnapshot('thread-1', 'user-1', entry.id); - const failedAttempt = h.messageStore - .getById(h.stored.id) - .queueCustody.targetAttempts.find((attempt) => attempt.targetCatId === 'codex-sol' && attempt.state === 'failed'); - assert.ok(failedAttempt); - const retried = await h.coordinator.retryFailedTarget( - failedEntry, - 'codex-sol', - failedAttempt.id, - async (transitions) => { - for (const transition of transitions) { - const result = h.messageStore.transitionQueueCustody(transition.messageId, { - expectedRevision: transition.current.revision, - next: transition.next, - }); - assert.equal(result.kind, 'updated'); - } - return { outcome: 'committed' }; - }, - ); - assert.equal(retried.outcome, 'retried'); - assert.ok(h.queue.retryFailedTarget('thread-1', 'user-1', entry.id, 'codex-sol')); - - const successor = h.queue.markProcessing('thread-1', 'user-1'); - assert.equal(successor.id, entry.id); - assert.equal(successor.messageId, h.stored.id); - assert.equal(h.queue.list('thread-1', 'user-1').length, 1); - const receipt = h.messageStore.getById(h.stored.id).queueCustody; - assert.deepEqual(receipt.handledByCatIds, []); - assert.equal(receipt.seenInvocationIdByCatId['codex-sol'], undefined); - assert.equal(receipt.failedByCatIds.includes('codex-sol'), false); - assert.deepEqual( - receipt.targetAttempts.map((attempt) => ({ id: attempt.id, state: attempt.state })), - [ - { id: `${entry.id}:codex-sol:1`, state: 'failed' }, - { id: `${entry.id}:codex-sol:2`, state: 'queued' }, - ], - ); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.hold_dispositioned'), - false, - ); - }); - - test('re-hold advances only to its new condition without terminalizing the original ball', async () => { - const h = await harness(); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: h.projectionStore, - ballCustodyEventLog: h.eventLog, - }); - const opened = await gate.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: h.stored.id, - taskId: 'task-1', - }); - - await h.ingest.record(buildHeldEvent({ threadId: 'thread-1', catId: 'codex-sol', fireAt: 199_000, at: 4_000 })); - - assert.equal((await gate.close(opened)).shouldBlock, false); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).heldUntil, 199_000); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).some((event) => event.kind === 'ball.hold_dispositioned'), - false, - ); - // clowder-ai#1366: the re-held ball is a *newer* obligation. Retiring the old - // wake gives it a terminal without advancing the new hold to resolved. - const result = await h.service.complete(auth(h), 'completed'); - assert.equal(result.retired, true); - assert.equal((await h.projectionStore.get('ball:thread:thread-1')).heldUntil, 199_000); - assert.notEqual((await h.projectionStore.get('ball:thread:thread-1')).state, 'resolved'); - }); -}); diff --git a/packages/api/test/managed-hold-stale-terminal-closure.test.js b/packages/api/test/managed-hold-stale-terminal-closure.test.js deleted file mode 100644 index f97280096d..0000000000 --- a/packages/api/test/managed-hold-stale-terminal-closure.test.js +++ /dev/null @@ -1,645 +0,0 @@ -/** - * F167 × clowder-ai#1366 — stale / adopted managed-hold terminal lifecycle. - * - * Two symptoms, one root cause: an exact (sourceMessageId, taskId) wake has no - * durable terminal of its own, so a late or superseded wake can neither be - * dispositioned nor recognized as resolved — and the F167 stop gate reinjects it - * forever, blocking unrelated healthy turns. - * - * Real event log / projector / disposition service / turn-custody projection. - * No sequence-shaped fake projection is allowed in this regression. - */ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { createCatId } from '@cat-cafe/shared'; -import { BallCustodyIngest } from '../dist/domains/ball-custody/BallCustodyIngest.js'; -import { BallCustodyProjector } from '../dist/domains/ball-custody/BallCustodyProjector.js'; -import { - buildHandedEvent, - buildHeldEvent, - buildWakeConditionMetEvent, -} from '../dist/domains/ball-custody/ball-custody-events.js'; -import { ManagedHoldDispositionService } from '../dist/domains/ball-custody/ManagedHoldDispositionService.js'; -import { ManagedHoldReceiptService } from '../dist/domains/ball-custody/ManagedHoldReceiptService.js'; -import { TurnCustodyProjectionService } from '../dist/domains/ball-custody/TurnCustodyProjectionService.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; - -const THREAD = 'thread-1'; -const USER = 'user-1'; -const CAT = 'codex-sol'; -const SUBJECT = `ball:thread:${THREAD}`; - -class MemoryEventLog { - events = []; - async append(event) { - if (this.events.some((c) => c.sourceEventId === event.sourceEventId)) return { appended: false, sequence: -1 }; - this.events.push(structuredClone(event)); - return { appended: true, sequence: this.events.length - 1 }; - } - async appendFenced(event, expectedSequence) { - if (this.events.some((c) => c.sourceEventId === event.sourceEventId)) return { outcome: 'duplicate' }; - const actual = this.events.filter((c) => c.subjectKey === event.subjectKey).length; - if (actual !== expectedSequence) return { outcome: 'conflict', actualSequence: actual }; - this.events.push(structuredClone(event)); - return { outcome: 'appended', sequence: expectedSequence }; - } - async read(subjectKey, fromSequence = 0) { - return this.events.filter((e) => e.subjectKey === subjectKey).slice(fromSequence); - } - async listSubjects() { - return [...new Set(this.events.map((e) => e.subjectKey))]; - } -} - -class MemoryProjectionStore { - projections = new Map(); - async get(subjectKey) { - return structuredClone(this.projections.get(subjectKey) ?? null); - } - async save(projection) { - this.projections.set(projection.subjectKey, structuredClone(projection)); - } - async listSubjectKeys() { - return [...this.projections.keys()]; - } - async delete(subjectKey) { - this.projections.delete(subjectKey); - } -} - -function managedTask({ id, messageId, state, fireAt }) { - return { - id, - templateId: 'reminder', - trigger: { type: 'once', fireAt }, - params: { - message: 'fallback', - targetCatId: CAT, - triggerUserId: USER, - holdLifecycle: { - mode: 'wake_when', - status: 'active', - wakeAt: fireAt, - managedCommand: { - state, - command: `pnpm test:${id}`, - startedAt: 1_000, - conditionMetAt: 2_000, - wakeContent: 'tests passed', - result: { exitCode: 0, timedOut: false, durationMs: 1_000 }, - messageId, - messageWrittenAt: 2_100, - }, - }, - }, - display: { label: 'hold', category: 'system', description: 'hold' }, - deliveryThreadId: THREAD, - enabled: true, - createdBy: `hold-ball:${CAT}`, - createdAt: new Date(1_000).toISOString(), - }; -} - -/** One managed wake, end to end through the real carriers. */ -async function harness() { - const now = Date.now() + 1_000; - const eventLog = new MemoryEventLog(); - const projectionStore = new MemoryProjectionStore(); - const projector = new BallCustodyProjector(eventLog, projectionStore); - const ingest = new BallCustodyIngest(eventLog, projector); - const messageStore = new MessageStore(); - const queue = new InvocationQueue(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore, now: () => now }); - const tasks = new Map(); - - async function deliverWake({ taskId, invocationId, at, state = 'enqueued' }) { - const enqueue = queue.enqueue({ - threadId: THREAD, - userId: USER, - ownerAuthProvenance: 'unknown', - content: `[定时任务] ${taskId} passed`, - source: 'connector', - sourceCategory: 'scheduled', - targetCats: [CAT], - intent: 'execute', - priority: 'normal', - }); - assert.ok(enqueue.entry); - const stored = messageStore.append({ - id: 'ignored-by-store', - userId: 'scheduler', - catId: null, - content: `[定时任务] ${taskId} passed`, - mentions: [], - timestamp: at + 100, - threadId: THREAD, - deliveryStatus: 'queued', - source: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId, threadId: THREAD, catId: CAT, wakeWhen: true }, - }, - }); - tasks.set(taskId, managedTask({ id: taskId, messageId: stored.id, state, fireAt: at })); - queue.backfillMessageId(THREAD, USER, enqueue.entry.id, stored.id); - messageStore.initializeQueueCustody( - stored.id, - createInitialQueuedMessageCustody(queue.getEntrySnapshot(THREAD, USER, enqueue.entry.id)), - ); - const processing = queue.markProcessing(THREAD, USER); - await coordinator.persistEntry(queue.getEntrySnapshot(THREAD, USER, processing.id)); - queue.markProcessingSeen(THREAD, USER, processing.id, [CAT], invocationId, at + 200); - await coordinator.persistEntry(queue.getEntrySnapshot(THREAD, USER, processing.id)); - - await ingest.record(buildHeldEvent({ threadId: THREAD, catId: CAT, fireAt: at + 90_000, at })); - await ingest.record( - buildWakeConditionMetEvent({ - threadId: THREAD, - catId: CAT, - taskId, - command: `pnpm test:${taskId}`, - exitCode: 0, - timedOut: false, - durationMs: 1_000, - at: at + 1, - }), - ); - await ingest.record(buildHandedEvent({ threadId: THREAD, toCatId: CAT, messageId: stored.id, at: at + 2 })); - return { stored, taskId }; - } - - let latestInvocationId = 'inv-1'; - const realReceiptService = new ManagedHoldReceiptService({ queue, messageStore, coordinator, now: () => now }); - let failNextReceipt = false; - const receiptService = { - async complete(input) { - if (failNextReceipt) { - failNextReceipt = false; - // Production order writes the custody event first, then settles F264. - // This models the window where the event is durable but the receipt is not. - throw new Error('receipt write failed'); - } - return realReceiptService.complete(input); - }, - }; - const service = new ManagedHoldDispositionService({ - registry: { isLatest: async (id) => id === latestInvocationId }, - dynamicTaskStore: { getById: (id) => tasks.get(id) ?? null }, - messageStore, - ballCustodyEventLog: eventLog, - ballCustodyProjectionStore: projectionStore, - ballCustody: ingest, - receiptService, - repairProjection: (subjectKey) => projector.rebuild(subjectKey), - now: () => now, - }); - const gate = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: projectionStore, - ballCustodyEventLog: eventLog, - }); - - function restartService() { - // Simulates a process restart: same durable event log + projection store, - // brand new service instance with no in-memory carry-over. - return new ManagedHoldDispositionService({ - registry: { isLatest: async (id) => id === latestInvocationId }, - dynamicTaskStore: { getById: (id) => tasks.get(id) ?? null }, - messageStore, - ballCustodyEventLog: eventLog, - ballCustodyProjectionStore: projectionStore, - ballCustody: ingest, - receiptService, - repairProjection: (subjectKey) => projector.rebuild(subjectKey), - now: () => now, - }); - } - - return { - eventLog, - projectionStore, - restartService, - failReceiptOnce() { - failNextReceipt = true; - }, - handledCatIds(messageId) { - return messageStore.getById(messageId).queueCustody.handledByCatIds; - }, - /** - * Model the real recovery path: Queue rolls the carrier back after a failed - * settlement and re-exposes the SAME source/task to a successor invocation. - */ - async reexposeTo(messageId, invocationId) { - const entry = queue.list(THREAD, USER).find((candidate) => candidate.messageId === messageId); - assert.ok(entry, 'carrier must still exist to be re-exposed'); - queue.rollbackProcessing(THREAD, entry.id); - queue.markQueuedFailedForCatAcrossUsers(THREAD, CAT, latestInvocationId, new Set([entry.id])); - await coordinator.persistEntry(queue.getEntrySnapshot(THREAD, USER, entry.id)); - assert.ok( - queue.retryFailedTarget(THREAD, USER, entry.id, CAT), - 'successor re-exposure must explicitly reopen the failed target', - ); - const successor = queue.markProcessing(THREAD, USER); - await coordinator.persistEntry(queue.getEntrySnapshot(THREAD, USER, successor.id)); - queue.markProcessingSeen(THREAD, USER, successor.id, [CAT], invocationId, now + 1); - await coordinator.persistEntry(queue.getEntrySnapshot(THREAD, USER, successor.id)); - latestInvocationId = invocationId; - }, - ingest, - tasks, - service, - gate, - deliverWake, - setLatest(id) { - latestInvocationId = id; - }, - }; -} - -function auth({ invocationId, sourceMessageId }) { - return { - invocationId, - callbackToken: 'token', - userId: USER, - ownerAuthProvenance: 'unknown', - catId: createCatId(CAT), - threadId: THREAD, - originTriggerMessageId: sourceMessageId, - clientMessageIds: new Set(), - createdAt: 1, - expiresAt: 9_999_999, - }; -} - -function holdWake({ sourceMessageId, taskId }) { - return { - kind: 'structured', - protocol: 'hold', - subjectKey: SUBJECT, - holderCatId: CAT, - sourceMessageId, - taskId, - }; -} - -async function dispositionEvents(h) { - return (await h.eventLog.read(SUBJECT)).filter((e) => e.kind === 'ball.hold_dispositioned'); -} - -describe('F167 stale/adopted managed-hold terminal closure (clowder-ai#1366)', () => { - // ── RED 1: #1366 as filed — late primary wake whose task already went terminal. - test('a late primary wake whose dynamic task is terminal still reaches a terminal disposition', async () => { - const h = await harness(); - const { stored, taskId } = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - - // The command task left the active delivery states before the callback was consumed. - h.tasks.get(taskId).params.holdLifecycle.managedCommand.state = 'consumed'; - - const result = await h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'); - assert.equal(typeof result.outcome, 'string'); - assert.equal(result.sourceMessageId, stored.id); - assert.equal(result.taskId, taskId); - - const events = await dispositionEvents(h); - assert.equal(events.length, 1, 'exactly one terminal custody event for the exact late wake'); - assert.equal(events[0].payload.taskId, taskId); - assert.equal(events[0].payload.sourceMessageId, stored.id); - }); - - test('duplicate disposition of the same late wake is idempotent', async () => { - const h = await harness(); - const { stored, taskId } = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - h.tasks.get(taskId).params.holdLifecycle.managedCommand.state = 'consumed'; - - const first = await h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'); - const second = await h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'); - assert.equal(second.outcome, 'replayed'); - assert.equal(first.taskId, second.taskId); - assert.equal((await dispositionEvents(h)).length, 1, 'no duplicate ball.hold_dispositioned'); - }); - - // ── RED 2: our live site — replacement happened BEFORE an unrelated turn adopted the old wake. - test('a wake superseded before adoption does not block the adopting turn', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - // A newer managed hold replaces it while the old callback is still unconsumed. - const second = await h.deliverWake({ taskId: 'task-2', invocationId: 'inv-2', at: 50_000 }); - - // An unrelated, healthy turn now reads the queue and adopts the OLD wake. - const adopted = await h.gate.open(holdWake({ sourceMessageId: first.stored.id, taskId: first.taskId })); - const decision = await h.gate.close(adopted); - - assert.equal( - decision.shouldBlock, - false, - 'a wake already superseded before adoption must not block an unrelated successful turn', - ); - assert.notEqual(adopted.state, 'unknown_legacy'); - assert.ok(second.stored.id); - }); - - // ── Guard (must stay GREEN): legitimate post-adoption continuation is NOT retired early. - test('a rehold after adoption still counts as continuation for the adopted wake', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - - const adopted = await h.gate.open(holdWake({ sourceMessageId: first.stored.id, taskId: first.taskId })); - assert.equal(adopted.state, 'covered_active', 'a live wake is a real obligation at adoption time'); - - // The cat legitimately reholds during this same turn (#3636 continuation witness). - await h.ingest.record(buildHeldEvent({ threadId: THREAD, catId: CAT, fireAt: 200_000, at: 120_000 })); - - const decision = await h.gate.close(adopted); - assert.equal(decision.transitionObserved, true); - assert.equal(decision.structuredTransitionKind, 'held'); - assert.equal(decision.shouldBlock, false); - }); - - test('a handoff after adoption still counts as continuation for the adopted wake', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - const adopted = await h.gate.open(holdWake({ sourceMessageId: first.stored.id, taskId: first.taskId })); - - await h.ingest.record( - buildHandedEvent({ threadId: THREAD, fromCatId: CAT, toCatId: 'fable5', messageId: 'handoff-1', at: 120_000 }), - ); - - const decision = await h.gate.close(adopted); - assert.equal(decision.transitionObserved, true); - assert.equal(decision.structuredTransitionKind, 'handed'); - assert.equal(decision.shouldBlock, false); - }); - - // ── Guard: retiring the old wake must never terminate the newer active hold. - test('settling a superseded wake leaves the newer hold untouched', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - await h.deliverWake({ taskId: 'task-2', invocationId: 'inv-2', at: 50_000 }); - - const before = await h.projectionStore.get(SUBJECT); - assert.equal(before.holder, CAT); - assert.ok(before.state === 'active' || before.state === 'blocked'); - - // The old wake must actually reach a terminal (not silently throw) — otherwise - // this guard would pass vacuously while the liveness loop is still live. - h.setLatest('inv-1'); - const result = await h.service.complete( - auth({ invocationId: 'inv-1', sourceMessageId: first.stored.id }), - 'handled', - ); - assert.equal(result.retired, true, 'a superseded wake terminates as retired, not as a subject resolution'); - const written = await dispositionEvents(h); - assert.equal(written.length, 1, 'the retired terminal is durable for the stop gate'); - assert.equal(written[0].payload.retired, true); - assert.equal(written[0].payload.taskId, first.taskId); - - const after = await h.projectionStore.get(SUBJECT); - assert.equal(after.holder, CAT, 'newer hold keeps its holder'); - assert.notEqual(after.state, 'resolved', 'retiring an old wake must not resolve the newer active hold'); - }); - - test('a retired terminal survives restart and stays idempotent', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - await h.deliverWake({ taskId: 'task-2', invocationId: 'inv-2', at: 50_000 }); - - h.setLatest('inv-1'); - const before = await h.service.complete( - auth({ invocationId: 'inv-1', sourceMessageId: first.stored.id }), - 'handled', - ); - assert.equal(before.retired, true); - - // Restart: nothing in memory, only the durable custody log. - const after = await h - .restartService() - .complete(auth({ invocationId: 'inv-1', sourceMessageId: first.stored.id }), 'handled'); - assert.equal(after.outcome, 'replayed'); - assert.equal(after.retired, true, 'the retired plane is reconstructed from the event, not from memory'); - assert.equal((await dispositionEvents(h)).length, 1, 'restart must not append a duplicate terminal'); - }); - - test('the fix does not weaken fail-closed: a live wake still blocks and a forged one still rejects', async () => { - const h = await harness(); - const live = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - - // A genuinely live wake is still a real obligation that blocks the turn. - const opened = await h.gate.open(holdWake({ sourceMessageId: live.stored.id, taskId: live.taskId })); - assert.equal(opened.state, 'covered_active'); - assert.equal((await h.gate.close(opened)).shouldBlock, true, 'live obligations must still block'); - - // Identity mismatches stay non-negotiable even on the new terminal path. - h.tasks.get(live.taskId).params.holdLifecycle.managedCommand.state = 'consumed'; - h.tasks.get(live.taskId).params.holdLifecycle.managedCommand.messageId = 'someone-elses-message'; - await assert.rejects( - () => h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: live.stored.id }), 'handled'), - (error) => error.code === 'managed_hold_disposition_task_mismatch', - 'a terminal carrier must not become a bypass for identity checks', - ); - assert.equal((await dispositionEvents(h)).length, 0); - }); - - // ── Sol REQUEST_CHANGES P1: a terminal command carrier must NOT by itself make - // the disposition subject-inert. If nothing superseded the wake, it is still the - // subject's live wake and disposing it must actually resolve the ball. - test('a terminal task with no successor resolves the subject instead of leaking active state', async () => { - const h = await harness(); - const { stored, taskId } = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - h.tasks.get(taskId).params.holdLifecycle.managedCommand.state = 'consumed'; - - const result = await h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'); - - assert.equal(result.retired, false, 'a terminal carrier alone is not a supersession'); - const projection = await h.projectionStore.get(SUBJECT); - assert.equal(projection.state, 'resolved', 'the exact live wake must reach subject terminal, not stay active'); - }); - - test('a terminal task WITH a newer hold retires and leaves the newer custody untouched', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - h.tasks.get(first.taskId).params.holdLifecycle.managedCommand.state = 'consumed'; - await h.deliverWake({ taskId: 'task-2', invocationId: 'inv-2', at: 50_000 }); - const beforeHeldUntil = (await h.projectionStore.get(SUBJECT)).heldUntil; - - h.setLatest('inv-1'); - const result = await h.service.complete( - auth({ invocationId: 'inv-1', sourceMessageId: first.stored.id }), - 'handled', - ); - - assert.equal(result.retired, true); - const projection = await h.projectionStore.get(SUBJECT); - assert.notEqual(projection.state, 'resolved'); - assert.equal(projection.holder, CAT); - assert.equal(projection.heldUntil, beforeHeldUntil, 'the newer hold window is untouched'); - }); - - // ── Sol R2 P1: the terminal's sourceEventId embeds invocationId, but the wake's - // identity is (sourceMessageId, taskId). If the event lands and the F264 receipt - // then fails, Queue re-exposes the SAME wake to a successor invocation — which - // must recognize the existing terminal instead of writing a second one. - test('a successor invocation settles the same wake without a second terminal (no successor hold)', async () => { - const h = await harness(); - const { stored } = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - - h.failReceiptOnce(); - await assert.rejects( - () => h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'), - 'the receipt failure must surface, leaving the durable event in place', - ); - assert.equal((await dispositionEvents(h)).length, 1, 'the custody terminal is already durable'); - - // Queue rolled the carrier back and re-exposed it to a new invocation. - await h.reexposeTo(stored.id, 'inv-2'); - const retry = await h.service.complete(auth({ invocationId: 'inv-2', sourceMessageId: stored.id }), 'handled'); - - assert.equal(retry.outcome, 'replayed', 'the successor must recognize the existing per-wake terminal'); - assert.equal((await dispositionEvents(h)).length, 1, 'exactly one terminal event for one wake'); - }); - - test('a successor invocation settles a retired wake without a second terminal (newer hold present)', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - await h.deliverWake({ taskId: 'task-2', invocationId: 'inv-2', at: 50_000 }); - - h.setLatest('inv-1'); - h.failReceiptOnce(); - await assert.rejects(() => - h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: first.stored.id }), 'handled'), - ); - assert.equal((await dispositionEvents(h)).length, 1); - - await h.reexposeTo(first.stored.id, 'inv-3'); - const retry = await h.service.complete( - auth({ invocationId: 'inv-3', sourceMessageId: first.stored.id }), - 'handled', - ); - - assert.equal(retry.outcome, 'replayed'); - assert.equal(retry.retired, true, 'the successor reads the retired plane off the durable event'); - assert.equal((await dispositionEvents(h)).length, 1, '#1366 AC: duplicate disposition creates no duplicate event'); - const projection = await h.projectionStore.get(SUBJECT); - assert.notEqual(projection.state, 'resolved', 'the newer hold is still untouched'); - }); - - // ── Sol R3 P1: the producer can replay, but the CONSUMER must also recognize an - // existing per-wake terminal. Production order is gate.open -> tool -> gate.close; - // the first non-retired terminal already drove the subject to `resolved`, so - // openStructured bailed to unknown_legacy and the route still wrote - // `managed_hold_disposition_missing`, failing the successor invocation. - test('the stop gate recognizes an existing terminal after receipt-failure re-exposure', async () => { - const h = await harness(); - const { stored, taskId } = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - - h.failReceiptOnce(); - await assert.rejects(() => - h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'), - ); - assert.equal((await dispositionEvents(h)).length, 1); - assert.equal((await h.projectionStore.get(SUBJECT)).state, 'resolved'); - - await h.reexposeTo(stored.id, 'inv-2'); - - // Production order: the route opens the projection BEFORE the tool runs. - const opened = await h.gate.open(holdWake({ sourceMessageId: stored.id, taskId })); - const retry = await h.service.complete(auth({ invocationId: 'inv-2', sourceMessageId: stored.id }), 'handled'); - const decision = await h.gate.close(opened); - - assert.equal(retry.outcome, 'replayed'); - assert.notEqual(opened.state, 'unknown_legacy', 'an already-terminal wake is not unknown legacy'); - assert.equal(decision.shouldBlock, false, 'no managed_hold_disposition_missing for a settled wake'); - assert.equal((await dispositionEvents(h)).length, 1); - }); - - test('the stop gate recognizes an existing retired terminal while the newer hold stays live', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - await h.deliverWake({ taskId: 'task-2', invocationId: 'inv-2', at: 50_000 }); - - h.setLatest('inv-1'); - h.failReceiptOnce(); - await assert.rejects(() => - h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: first.stored.id }), 'handled'), - ); - - await h.reexposeTo(first.stored.id, 'inv-3'); - const opened = await h.gate.open(holdWake({ sourceMessageId: first.stored.id, taskId: first.taskId })); - const retry = await h.service.complete( - auth({ invocationId: 'inv-3', sourceMessageId: first.stored.id }), - 'handled', - ); - const decision = await h.gate.close(opened); - - assert.equal(retry.outcome, 'replayed'); - assert.equal(decision.shouldBlock, false); - assert.equal((await dispositionEvents(h)).length, 1); - assert.notEqual((await h.projectionStore.get(SUBJECT)).state, 'resolved', 'newer hold untouched'); - }); - - // ── Sol R4 P1: every turn is free to pick handled|completed. A successor - // invocation cannot know what its predecessor chose, so requiring the same value - // left the current F264 exposure permanently unsettled. - test('a successor may choose a different disposition and still settle via the canonical terminal', async () => { - const h = await harness(); - const { stored } = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - - h.failReceiptOnce(); - await assert.rejects(() => - h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'), - ); - assert.equal((await dispositionEvents(h)).length, 1); - - await h.reexposeTo(stored.id, 'inv-2'); - const retry = await h.service.complete(auth({ invocationId: 'inv-2', sourceMessageId: stored.id }), 'completed'); - - assert.equal(retry.outcome, 'replayed'); - assert.equal(retry.disposition, 'handled', 'the canonical prior terminal wins; it is not rewritten'); - const events = await dispositionEvents(h); - assert.equal(events.length, 1, 'no second terminal for one wake'); - assert.equal(events[0].payload.disposition, 'handled'); - assert.deepEqual(h.handledCatIds(stored.id), [CAT], 'the current exposure receipt is settled'); - }); - - test('a successor with a newer hold may also choose a different disposition', async () => { - const h = await harness(); - const first = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - await h.deliverWake({ taskId: 'task-2', invocationId: 'inv-2', at: 50_000 }); - - h.setLatest('inv-1'); - h.failReceiptOnce(); - await assert.rejects(() => - h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: first.stored.id }), 'completed'), - ); - - await h.reexposeTo(first.stored.id, 'inv-3'); - const retry = await h.service.complete( - auth({ invocationId: 'inv-3', sourceMessageId: first.stored.id }), - 'handled', - ); - - assert.equal(retry.outcome, 'replayed'); - assert.equal(retry.disposition, 'completed', 'canonical prior terminal preserved'); - assert.equal(retry.retired, true); - assert.equal((await dispositionEvents(h)).length, 1); - assert.notEqual((await h.projectionStore.get(SUBJECT)).state, 'resolved', 'newer hold untouched'); - }); - - test('two conflicting dispositions inside ONE invocation still linearize to one winner', async () => { - const h = await harness(); - const { stored } = await h.deliverWake({ taskId: 'task-1', invocationId: 'inv-1', at: 2_000 }); - - const results = await Promise.allSettled([ - h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'handled'), - h.service.complete(auth({ invocationId: 'inv-1', sourceMessageId: stored.id }), 'completed'), - ]); - - assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1, 'same-invocation conflict rejects a loser'); - assert.equal(results.filter((r) => r.status === 'rejected').length, 1); - assert.equal((await dispositionEvents(h)).length, 1); - }); -}); diff --git a/packages/api/test/mark-all-read.test.js b/packages/api/test/mark-all-read.test.js index d230af6f23..27a082d763 100644 --- a/packages/api/test/mark-all-read.test.js +++ b/packages/api/test/mark-all-read.test.js @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('POST /api/threads/read/mark-all', () => { let app; @@ -63,22 +64,26 @@ describe('POST /api/threads/read/mark-all', () => { // Add messages to each thread for (const t of threads) { - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: `msg1 in ${t.id}`, - mentions: [], - timestamp: 1000, - threadId: t.id, - }); - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: `msg2 in ${t.id}`, - mentions: [], - timestamp: 2000, - threadId: t.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: `msg1 in ${t.id}`, + mentions: [], + timestamp: 1000, + threadId: t.id, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: `msg2 in ${t.id}`, + mentions: [], + timestamp: 2000, + threadId: t.id, + }), + ); } const res = await app.inject({ @@ -95,15 +100,17 @@ describe('POST /api/threads/read/mark-all', () => { it('acks threads whose latest visible item is queued cat-authored speech', async () => { const thread = threadStore.create('alice', 'Source-cat seed only'); - const seed = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - content: 'published source-cat seed', - mentions: ['opus'], - timestamp: 1000, - threadId: thread.id, - deliveryStatus: 'queued', - }); + const seed = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + content: 'published source-cat seed', + mentions: ['opus'], + timestamp: 1000, + threadId: thread.id, + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'POST', @@ -120,23 +127,27 @@ describe('POST /api/threads/read/mark-all', () => { // mark-all must ack to Q (latest visibilitySeq), not A. it('mixed thread: queued cat speech Q after ordinary A — acks to Q', async () => { const thread = threadStore.create('alice', 'Mixed: ordinary + queued'); - messageStore.append({ - userId: 'alice', - catId: null, - content: 'ordinary message A', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); - const q = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - content: 'queued cat speech Q', - mentions: ['opus'], - timestamp: 2000, - threadId: thread.id, - deliveryStatus: 'queued', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'ordinary message A', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); + const q = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + content: 'queued cat speech Q', + mentions: ['opus'], + timestamp: 2000, + threadId: thread.id, + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'POST', @@ -155,14 +166,16 @@ describe('POST /api/threads/read/mark-all', () => { it('is idempotent — second call advances 0', async () => { const t = threadStore.create('alice', 'Thread X'); - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: 1000, - threadId: t.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: 1000, + threadId: t.id, + }), + ); // First call await app.inject({ method: 'POST', url: '/api/threads/read/mark-all', headers: { 'x-cat-cafe-user': 'alice' } }); diff --git a/packages/api/test/memory/asr-defer-receipt-lifecycle-route.test.js b/packages/api/test/memory/asr-defer-receipt-lifecycle-route.test.js index 1c48d9ced4..a693a689a6 100644 --- a/packages/api/test/memory/asr-defer-receipt-lifecycle-route.test.js +++ b/packages/api/test/memory/asr-defer-receipt-lifecycle-route.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { adaptMessageStore } from '../helpers/message-from-fixtures.js'; const LINEAGE = `write_lineage_${'a'.repeat(32)}`; const OPPORTUNITY = `write_opp_${'c'.repeat(32)}`; @@ -25,7 +26,7 @@ describe('F276 deferred receipt lifecycle invalidates write-opportunity lineage' import('../../dist/routes/callback-auth-prehandler.js'), ]); registry = new registryMod.InvocationRegistry(); - messageStore = new messageMod.MessageStore(); + messageStore = adaptMessageStore(new messageMod.MessageStore()); app = Fastify(); authMod.registerCallbackAuthHook(app, registry); routeMod.registerCallbackDeferPersonMemoryRoutes(app, { diff --git a/packages/api/test/memory/asr-defer-reentry-route.test.js b/packages/api/test/memory/asr-defer-reentry-route.test.js index 8596de0b43..4b982ad22d 100644 --- a/packages/api/test/memory/asr-defer-reentry-route.test.js +++ b/packages/api/test/memory/asr-defer-reentry-route.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { adaptMessageStore } from '../helpers/message-from-fixtures.js'; const LINEAGE = `write_lineage_${'a'.repeat(32)}`; @@ -59,7 +60,7 @@ describe('F276 generation+1 defer re-arms the claimed receipt', () => { import('../../dist/routes/callback-auth-prehandler.js'), ]); registry = new registryMod.InvocationRegistry(); - messageStore = new messageMod.MessageStore(); + messageStore = adaptMessageStore(new messageMod.MessageStore()); app = Fastify(); authMod.registerCallbackAuthHook(app, registry); routeMod.registerCallbackDeferPersonMemoryRoutes(app, { diff --git a/packages/api/test/memory/asr-defer-route-authority.test.js b/packages/api/test/memory/asr-defer-route-authority.test.js index fde978ec23..7d2fe5a369 100644 --- a/packages/api/test/memory/asr-defer-route-authority.test.js +++ b/packages/api/test/memory/asr-defer-route-authority.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import Fastify from 'fastify'; +import { adaptMessageStore } from '../helpers/message-from-fixtures.js'; describe('F276 defer route requires durable disposition authority', () => { it('rejects an attributed defer before staging when either authority store is absent', async () => { @@ -11,7 +12,7 @@ describe('F276 defer route requires durable disposition authority', () => { import('../../dist/routes/callback-auth-prehandler.js'), ]); const registry = new registryMod.InvocationRegistry(); - const messageStore = new messageMod.MessageStore(); + const messageStore = adaptMessageStore(new messageMod.MessageStore()); let stageCalls = 0; const app = Fastify(); authMod.registerCallbackAuthHook(app, registry); diff --git a/packages/api/test/memory/asr-defer-route-opportunity-ref.test.js b/packages/api/test/memory/asr-defer-route-opportunity-ref.test.js index 7c88f63f82..0bbc1ad3ae 100644 --- a/packages/api/test/memory/asr-defer-route-opportunity-ref.test.js +++ b/packages/api/test/memory/asr-defer-route-opportunity-ref.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { adaptMessageStore } from '../helpers/message-from-fixtures.js'; const OPPORTUNITY = `write_opp_${'c'.repeat(32)}`; const LINEAGE = `write_lineage_${'a'.repeat(32)}`; @@ -62,7 +63,7 @@ describe('F276 defer route binds a validated write-opportunity ref', () => { ]); TerminalConflictError = ledgerMod.WriteOpportunityTerminalConflictError; registry = new registryMod.InvocationRegistry(); - messageStore = new messageMod.MessageStore(); + messageStore = adaptMessageStore(new messageMod.MessageStore()); app = Fastify(); authMod.registerCallbackAuthHook(app, registry); routeMod.registerCallbackDeferPersonMemoryRoutes(app, { diff --git a/packages/api/test/memory/asr-person-memory-reentry-carrier.test.js b/packages/api/test/memory/asr-person-memory-reentry-carrier.test.js index af989bd182..e1662847e4 100644 --- a/packages/api/test/memory/asr-person-memory-reentry-carrier.test.js +++ b/packages/api/test/memory/asr-person-memory-reentry-carrier.test.js @@ -58,8 +58,8 @@ describe('F276 scheduler re-entry carrier', () => { function messages(overrides = {}) { const source = { id: 'owner-message-1', + from: { kind: 'user', userId: 'owner-1' }, userId: 'owner-1', - catId: null, content: 'meeting attachment', mentions: [], timestamp: 1, @@ -77,8 +77,8 @@ describe('F276 scheduler re-entry carrier', () => { }; const trigger = { id: 'scheduler-message-1', + from: { kind: 'system', service: 'scheduler' }, userId: 'scheduler', - catId: null, content: 'daily clerk', mentions: [], timestamp: 2, @@ -203,7 +203,7 @@ describe('F276 scheduler re-entry carrier', () => { }); it('does not accept an owner-authored or visible scheduler lookalike as authority', async () => { - assert.deepEqual(await resolve({ trigger: { userId: 'owner-1' } }), []); + assert.deepEqual(await resolve({ trigger: { from: { kind: 'user', userId: 'owner-1' }, userId: 'owner-1' } }), []); assert.deepEqual( await resolve({ trigger: { @@ -269,7 +269,10 @@ describe('F276 scheduler re-entry carrier', () => { messageStore: { getById: async () => ({ ...base.source, ...overrides.source }) }, }); - assert.deepEqual(await resolveRetry({ trigger: { userId: 'owner-1' } }), []); + assert.deepEqual( + await resolveRetry({ trigger: { from: { kind: 'user', userId: 'owner-1' }, userId: 'owner-1' } }), + [], + ); assert.deepEqual(await resolveRetry({ source: { deletedAt: 3 } }), []); assert.deepEqual(await resolveRetry({ targetCatId: 'other-cat' }), []); assert.deepEqual( diff --git a/packages/api/test/memory/cat-owned-seed-memory-cue.test.js b/packages/api/test/memory/cat-owned-seed-memory-cue.test.js index 5be4cdaddc..ddf528c82c 100644 --- a/packages/api/test/memory/cat-owned-seed-memory-cue.test.js +++ b/packages/api/test/memory/cat-owned-seed-memory-cue.test.js @@ -156,6 +156,7 @@ describe('F312 Phase E cat-owned Seed Standing Reflex', () => { id: 'message-present-loop', threadId: SCOPE.threadId, userId: 'scheduler', + from: { kind: 'system', service: 'scheduler' }, catId: null, content: '[scheduler] privateContext=invocation-only', source: { connector: 'scheduler', label: '定时任务' }, diff --git a/packages/api/test/memory/f287-alden-journey.test.js b/packages/api/test/memory/f287-alden-journey.test.js index 0114c2a041..1c30d7d11b 100644 --- a/packages/api/test/memory/f287-alden-journey.test.js +++ b/packages/api/test/memory/f287-alden-journey.test.js @@ -265,7 +265,10 @@ describe('F287 D1 Alden golden journey', { concurrency: false }, () => { const memoryCuePromptService = { async resolve(input) { calls.push(input); - const seed = input.seeds[0]; + // F117: seeds arrive caller-supplied via options.memoryCueOpportunitySeeds + // (queue-owned ingress) plus route-detected subject_seen seeds; pin the + // mock to the typed Entity seed regardless of array order. + const seed = input.seeds.find((candidate) => candidate.kind === 'subject_seen') ?? input.seeds[0]; return admitCue ? { promptSegment: `\nTitle: ${seed.payload.matchedAlias}\nSource: ${seed.payload.entityId}\nDrill: person_memory handle-alden\n`, @@ -348,9 +351,21 @@ describe('F287 D1 Alden golden journey', { concurrency: false }, () => { }, }; const deps = createRouteDeps(capturing, evidenceDb, memoryCuePromptService); + // F117 new contract: route assembly no longer detects explicit approved Taste + // triggers from message text (route-helpers explicitApprovedTasteCueSeeds was + // removed in e43cb4ee3); caller-supplied ingress seeds arrive via + // options.memoryCueOpportunitySeeds, mirroring QueueProcessor's trusted-connector path. const options = { currentUserMessageId: 'message-current', frustrationAutoIssueEligible: true, + memoryCueOpportunitySeeds: [ + { + kind: 'approved_taste_invoked', + producer: 'owner_message', + occurredAt: 1_788_000_000_000, + payload: { triggerKey: 'ELI5', sourceMessageId: 'message-current' }, + }, + ], }; for await (const _event of strategy( deps, @@ -377,25 +392,26 @@ describe('F287 D1 Alden golden journey', { concurrency: false }, () => { for (const result of [serial, parallel]) { assert.equal(result.calls.length, 1, 'one resolver call per actual cat invocation'); assert.equal(result.calls[0].seeds.length, 2, 'one Entity seed and one explicit approved Taste seed'); - assert.match(result.calls[0].seeds[0].payload.sourceRevision, /^sha256:[a-f0-9]{64}$/); + // Caller-supplied seeds precede route-detected subject_seen seeds (route-serial.ts assembly order). assert.deepEqual(result.calls[0].seeds[0], { - kind: 'subject_seen', - producer: 'entity_nudge', - occurredAt: result.calls[0].seeds[0].occurredAt, + kind: 'approved_taste_invoked', + producer: 'owner_message', + occurredAt: 1_788_000_000_000, payload: { - entityId: 'person:alden', - matchedAlias: 'Alden', + triggerKey: 'ELI5', sourceMessageId: 'message-current', - sourceRevision: result.calls[0].seeds[0].payload.sourceRevision, }, }); + assert.match(result.calls[0].seeds[1].payload.sourceRevision, /^sha256:[0-9a-f]{64}$/); assert.deepEqual(result.calls[0].seeds[1], { - kind: 'approved_taste_invoked', - producer: 'owner_message', + kind: 'subject_seen', + producer: 'entity_nudge', occurredAt: result.calls[0].seeds[1].occurredAt, payload: { - triggerKey: 'ELI5', + entityId: 'person:alden', + matchedAlias: 'Alden', sourceMessageId: 'message-current', + sourceRevision: result.calls[0].seeds[1].payload.sourceRevision, }, }); assert.deepEqual(result.calls[0].serverScope, { diff --git a/packages/api/test/memory/input-entity-detector.test.js b/packages/api/test/memory/input-entity-detector.test.js index dcf31b28ed..ea56a76cbb 100644 --- a/packages/api/test/memory/input-entity-detector.test.js +++ b/packages/api/test/memory/input-entity-detector.test.js @@ -632,6 +632,55 @@ describe('InputEntityDetector', () => { }, ]); }); + + it('does not turn a near-verbatim archived thread title into an entity nudge', () => { + const repeatedRequest = '@宪宪 出来打个招呼;另外让缅因猫和狸花猫也出来打个招呼的'; + seedEvidenceDoc(db, { + anchor: 'thread-thread-old', + title: repeatedRequest, + sourcePath: 'threads/thread-old', + }); + seedDocAliases(db, [ + { + alias: repeatedRequest, + docAnchor: 'thread-thread-old', + source: 'doc-title', + }, + ]); + + assert.deepEqual(detector.detect(`${repeatedRequest} 不要用 multi mention 的`), []); + }); + + it('still detects a short intentionally named archived thread', () => { + seedEvidenceDoc(db, { + anchor: 'thread-release-room', + title: 'Release Room', + sourcePath: 'threads/thread-release-room', + }); + seedDocAliases(db, [ + { + alias: 'Release Room', + docAnchor: 'thread-release-room', + source: 'doc-title', + }, + ]); + + const [result] = detector.detect('Please check Release Room before shipping'); + assert.equal(result?.docAnchor, 'thread-release-room'); + }); + + it('still detects a long intentionally named archived thread', () => { + const title = 'Canonical A2A Delivery Lifecycle and Runtime Observation Model'; + seedEvidenceDoc(db, { + anchor: 'thread-long-design', + title, + sourcePath: 'threads/thread-long-design', + }); + seedDocAliases(db, [{ alias: title, docAnchor: 'thread-long-design', source: 'doc-title' }]); + + const [result] = detector.detect(`Please review ${title} before implementation`); + assert.equal(result?.docAnchor, 'thread-long-design'); + }); }); // ─── F260 post-close regression: @ prefix routing mention filter ─── diff --git a/packages/api/test/memory/proactive-memory-candidate-detector.test.js b/packages/api/test/memory/proactive-memory-candidate-detector.test.js index 867cd94414..efe224b482 100644 --- a/packages/api/test/memory/proactive-memory-candidate-detector.test.js +++ b/packages/api/test/memory/proactive-memory-candidate-detector.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from '../helpers/message-from-fixtures.js'; describe('ProactiveMemoryCandidateDetector', () => { let MessageStore; @@ -35,15 +36,17 @@ describe('ProactiveMemoryCandidateDetector', () => { } function append(content, threadId, timestamp, overrides = {}) { - return messageStore.append({ - userId: 'owner-1', - catId: null, - content, - mentions: [], - threadId, - timestamp, - ...overrides, - }); + return messageStore.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content, + mentions: [], + threadId, + timestamp, + ...overrides, + }), + ); } function detector(overrides = {}) { @@ -147,7 +150,10 @@ describe('ProactiveMemoryCandidateDetector', () => { const current = append('Alden', 'thread-public-b', 200); append('Alden', 'thread-private', 210); append('Alden', 'thread-whisper', 220, { visibility: 'whisper' }); - append('Alden', 'thread-connector', 230, { source: { provider: 'feishu', externalMessageId: 'ext-1' } }); + append('Alden', 'thread-connector', 230, { + from: { kind: 'external', connectorId: 'feishu' }, + source: { connector: 'feishu', label: 'Feishu', icon: 'feishu', meta: { externalMessageId: 'ext-1' } }, + }); append('Alden', 'thread-deleted', 235); const deleted = append('Alden', 'thread-message-deleted', 240); messageStore.softDelete(deleted.id, 'owner-1'); diff --git a/packages/api/test/mention-ack.test.js b/packages/api/test/mention-ack.test.js index 27070b6574..80f7558af3 100644 --- a/packages/api/test/mention-ack.test.js +++ b/packages/api/test/mention-ack.test.js @@ -16,12 +16,14 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { parseCursor } = await import('../dist/domains/cats/services/stores/cursor.js'); function createMockSocketManager() { return { broadcastAgentMessage() {}, + emitToUser() {}, getMessages() { return []; }, @@ -33,6 +35,8 @@ describe('Mention Ack (#77)', () => { let messageStore; let deliveryCursorStore; let socketManager; + let invocationQueue; + let queueProcessor; beforeEach(async () => { const { InvocationRegistry } = await import( @@ -40,11 +44,14 @@ describe('Mention Ack (#77)', () => { ); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { DeliveryCursorStore } = await import('../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); registry = new InvocationRegistry(); messageStore = new MessageStore(); deliveryCursorStore = new DeliveryCursorStore(); socketManager = createMockSocketManager(); + invocationQueue = new InvocationQueue(); + queueProcessor = { async requestDrain() {} }; }); async function createApp(extraOpts = {}) { @@ -55,6 +62,8 @@ describe('Mention Ack (#77)', () => { messageStore, socketManager, deliveryCursorStore, + invocationQueue, + queueProcessor, sharedBank: 'cat-cafe-shared', ...extraOpts, }); @@ -62,14 +71,16 @@ describe('Mention Ack (#77)', () => { } function appendMention(threadId, content, ts) { - return messageStore.append({ - userId: 'user-1', - catId: null, - content, - mentions: ['opus'], - timestamp: ts ?? Date.now(), - threadId, - }); + return messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content, + mentions: ['opus'], + timestamp: ts ?? Date.now(), + threadId, + }), + ); } async function getPending(app, invocationId, callbackToken, { includeAcked } = {}) { @@ -188,14 +199,16 @@ describe('Mention Ack (#77)', () => { const m1 = appendMention('thread-A', '@opus valid', 1000); // Message from user-1 mentioning codex (not opus) - const mCodex = messageStore.append({ - userId: 'user-1', - catId: null, - content: '@codex review', - mentions: ['codex'], - timestamp: 2000, - threadId: 'thread-A', - }); + const mCodex = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@codex review', + mentions: ['codex'], + timestamp: 2000, + threadId: 'thread-A', + }), + ); // Message in different thread const mOtherThread = appendMention('thread-B', '@opus other-thread', 3000); @@ -450,7 +463,7 @@ describe('Mention Ack (#77)', () => { }); // ---- Test 10: Auto-ack on enqueue (F27) ---- - test('auto-acks mention cursor when callback enqueues to parent worklist', async () => { + test('does not auto-ack a mention before its canonical Queue carrier is consumed', async () => { const { registerWorklist, unregisterWorklist } = await import( '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' ); @@ -474,34 +487,28 @@ describe('Mention Ack (#77)', () => { assert.equal(r1.statusCode, 200); assert.equal(r1.body.status, 'ok'); - // Ensure the callback path actually enqueued to the parent worklist. - assert.ok(owner.list.includes('codex')); - assert.ok(owner.list.includes('opus')); - const [triggerMessage] = messageStore.getRecent(1, 'user-1'); assert.ok(triggerMessage); // #1200: cursor is now v2 after canonicalization const cursor = await deliveryCursorStore.getMentionAckCursor('user-1', 'opus', threadId); - assert.ok(cursor, 'cursor should exist'); - const parsedCursor = parseCursor(cursor); - assert.equal(parsedCursor.version, 2, 'auto-ack cursor should be v2'); - assert.equal(parsedCursor.id, triggerMessage.id, 'cursor should reference trigger message'); + assert.equal(cursor, undefined, 'Queue admission alone is not body-consumption evidence'); const opus = await registry.create('user-1', 'opus', threadId); const pending = await getPending(app, opus.invocationId, opus.callbackToken); - assert.equal(pending.mentions.length, 0); + assert.equal(pending.mentions.length, 0, 'unconsumed Queue carriers stay outside pending-mention context'); } finally { unregisterWorklist(threadId, owner); } }); // ---- Task 4: Edge case — enqueue twice is monotonic ---- - test('enqueue twice: mention ack cursor is monotonic and pending-mentions stays empty', async () => { + test('enqueue replay does not fabricate a mention-ack cursor', async () => { const { registerWorklist, unregisterWorklist } = await import( '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' ); const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const routerStub = { routeExecution: async function* () {} }; const invocationRecordStoreStub = { @@ -510,6 +517,7 @@ describe('Mention Ack (#77)', () => { }; const app = await createApp({ router: routerStub, invocationRecordStore: invocationRecordStoreStub }); + const invocationQueue = new InvocationQueue(); const threadId = 'thread-enqueue-twice-monotonic'; const owner = registerWorklist(threadId, ['codex'], 5); @@ -522,14 +530,9 @@ describe('Mention Ack (#77)', () => { const [trigger1] = messageStore.getRecent(1, 'user-1'); assert.ok(trigger1); - assert.ok(owner.list.includes('opus')); - // #1200: cursor is now v2 after canonicalization const cursor1 = await deliveryCursorStore.getMentionAckCursor('user-1', 'opus', threadId); - assert.ok(cursor1, 'cursor should exist'); - const parsed1 = parseCursor(cursor1); - assert.equal(parsed1.version, 2, 'cursor should be v2'); - assert.equal(parsed1.id, trigger1.id, 'cursor should reference trigger1'); + assert.equal(cursor1, undefined); const opus1 = await registry.create('user-1', 'opus', threadId); const pending1 = await getPending(app, opus1.invocationId, opus1.callbackToken); @@ -541,6 +544,9 @@ describe('Mention Ack (#77)', () => { invocationRecordStore: invocationRecordStoreStub, socketManager, deliveryCursorStore, + invocationQueue, + queueProcessor: { async requestDrain() {} }, + messageStore, log: app.log, }, { @@ -554,8 +560,7 @@ describe('Mention Ack (#77)', () => { ); const cursor2 = await deliveryCursorStore.getMentionAckCursor('user-1', 'opus', threadId); - assert.ok(cursor2 >= cursor1); - assert.ok(cursor2 >= trigger1.id); + assert.equal(cursor2, undefined); const opus2 = await registry.create('user-1', 'opus', threadId); const pending2 = await getPending(app, opus2.invocationId, opus2.callbackToken); diff --git a/packages/api/test/message-bundle-prompt.test.js b/packages/api/test/message-bundle-prompt.test.js index 6417dc14fc..5abc92cc10 100644 --- a/packages/api/test/message-bundle-prompt.test.js +++ b/packages/api/test/message-bundle-prompt.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { digestMessageBundleQuoteProjection } = await import( '../dist/domains/cats/services/context/MessageSelectionResolver.js' @@ -23,7 +24,7 @@ function buildDeps(messages, sourceThread = { id: 'source-thread', title: 'Sourc } function sourceMessage(overrides = {}) { - return { + return canonicalTestMessageInput({ id: 'source-1', threadId: 'source-thread', userId: 'user-1', @@ -32,7 +33,7 @@ function sourceMessage(overrides = {}) { mentions: [], timestamp: Date.parse('2026-08-12T10:00:00.000Z'), ...overrides, - }; + }); } describe('Message Bundle prompt projection', () => { diff --git a/packages/api/test/message-bundles-route.test.js b/packages/api/test/message-bundles-route.test.js index 891d01ce5a..1576189736 100644 --- a/packages/api/test/message-bundles-route.test.js +++ b/packages/api/test/message-bundles-route.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { messageBundleRoutes } = await import('../dist/routes/message-bundles.js'); const { digestMessageBundleQuoteProjection } = await import( @@ -8,7 +9,7 @@ const { digestMessageBundleQuoteProjection } = await import( ); function targetMessage(overrides = {}) { - return { + return canonicalTestMessageInput({ id: 'bundle-1', threadId: 'target-thread', userId: 'user-1', @@ -25,11 +26,11 @@ function targetMessage(overrides = {}) { }, }, ...overrides, - }; + }); } function sourceMessage(overrides = {}) { - return { + return canonicalTestMessageInput({ id: 'source-1', threadId: 'source-thread', userId: 'user-1', @@ -38,7 +39,7 @@ function sourceMessage(overrides = {}) { mentions: [], timestamp: 100, ...overrides, - }; + }); } describe('GET /api/message-bundles/:messageId', () => { @@ -83,7 +84,7 @@ describe('GET /api/message-bundles/:messageId', () => { assert.equal(deps.messageStore.getById.mock.calls.length, 0); }); - it('hydrates current source truth with Bundle identity, source, author, and exact message ref', async () => { + it('hydrates current source truth with Bundle identity, source, sender, and exact message ref', async () => { const response = await app.inject({ method: 'GET', url: '/api/message-bundles/bundle-1', @@ -104,7 +105,7 @@ describe('GET /api/message-bundles/:messageId', () => { kind: 'message', messageId: 'source-1', sourceThreadId: 'source-thread', - author: { kind: 'cat', catId: 'opus' }, + from: { kind: 'agent', catId: 'opus' }, timestamp: 100, readableContent: 'current source body', }, diff --git a/packages/api/test/message-delivered-at.test.js b/packages/api/test/message-delivered-at.test.js index 9b327f039f..8719467118 100644 --- a/packages/api/test/message-delivered-at.test.js +++ b/packages/api/test/message-delivered-at.test.js @@ -7,20 +7,23 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { MessageStore, generateSortableId } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); describe('MessageStore.markDelivered', () => { test('sets deliveredAt on a queued message', () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'queued message', - mentions: ['opus'], - timestamp: 1000, - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'queued message', + mentions: ['opus'], + timestamp: 1000, + deliveryStatus: 'queued', + }), + ); const now = Date.now(); const updated = store.markDelivered(msg.id, now); @@ -39,14 +42,16 @@ describe('MessageStore.markDelivered', () => { test('deliveredAt is persisted and visible via getById', () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'test', - mentions: [], - timestamp: 1000, - deliveryStatus: 'queued', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'test', + mentions: [], + timestamp: 1000, + deliveryStatus: 'queued', + }), + ); store.markDelivered(msg.id, 5000); @@ -57,13 +62,15 @@ describe('MessageStore.markDelivered', () => { test('deliveredAt field exists on StoredMessage type (not set by default)', () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'immediate message', - mentions: [], - timestamp: 1000, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'immediate message', + mentions: [], + timestamp: 1000, + }), + ); // Immediate messages should NOT have deliveredAt assert.equal(msg.deliveredAt, undefined); @@ -77,30 +84,36 @@ describe('MessageStore.getByThreadAfter', () => { // where a far-future-timestamped message that later gets pruned would // permanently hide all normally-timestamped messages. const store = new MessageStore(); - const beforeCursor = store.append({ - threadId: 'thread-a', - userId: 'u1', - catId: null, - content: 'before cursor', - mentions: [], - timestamp: 1000, - }); - const afterCursor = store.append({ - threadId: 'thread-a', - userId: 'u1', - catId: null, - content: 'after missing cursor', - mentions: [], - timestamp: 2000, - }); - store.append({ - threadId: 'thread-b', - userId: 'u1', - catId: null, - content: 'other thread', - mentions: [], - timestamp: 3000, - }); + const beforeCursor = store.append( + canonicalTestMessageInput({ + threadId: 'thread-a', + userId: 'u1', + catId: null, + content: 'before cursor', + mentions: [], + timestamp: 1000, + }), + ); + const afterCursor = store.append( + canonicalTestMessageInput({ + threadId: 'thread-a', + userId: 'u1', + catId: null, + content: 'after missing cursor', + mentions: [], + timestamp: 2000, + }), + ); + store.append( + canonicalTestMessageInput({ + threadId: 'thread-b', + userId: 'u1', + catId: null, + content: 'other thread', + mentions: [], + timestamp: 3000, + }), + ); const missingCursor = generateSortableId(1500); const results = store.getByThreadAfter('thread-a', missingCursor, 5, 'u1'); diff --git a/packages/api/test/message-from.test.js b/packages/api/test/message-from.test.js new file mode 100644 index 0000000000..899aaccab5 --- /dev/null +++ b/packages/api/test/message-from.test.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { messageFrom, UNPARSEABLE_LEGACY_CONNECTOR_ID } from '../dist/domains/cats/services/stores/message-from.js'; + +const legacy = (overrides = {}) => ({ + userId: 'owner-1', + catId: null, + ...overrides, +}); + +describe('messageFrom', () => { + it('uses canonical MessageFrom without re-inferring identity from compatibility projections', () => { + const from = { kind: 'external', connectorId: 'github', sender: { id: 'octocat', name: 'Octocat' } }; + assert.equal( + messageFrom( + legacy({ + from, + userId: 'system', + catId: 'system', + origin: 'briefing', + source: { connector: 'wrong', label: 'Wrong', icon: 'x' }, + }), + ), + from, + ); + }); + + it('projects a legacy connector source, including sender presentation identity', () => { + assert.deepEqual( + messageFrom( + legacy({ + source: { + connector: 'github', + label: 'GitHub', + icon: 'github', + sender: { id: 'octocat', name: 'Octocat' }, + }, + }), + ), + { kind: 'external', connectorId: 'github', sender: { id: 'octocat', name: 'Octocat' } }, + ); + }); + + it('keeps an unparseable legacy connector outside user authority', () => { + assert.deepEqual(messageFrom(legacy({ sourceParseFailure: true })), { + kind: 'external', + connectorId: UNPARSEABLE_LEGACY_CONNECTOR_ID, + }); + }); + + it('projects legacy system and briefing rows into explicit system services', () => { + assert.deepEqual(messageFrom(legacy({ userId: 'scheduler' })), { + kind: 'system', + service: 'scheduler', + }); + assert.deepEqual(messageFrom(legacy({ userId: 'system', catId: 'system' })), { + kind: 'system', + service: 'system', + }); + assert.deepEqual(messageFrom(legacy({ userId: 'owner-1', catId: 'opus', origin: 'briefing' })), { + kind: 'system', + service: 'legacy-briefing', + }); + }); + + it('projects remaining legacy cat and user rows without pseudo-user string checks at call sites', () => { + assert.deepEqual(messageFrom(legacy({ catId: 'opus' })), { kind: 'agent', catId: 'opus' }); + assert.deepEqual(messageFrom(legacy()), { kind: 'user', userId: 'owner-1' }); + }); +}); diff --git a/packages/api/test/message-lifecycle-content-validation.test.js b/packages/api/test/message-lifecycle-content-validation.test.js new file mode 100644 index 0000000000..ed317f03be --- /dev/null +++ b/packages/api/test/message-lifecycle-content-validation.test.js @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { applyLifecycleTerminal, validateLifecycleQueueEntry } = await import( + '../dist/domains/cats/services/agents/invocation/message-lifecycle-kernel.js' +); + +function inlineEntry(kind, body) { + return { + id: `${kind}-entry`, + threadId: 'thread-1', + kind, + ...(kind === 'conversation_input' ? { sourceRecordId: 'message-1' } : {}), + payload: { type: 'inline', body }, + from: { kind: 'user', userId: 'user-1' }, + targets: kind === 'private_input' ? ['codex'] : [], + ownerAuthProvenance: 'strict', + priority: 'normal', + enqueuedAt: 100, + }; +} + +describe('message lifecycle inline content validation', () => { + it('rejects malformed content blocks for every inline QueueEntry kind', () => { + for (const [kind, body] of [ + ['conversation_input', [{ type: 'text', text: 1 }]], + ['conversation_input', [{ type: 'image', url: '/uploads/../secret.png' }]], + ['private_input', [{ type: 'unknown', value: 'unsafe' }]], + ]) { + assert.equal( + validateLifecycleQueueEntry(inlineEntry(kind, body)).valid, + false, + `${kind} must reject ${body[0].type}`, + ); + } + }); + + it('rejects coerced owner provenance values', () => { + assert.equal( + validateLifecycleQueueEntry({ + ...inlineEntry('conversation_input', [{ type: 'text', text: 'hello' }]), + ownerAuthProvenance: ['strict'], + }).valid, + false, + ); + }); + + it('rejects malformed inline routing warnings', () => { + for (const routingWarnings of [ + { kind: 'cat_not_found', mention: '@missing', alternatives: [] }, + [{ kind: 'cat_disabled', catId: 'codex', displayName: 'Codex', alternatives: 'none' }], + ]) { + const candidate = inlineEntry('conversation_input', [{ type: 'text', text: 'hello' }]); + candidate.payload.routingWarnings = routingWarnings; + assert.equal(validateLifecycleQueueEntry(candidate).valid, false); + } + }); + + it('rejects malformed content before committing a terminal bubble', () => { + const bubble = { + id: 'response-1', + threadId: 'thread-1', + orderKey: '2', + invocationId: 'invocation-1', + targetId: 'codex', + inputEntryIds: ['entry-1'], + inputMessageIds: ['message-1'], + body: [], + status: 'processing', + startedAt: 100, + }; + + assert.deepEqual( + applyLifecycleTerminal(bubble, { + status: 'completed', + body: [ + { + type: 'file', + url: '/uploads/../secret.pdf', + fileName: 'secret.pdf', + mimeType: 'application/pdf', + fileSize: 1, + }, + ], + completedAt: 200, + }), + { outcome: 'conflict', reason: 'invalid_terminal' }, + ); + }); + + it('rejects a non-string terminal reason', () => { + const bubble = { + id: 'response-1', + threadId: 'thread-1', + orderKey: '2', + invocationId: 'invocation-1', + targetId: 'codex', + inputEntryIds: ['entry-1'], + inputMessageIds: ['message-1'], + body: [], + status: 'processing', + startedAt: 100, + }; + + assert.deepEqual( + applyLifecycleTerminal(bubble, { + status: 'failed', + body: [{ type: 'text', text: 'failed' }], + completedAt: 200, + reason: 500, + }), + { outcome: 'conflict', reason: 'invalid_terminal' }, + ); + }); +}); diff --git a/packages/api/test/message-lifecycle-ingress.test.js b/packages/api/test/message-lifecycle-ingress.test.js new file mode 100644 index 0000000000..8447f01248 --- /dev/null +++ b/packages/api/test/message-lifecycle-ingress.test.js @@ -0,0 +1,642 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import Fastify from 'fastify'; +import './helpers/setup-cat-registry.js'; + +const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); +const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); +const { messagesRoutes } = await import('../dist/routes/messages.js'); +const { sendMessageSchema } = await import('../dist/routes/messages.schema.js'); + +function createDependencies(overrides = {}) { + const invocationQueue = new InvocationQueue(); + let messageSequence = 0; + const messagesByIdempotencyKey = new Map(); + const append = mock.fn(async (message) => ({ id: `message-${++messageSequence}`, ...message })); + const appendWithQueueLedgerAdmission = mock.fn(async (message, buildAdmission, ledgerStore, maxQueued) => { + const replay = message.idempotencyKey ? messagesByIdempotencyKey.get(message.idempotencyKey) : null; + if (replay) { + const expected = buildAdmission(replay.id); + const entries = (await Promise.all(expected.map((entry) => ledgerStore.get(entry.threadId, entry.id)))).filter( + Boolean, + ); + return { outcome: 'enqueued', message: replay, entries, deduped: true }; + } + const stored = await append(message); + const entries = buildAdmission(stored.id); + const admitted = await ledgerStore.enqueue(entries, maxQueued); + if (admitted.outcome === 'full') return { outcome: 'full' }; + assert.equal(admitted.outcome, 'enqueued'); + if (message.idempotencyKey) messagesByIdempotencyKey.set(message.idempotencyKey, stored); + return { outcome: 'enqueued', message: stored, entries: admitted.entries, deduped: false }; + }); + return { + registry: new InvocationRegistry(), + invocationQueue, + messageStore: { + append, + appendWithQueueLedgerAdmission, + getById: mock.fn(async () => null), + getByIdempotencyKey: mock.fn(async (_userId, _threadId, key) => messagesByIdempotencyKey.get(key) ?? null), + getByThread: mock.fn(async () => []), + getByThreadBefore: mock.fn(async () => []), + getByThreadAfter: mock.fn(async () => []), + }, + socketManager: { + broadcastAgentMessage: mock.fn(), + broadcastToRoom: mock.fn(), + emitToUser: mock.fn(), + }, + router: { + resolveTargetsAndIntent: mock.fn(async () => ({ + targetCats: ['opus'], + intent: { intent: 'execute' }, + })), + resolveExplicitTargets: mock.fn(async (cats) => cats), + resolveConversationTargetsAtAdmission: mock.fn(async (cats) => (cats.length > 0 ? cats : ['opus'])), + routeExecution: mock.fn(async function* () { + yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + }), + ackCollectedCursors: mock.fn(async () => {}), + route: mock.fn(async function* () { + yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + }), + }, + invocationTracker: { + has: mock.fn(() => false), + tryStartThreadAll: mock.fn(() => new AbortController()), + startAll: mock.fn(() => new AbortController()), + completeAll: mock.fn(), + isDeleting: mock.fn(() => false), + }, + invocationRecordStore: { + create: mock.fn(async () => ({ outcome: 'created', invocationId: 'invocation-1' })), + update: mock.fn(async () => ({})), + get: mock.fn(async () => null), + }, + queueProcessor: { + tryAutoAppendExactEntry: mock.fn(async () => ({ outcome: 'rejected', reason: 'append_unavailable' })), + requestDrain: mock.fn(async () => {}), + }, + threadStore: { + get: mock.fn(async () => ({ id: 'thread-1', createdBy: 'user-1' })), + updateTitle: mock.fn(async () => {}), + }, + ...overrides, + }; +} + +describe('canonical message lifecycle ingress', () => { + let app; + let dependencies; + + beforeEach(async () => { + dependencies = createDependencies(); + app = Fastify(); + await app.register(messagesRoutes, dependencies); + await app.ready(); + }); + + afterEach(async () => { + await app?.close(); + }); + + it('queues an ordinary idle-thread input instead of invoking the provider directly', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: 'single ingress', threadId: 'thread-1' }, + }); + + assert.equal(response.statusCode, 202, response.body); + const body = JSON.parse(response.body); + assert.equal(body.status, 'queued'); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.from, { kind: 'user', userId: 'user-1' }); + assert.equal('source' in entry, false, 'Queue sender identity must have one MessageFrom truth'); + const appended = dependencies.messageStore.append.mock.calls[0].arguments[0]; + assert.deepEqual(appended.from, { kind: 'user', userId: 'user-1' }); + assert.equal(appended.provenance?.author, undefined, 'History provenance must not duplicate MessageFrom'); + assert.equal(dependencies.invocationRecordStore.create.mock.calls.length, 0); + assert.equal(dependencies.router.routeExecution.mock.calls.length, 0); + assert.equal(dependencies.queueProcessor.requestDrain.mock.calls.length, 1); + }); + + it('offers an explicitly guided exact running target to auto-append', async () => { + dependencies.invocationTracker.has.mock.mockImplementation(() => true); + dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); + dependencies.invocationTracker.getExecutionId = mock.fn(() => 'parent-1'); + dependencies.router.freshnessCarrierCapability = mock.fn(() => ({ + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + })); + dependencies.queueProcessor.tryAutoAppendExactEntry.mock.mockImplementation(async () => ({ + outcome: 'appended', + entry: null, + acceptedTargetIds: ['opus'], + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'append to the current writer', + threadId: 'thread-1', + messageDisposition: 'continue_current', + }, + }); + + assert.equal(response.statusCode, 202, response.body); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.delivery.authorIntentByTarget.opus, { + requested: 'continue_current', + boundParentInvocationId: 'parent-1', + carrierCapability: { + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + }, + }); + assert.deepEqual(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls[0].arguments, [ + { threadId: 'thread-1', userId: 'user-1', entryId: entry.id, targetCatId: 'opus' }, + ]); + }); + + it('offers every admitted target independently and returns every exact target identity', async () => { + dependencies.router.resolveExplicitTargets.mock.mockImplementation(async (cats) => cats); + dependencies.invocationTracker.has.mock.mockImplementation(() => true); + dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); + dependencies.invocationTracker.getExecutionId = mock.fn((_threadId, catId) => `parent-${catId}`); + dependencies.router.freshnessCarrierCapability = mock.fn(() => ({ + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + })); + dependencies.queueProcessor.tryAutoAppendExactEntry.mock.mockImplementation(async () => ({ + outcome: 'appended', + acceptedTargetIds: ['opus', 'codex'], + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'guide both replies', + threadId: 'thread-1', + mentions: ['opus', 'codex'], + messageDisposition: 'continue_current', + }, + }); + + assert.equal(response.statusCode, 202, response.body); + const body = response.json(); + assert.equal(body.entries.length, 2); + assert.deepEqual( + body.entries.map((entry) => entry.targetCatId), + ['opus', 'codex'], + ); + assert.deepEqual( + dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.map((call) => call.arguments[0]), + [ + { threadId: 'thread-1', userId: 'user-1', entryId: body.entries[0].entryId, targetCatId: 'opus' }, + { threadId: 'thread-1', userId: 'user-1', entryId: body.entries[0].entryId, targetCatId: 'codex' }, + ], + ); + assert.equal(new Set(body.entries.map((entry) => entry.entryId)).size, 1); + }); + + it('preserves an explicit next-work request without offering it to auto-append', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: 'do this later', threadId: 'thread-1', messageDisposition: 'next_work' }, + }); + + assert.equal(response.statusCode, 202, response.body); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.equal(entry.delivery.authorIntentByTarget.opus.requested, 'next_work'); + assert.equal(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); + }); + + it('binds an ordinary unmentioned input to the canonical conversation fallback before enqueue', async () => { + dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async (_content, _threadId, options) => { + assert.equal(options.allowFallback, false); + return { + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + routing_warnings: [], + }; + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: 'continue without choosing a member yet', threadId: 'thread-1' }, + }); + + assert.equal(response.statusCode, 202, response.body); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.targets, ['opus']); + assert.deepEqual(dependencies.router.resolveConversationTargetsAtAdmission.mock.calls[0].arguments, [ + [], + 'thread-1', + ]); + assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].mentions, []); + assert.deepEqual(response.json().entries, [{ entryId: entry.id, targetCatId: 'opus' }]); + assert.equal(dependencies.queueProcessor.requestDrain.mock.calls.length, 1); + }); + + it('guides the exact active fallback without rewriting an unmentioned source message', async () => { + dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async (_content, _threadId, options) => { + assert.equal(options.allowFallback, false); + return { + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + routing_warnings: [], + }; + }); + dependencies.router.resolveConversationTargetsAtAdmission = mock.fn(async () => ['opus']); + dependencies.router.freshnessCarrierCapability = mock.fn(() => ({ + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + })); + dependencies.invocationTracker.has.mock.mockImplementation(() => true); + dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); + dependencies.invocationTracker.getExecutionId = mock.fn(() => 'parent-1'); + dependencies.queueProcessor.tryAutoAppendExactEntry.mock.mockImplementation(async () => ({ + outcome: 'appended', + acceptedTargetIds: ['opus'], + })); + const participants = []; + dependencies.threadStore.get.mock.mockImplementation(async () => ({ + id: 'thread-1', + createdBy: 'user-1', + participants: [...participants], + })); + dependencies.threadStore.addParticipants = mock.fn(async (_threadId, catIds) => { + for (const catId of catIds) if (!participants.includes(catId)) participants.push(catId); + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'guide whoever is already replying', + threadId: 'thread-1', + messageDisposition: 'continue_current', + }, + }); + + assert.equal(response.statusCode, 202, response.body); + assert.deepEqual(dependencies.router.resolveConversationTargetsAtAdmission.mock.calls[0].arguments, [ + [], + 'thread-1', + ]); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.targets, ['opus']); + assert.deepEqual(entry.delivery.authorIntentByTarget.opus, { + requested: 'continue_current', + boundParentInvocationId: 'parent-1', + carrierCapability: { + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + }, + }); + assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].mentions, []); + assert.deepEqual(dependencies.threadStore.addParticipants.mock.calls[0].arguments, ['thread-1', ['opus']]); + assert.deepEqual(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls[0].arguments, [ + { threadId: 'thread-1', userId: 'user-1', entryId: entry.id, targetCatId: 'opus' }, + ]); + assert.deepEqual(response.json().entries, [{ entryId: entry.id, targetCatId: 'opus' }]); + }); + + it('keeps the canonical fallback target queued when it has no current reply to guide', async () => { + dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + routing_warnings: [], + })); + dependencies.router.resolveConversationTargetsAtAdmission = mock.fn(async () => ['opus']); + dependencies.router.freshnessCarrierCapability = mock.fn(() => ({ + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'queue when nobody is replying', + threadId: 'thread-1', + messageDisposition: 'continue_current', + }, + }); + + assert.equal(response.statusCode, 202, response.body); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.targets, ['opus']); + assert.equal(entry.delivery.authorIntentByTarget.opus.fallbackReason, 'no_active_parent'); + assert.deepEqual(response.json().entries, [{ entryId: entry.id, targetCatId: 'opus' }]); + assert.deepEqual(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls[0].arguments, [ + { threadId: 'thread-1', userId: 'user-1', entryId: entry.id, targetCatId: 'opus' }, + ]); + }); + + it('records a truthful queued fallback when the current reply ends before automatic Append', async () => { + dependencies.invocationTracker.has.mock.mockImplementation(() => true); + dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); + dependencies.invocationTracker.getExecutionId = mock.fn(() => 'parent-1'); + dependencies.router.freshnessCarrierCapability = mock.fn(() => ({ + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'queue if the reply closes at the cutover', + threadId: 'thread-1', + mentions: ['opus'], + messageDisposition: 'continue_current', + }, + }); + + assert.equal(response.statusCode, 202, response.body); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.equal(entry.delivery.authorIntentByTarget.opus.requested, 'continue_current'); + assert.equal(entry.delivery.authorIntentByTarget.opus.boundParentInvocationId, 'parent-1'); + assert.equal(entry.delivery.authorIntentByTarget.opus.fallbackReason, 'parent_terminal_before_exposure'); + assert.equal(typeof entry.delivery.authorIntentByTarget.opus.fallbackAt, 'number'); + }); + + it('routes a composer-selected member through the explicit target field without rewriting visible content', async () => { + dependencies.router.resolveExplicitTargets.mock.mockImplementation(async (cats) => cats); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: '正文里不需要补一个 @ 提及', threadId: 'thread-1', mentions: ['codex'] }, + }); + + assert.equal(response.statusCode, 202, response.body); + assert.equal(dependencies.router.resolveTargetsAndIntent.mock.calls.length, 0); + assert.deepEqual(dependencies.router.resolveExplicitTargets.mock.calls[0].arguments[0], ['codex']); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.targets, ['codex']); + assert.equal(entry.payload.content, '正文里不需要补一个 @ 提及'); + assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].mentions, ['codex']); + }); + + it('rejects an unavailable composer-selected member instead of silently falling back', async () => { + dependencies.router.resolveExplicitTargets.mock.mockImplementation(async () => []); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: 'do not reroute this', threadId: 'thread-1', mentions: ['codex'] }, + }); + + assert.equal(response.statusCode, 400, response.body); + assert.equal(JSON.parse(response.body).code, 'INVALID_EXPLICIT_TARGETS'); + assert.equal(dependencies.invocationQueue.list('thread-1', 'user-1').length, 0); + assert.equal(dependencies.messageStore.append.mock.calls.length, 0); + }); + + it('keeps routing warnings on the canonical Queue/source payload instead of broadcasting a detached notice', async () => { + const warning = { + kind: 'cat_not_found', + mention: '@missing-cat', + alternatives: [], + }; + dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: true, + routing_warnings: [warning], + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: '@missing-cat please inspect this', threadId: 'thread-1' }, + }); + + assert.equal(response.statusCode, 202, response.body); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.targets, [], 'an invalid authored mention must not become an ordinary fallback send'); + assert.equal(dependencies.router.resolveConversationTargetsAtAdmission.mock.calls.length, 0); + assert.deepEqual(entry.payload.routingWarnings, [warning]); + assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].extra.routingWarnings, [warning]); + assert.equal(dependencies.socketManager.broadcastAgentMessage.mock.calls.length, 0); + }); + + it('does not expose direct immediate or force execution on message creation', () => { + for (const deliveryMode of ['immediate', 'force']) { + const result = sendMessageSchema.safeParse({ content: 'no direct path', deliveryMode }); + assert.equal(result.success, false, `${deliveryMode} must not be a message-ingress mode`); + } + }); + + it('deduplicates replay at the Queue boundary without a second source record', async () => { + const request = { + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'same work', + threadId: 'thread-1', + idempotencyKey: '11111111-1111-4111-8111-111111111111', + }, + }; + + const first = await app.inject(request); + const replay = await app.inject(request); + + assert.equal(first.statusCode, 202, first.body); + assert.equal(replay.statusCode, 202, replay.body); + assert.equal(JSON.parse(replay.body).entryId, JSON.parse(first.body).entryId); + assert.equal(dependencies.invocationQueue.list('thread-1', 'user-1').length, 1); + assert.equal(dependencies.messageStore.append.mock.calls.length, 1); + }); + + it('replays the originally admitted no-mention target after the conversation fallback changes', async () => { + dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + routing_warnings: [], + })); + let fallbackTarget = 'opus'; + dependencies.router.resolveConversationTargetsAtAdmission = mock.fn(async () => [fallbackTarget]); + const request = { + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'same no-mention work', + threadId: 'thread-1', + idempotencyKey: '22222222-2222-4222-8222-222222222222', + }, + }; + + const first = await app.inject(request); + fallbackTarget = 'codex'; + const replay = await app.inject(request); + + assert.equal(first.statusCode, 202, first.body); + assert.equal(replay.statusCode, 202, replay.body); + assert.equal(dependencies.router.resolveConversationTargetsAtAdmission.mock.calls.length, 1); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.deepEqual(entry.targets, ['opus']); + assert.deepEqual(replay.json().entries, [{ entryId: entry.id, targetCatId: 'opus' }]); + assert.equal(dependencies.messageStore.append.mock.calls.length, 1); + }); + + it('keeps whisper visibility while routing an idle target through the same Queue', async () => { + dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + targetCats: ['codex'], + intent: { intent: 'execute' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'private input', + threadId: 'thread-1', + visibility: 'whisper', + whisperTo: ['codex'], + }, + }); + + assert.equal(response.statusCode, 202, response.body); + assert.deepEqual(dependencies.invocationQueue.list('thread-1', 'user-1')[0].targets, ['codex']); + assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].whisperTo, ['codex']); + assert.equal(dependencies.router.routeExecution.mock.calls.length, 0); + }); + + it('keeps an all-idle multi-target input in one source Queue row', async () => { + dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + targetCats: ['opus', 'codex'], + intent: { intent: 'execute' }, + hasMentions: true, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: '@opus @codex inspect', threadId: 'thread-1' }, + }); + + assert.equal(response.statusCode, 202, response.body); + const entries = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0].targets.toSorted(), ['codex', 'opus']); + assert.ok(entries.every((entry) => entry.payload.messageId === entries[0].payload.messageId)); + assert.equal(dependencies.invocationRecordStore.create.mock.calls.length, 0); + }); + + it('admits a refs-only Message Bundle into the same Queue ingress', async () => { + const sourceMessage = { + id: 'source-message-1', + threadId: 'source-thread', + userId: 'user-1', + from: { kind: 'agent', catId: 'opus' }, + catId: 'opus', + content: 'private source body', + mentions: [], + timestamp: 1000, + }; + dependencies.messageStore.getById.mock.mockImplementation(async (messageId) => + messageId === sourceMessage.id ? sourceMessage : null, + ); + dependencies.messageStore.getByThreadAfter.mock.mockImplementation(async (threadId) => + threadId === sourceMessage.threadId ? [sourceMessage] : [], + ); + dependencies.threadStore.get.mock.mockImplementation(async (threadId) => ({ + id: threadId, + title: threadId === 'source-thread' ? 'Source Thread' : 'Target Thread', + createdBy: 'user-1', + participants: [], + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: '', + threadId: 'thread-1', + messageBundle: { + sourceThreadId: 'source-thread', + note: 'focus on the decision', + items: [{ kind: 'message', messageId: sourceMessage.id }], + targetCats: ['opus'], + }, + }, + }); + + assert.equal(response.statusCode, 202, response.body); + assert.equal(dependencies.router.resolveTargetsAndIntent.mock.calls.length, 0); + assert.deepEqual(dependencies.router.resolveExplicitTargets.mock.calls[0].arguments[0], ['opus']); + const stored = dependencies.messageStore.append.mock.calls[0].arguments[0]; + assert.deepEqual(stored.extra.messageBundle.items, [{ kind: 'message', messageId: sourceMessage.id }]); + assert.equal(stored.content.includes(sourceMessage.content), false); + assert.equal(dependencies.router.routeExecution.mock.calls.length, 0); + }); + + it('detects magic words against the durable Queue source identity', async () => { + await app.close(); + const detected = []; + dependencies = createDependencies({ + onMagicWordDetected: (hits, threadId, catId, messageId, ownerUserId) => { + detected.push({ hits, threadId, catId, messageId, ownerUserId }); + }, + }); + app = Fastify(); + await app.register(messagesRoutes, dependencies); + await app.ready(); + + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: '这个方案是脚手架', threadId: 'thread-1' }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(response.statusCode, 202, response.body); + assert.equal(detected.length, 1); + assert.equal(detected[0].messageId, 'message-1'); + assert.equal(detected[0].ownerUserId, 'user-1'); + }); +}); diff --git a/packages/api/test/message-lifecycle-kernel.test.js b/packages/api/test/message-lifecycle-kernel.test.js new file mode 100644 index 0000000000..8e0645d84d --- /dev/null +++ b/packages/api/test/message-lifecycle-kernel.test.js @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { + advanceDispatchRef, + applyLifecycleTerminal, + applyVisibleQueueOrder, + compareLifecycleQueueEntries, + validateLifecycleQueueEntry, +} = await import('../dist/domains/cats/services/agents/invocation/message-lifecycle-kernel.js'); + +const inline = (text) => ({ type: 'inline', body: [{ type: 'text', text }] }); + +function conversationEntry(overrides = {}) { + return { + id: 'entry-a', + threadId: 'thread-1', + kind: 'conversation_input', + sourceRecordId: 'message-a', + payload: inline('hello'), + from: { kind: 'user', userId: 'user-1' }, + targets: [], + ownerAuthProvenance: 'strict', + priority: 'normal', + enqueuedAt: 100, + ...overrides, + }; +} + +describe('message lifecycle QueueEntry contract', () => { + it('accepts only the three legal kind/payload/target combinations', () => { + assert.deepEqual(validateLifecycleQueueEntry(conversationEntry()), { valid: true }); + assert.deepEqual( + validateLifecycleQueueEntry( + conversationEntry({ + id: 'wake', + kind: 'message_wake', + sourceRecordId: undefined, + payload: { type: 'message_ref', messageId: 'history-1' }, + from: { kind: 'agent', catId: 'opus' }, + targets: ['codex'], + }), + ), + { valid: true }, + ); + assert.deepEqual( + validateLifecycleQueueEntry( + conversationEntry({ + id: 'private', + kind: 'private_input', + sourceRecordId: undefined, + targets: ['codex'], + }), + ), + { valid: true }, + ); + + assert.equal( + validateLifecycleQueueEntry(conversationEntry({ kind: 'message_wake', sourceRecordId: undefined })).valid, + false, + 'message_wake cannot carry inline content', + ); + assert.equal( + validateLifecycleQueueEntry(conversationEntry({ kind: 'private_input', sourceRecordId: undefined, targets: [] })) + .valid, + false, + 'private_input must have an exact target', + ); + assert.equal( + validateLifecycleQueueEntry( + conversationEntry({ + kind: 'private_input', + sourceRecordId: undefined, + targets: ['codex'], + position: 0, + }), + ).valid, + false, + 'hidden private_input cannot carry a client-visible position', + ); + assert.equal( + validateLifecycleQueueEntry( + conversationEntry({ + kind: 'conversation_input', + payload: { type: 'message_ref', messageId: 'history-1' }, + }), + ).valid, + false, + 'conversation_input cannot refer to an existing History message', + ); + assert.equal( + validateLifecycleQueueEntry( + conversationEntry({ from: { kind: 'external', connectorId: 'github', sender: { name: 'missing-id' } } }), + ).valid, + false, + 'external actors remain in a validated connector/sender namespace', + ); + }); +}); + +describe('visible Queue reorder reducer', () => { + const privateEntry = conversationEntry({ + id: 'private', + kind: 'private_input', + sourceRecordId: undefined, + targets: ['codex'], + enqueuedAt: 25, + }); + const entries = [ + conversationEntry({ id: 'v1', enqueuedAt: 10 }), + privateEntry, + conversationEntry({ id: 'v2', enqueuedAt: 20 }), + conversationEntry({ id: 'v3', enqueuedAt: 30 }), + ]; + + it('atomically replaces the complete visible order without addressing hidden entries', () => { + const result = applyVisibleQueueOrder( + { revision: 'r1', entries, reorderableVisibleEntryIds: ['v1', 'v2', 'v3'] }, + { threadId: 'thread-1', expectedQueueRevision: 'r1', orderedVisibleEntryIds: ['v1', 'v3', 'v2'] }, + 'r2', + ); + + assert.equal(result.outcome, 'applied'); + assert.equal(result.snapshot.revision, 'r2'); + assert.deepEqual( + result.snapshot.entries.sort(compareLifecycleQueueEntries).map((entry) => entry.id), + ['v1', 'v3', 'v2', 'private'], + ); + assert.equal(result.snapshot.entries.find((entry) => entry.id === 'private').position, undefined); + }); + + it('fails closed on stale revision, duplicate ids, hidden ids, or an incomplete visible set', () => { + const snapshot = { revision: 'r1', entries, reorderableVisibleEntryIds: ['v1', 'v2', 'v3'] }; + for (const [expectedQueueRevision, orderedVisibleEntryIds, reason] of [ + ['stale', ['v1', 'v2', 'v3'], 'stale_revision'], + ['r1', ['v1', 'v1', 'v3'], 'invalid_order'], + ['r1', ['v1', 'private', 'v2', 'v3'], 'visible_set_changed'], + ['r1', ['v1', 'v2'], 'visible_set_changed'], + ]) { + const result = applyVisibleQueueOrder( + snapshot, + { threadId: 'thread-1', expectedQueueRevision, orderedVisibleEntryIds }, + 'r2', + ); + assert.deepEqual(result, { outcome: 'conflict', reason }); + } + assert.ok( + entries.every((entry) => entry.position === undefined), + 'conflicts must not partially mutate input', + ); + assert.deepEqual( + applyVisibleQueueOrder( + snapshot, + { + threadId: 'thread-1', + expectedQueueRevision: 'r1', + orderedVisibleEntryIds: ['v1', 'v2', 'v3'], + }, + 'r1', + ), + { outcome: 'conflict', reason: 'invalid_revision' }, + ); + }); + + it('rejects invalid or duplicate canonical snapshot identities before assigning positions', () => { + const duplicated = [ + conversationEntry({ id: 'duplicate', enqueuedAt: 10 }), + conversationEntry({ id: 'duplicate', enqueuedAt: 20 }), + ]; + assert.deepEqual( + applyVisibleQueueOrder( + { revision: 'r1', entries: duplicated, reorderableVisibleEntryIds: ['duplicate'] }, + { threadId: 'thread-1', expectedQueueRevision: 'r1', orderedVisibleEntryIds: ['duplicate'] }, + 'r2', + ), + { outcome: 'conflict', reason: 'invalid_snapshot' }, + ); + + assert.deepEqual( + applyVisibleQueueOrder( + { + revision: 'r1', + entries: [ + conversationEntry({ id: 'visible', enqueuedAt: 20 }), + conversationEntry({ + id: 'hidden', + kind: 'private_input', + sourceRecordId: undefined, + targets: ['codex'], + position: 0, + enqueuedAt: 10, + }), + ], + reorderableVisibleEntryIds: ['visible'], + }, + { threadId: 'thread-1', expectedQueueRevision: 'r1', orderedVisibleEntryIds: ['visible'] }, + 'r2', + ), + { outcome: 'conflict', reason: 'invalid_snapshot' }, + ); + }); +}); + +describe('derived ref and terminal reducers', () => { + it('allows only monotonic dispatch-ref transitions and idempotent replay', () => { + const dispatched = { + targetId: 'codex', + phase: 'dispatched', + statusMessageId: 'response-1', + dispatchedAt: 100, + }; + const settled = { ...dispatched, phase: 'settled' }; + const legacyDispatched = { ...dispatched, dispatchedAt: undefined }; + + assert.deepEqual(advanceDispatchRef(dispatched, settled), { outcome: 'applied', ref: settled }); + assert.deepEqual(advanceDispatchRef(legacyDispatched, dispatched), { + outcome: 'applied', + ref: dispatched, + }); + assert.deepEqual(advanceDispatchRef(settled, settled), { outcome: 'replayed', ref: settled }); + assert.equal(advanceDispatchRef(settled, dispatched).outcome, 'conflict'); + assert.equal(advanceDispatchRef(dispatched, { ...settled, targetId: 'opus' }).outcome, 'conflict'); + assert.equal( + advanceDispatchRef(dispatched, { + targetId: 'codex', + phase: 'dispatched', + statusMessageId: 'response-2', + dispatchedAt: 100, + }).outcome, + 'conflict', + 'one target cannot be relinked to a different response bubble', + ); + assert.equal( + advanceDispatchRef(dispatched, { ...settled, dispatchedAt: 101 }).outcome, + 'conflict', + 'the actual delivery timestamp cannot be rewritten at settlement', + ); + }); + + it('commits one terminal per invocation and replays only the same fact', () => { + const bubble = { + id: 'response-1', + threadId: 'thread-1', + orderKey: '2', + invocationId: 'invocation-1', + targetId: 'codex', + inputEntryIds: ['entry-a'], + inputMessageIds: ['message-a'], + body: [], + status: 'processing', + startedAt: 100, + }; + const terminal = { status: 'failed', body: [{ type: 'text', text: 'partial' }], completedAt: 200, reason: 'boom' }; + const first = applyLifecycleTerminal(bubble, terminal); + assert.equal(first.outcome, 'applied'); + assert.deepEqual(applyLifecycleTerminal(first.bubble, terminal), { outcome: 'replayed', bubble: first.bubble }); + assert.equal(applyLifecycleTerminal(first.bubble, { ...terminal, status: 'completed' }).outcome, 'conflict'); + assert.equal( + applyLifecycleTerminal(bubble, { ...terminal, status: 'processing' }).outcome, + 'conflict', + 'runtime validation must reject a non-terminal status even for untyped callers', + ); + }); +}); diff --git a/packages/api/test/message-lifecycle-legacy-parser.test.js b/packages/api/test/message-lifecycle-legacy-parser.test.js new file mode 100644 index 0000000000..a9341989b5 --- /dev/null +++ b/packages/api/test/message-lifecycle-legacy-parser.test.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { safeParseLifecycleMetadata } = await import( + '../dist/domains/cats/services/stores/redis/redis-message-parsers.js' +); + +describe('message lifecycle legacy parser', () => { + it('drops v1 assigned intent while preserving actual delivery evidence', () => { + const parsed = safeParseLifecycleMetadata( + JSON.stringify({ + kind: 'input', + orderKey: '100:source', + dispatchRefs: [ + { targetId: 'never-delivered', phase: 'assigned' }, + { targetId: 'opus', phase: 'dispatched', statusMessageId: 'response-opus' }, + { targetId: 'codex', phase: 'settled', statusMessageId: 'response-codex', dispatchedAt: 200 }, + ], + }), + ); + + assert.deepEqual(parsed?.dispatchRefs, [ + { targetId: 'opus', phase: 'dispatched', statusMessageId: 'response-opus' }, + { targetId: 'codex', phase: 'settled', statusMessageId: 'response-codex', dispatchedAt: 200 }, + ]); + }); +}); diff --git a/packages/api/test/message-lifecycle-queue-order.test.js b/packages/api/test/message-lifecycle-queue-order.test.js new file mode 100644 index 0000000000..d66fc57c6a --- /dev/null +++ b/packages/api/test/message-lifecycle-queue-order.test.js @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { compareLifecycleQueueEntries } = await import( + '../dist/domains/cats/services/agents/invocation/message-lifecycle-queue-order.js' +); + +describe('canonical lifecycle Queue order', () => { + it('sorts only by position, priority, FIFO time, and id', () => { + const entries = [ + { id: 'normal-old', priority: 'normal', enqueuedAt: 10 }, + { id: 'urgent-new', priority: 'urgent', enqueuedAt: 30 }, + { id: 'urgent-old-z', priority: 'urgent', enqueuedAt: 20 }, + { id: 'urgent-old-a', priority: 'urgent', enqueuedAt: 20 }, + { id: 'positioned', priority: 'normal', position: 4, enqueuedAt: 40 }, + ]; + + assert.deepEqual( + entries.sort(compareLifecycleQueueEntries).map((entry) => entry.id), + ['positioned', 'urgent-old-a', 'urgent-old-z', 'urgent-new', 'normal-old'], + ); + }); +}); diff --git a/packages/api/test/message-lifecycle-redis-store.test.js b/packages/api/test/message-lifecycle-redis-store.test.js new file mode 100644 index 0000000000..bcd21e8a29 --- /dev/null +++ b/packages/api/test/message-lifecycle-redis-store.test.js @@ -0,0 +1,420 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; +import { + assertRedisIsolationOrThrow, + cleanupPrefixedRedisKeys, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; + +function canonicalFixture(input) { + return canonicalTestMessageInput(input); +} + +describe( + 'RedisMessageStore lifecycle pre-admission failure transaction', + { + skip: redisIsolationSkipReason(REDIS_URL), + }, + () => { + let redis; + let store; + let RedisMessageStore; + let connected = false; + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'RedisMessageStore lifecycle pre-admission failure transaction'); + const [{ createRedisClient }, messageStoreModule] = await Promise.all([ + import('@cat-cafe/shared/utils'), + import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'), + ]); + ({ RedisMessageStore } = messageStoreModule); + redis = createRedisClient({ url: REDIS_URL }); + try { + await redis.ping(); + connected = true; + store = new RedisMessageStore(redis, { ttlSeconds: 0 }); + } catch { + await redis.quit().catch(() => {}); + } + }); + + beforeEach(async (t) => { + if (!connected) return t.skip('Redis not connected'); + await cleanupPrefixedRedisKeys(redis, ['msg:*']); + }); + + after(async () => { + if (!connected) return; + await cleanupPrefixedRedisKeys(redis, ['msg:*']); + await redis.quit(); + }); + + test('atomically attaches one Queue input to every Redis-backed processing Active Run', async () => { + const input = await store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId: 'thread-redis-append', + catId: null, + content: 'append this', + mentions: ['opus', 'codex'], + timestamp: 90, + }), + ); + const response = (targetId, invocationId) => + store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId: 'thread-redis-append', + catId: targetId, + content: '', + mentions: [], + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: `100:${invocationId}`, + from: { kind: 'agent', catId: targetId }, + invocationId, + targetId, + inputEntryIds: ['entry-old'], + inputMessageIds: ['message-old'], + status: 'processing', + startedAt: 100, + }, + }), + ); + const [opus, codex] = await Promise.all([response('opus', 'turn-opus'), response('codex', 'turn-codex')]); + const admission = { + threadId: 'thread-redis-append', + entryId: 'entry-append', + inputMessageIds: [input.id], + runs: [ + { targetId: 'opus', invocationId: 'turn-opus', responseMessageId: opus.id, dispatchedAt: 101 }, + { targetId: 'codex', invocationId: 'turn-codex', responseMessageId: codex.id, dispatchedAt: 102 }, + ], + }; + + assert.equal((await store.commitLifecycleAppendAdmission(admission)).kind, 'applied'); + assert.deepEqual((await store.getById(input.id)).lifecycle.dispatchRefs, [ + { targetId: 'opus', phase: 'dispatched', statusMessageId: opus.id, dispatchedAt: 101 }, + { targetId: 'codex', phase: 'dispatched', statusMessageId: codex.id, dispatchedAt: 102 }, + ]); + assert.deepEqual((await store.getById(opus.id)).lifecycle.inputEntryIds, ['entry-old', 'entry-append']); + assert.deepEqual((await store.getById(codex.id)).lifecycle.inputMessageIds, ['message-old', input.id]); + assert.equal((await store.commitLifecycleAppendAdmission(admission)).kind, 'replayed'); + + const failure = await store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId: 'thread-redis-append', + catId: null, + content: 'codex carrier closed', + mentions: [], + timestamp: 110, + lifecycle: { + kind: 'delivery_failure', + orderKey: '110:failure-codex', + from: { kind: 'system', service: 'message_delivery' }, + status: 'failed', + sourceEntryId: 'entry-append', + inputMessageId: input.id, + requestedTargets: ['codex'], + reason: 'control_carrier_replaced', + createdAt: 110, + }, + }), + ); + const rejection = { + threadId: 'thread-redis-append', + entryId: 'entry-append', + inputMessageIds: [input.id], + failureMessageIds: [failure.id], + run: { targetId: 'codex', invocationId: 'turn-codex', responseMessageId: codex.id }, + }; + assert.equal((await store.commitLifecycleAppendRejection(rejection)).kind, 'applied'); + assert.deepEqual((await store.getById(input.id)).lifecycle.dispatchRefs, [ + { targetId: 'opus', phase: 'dispatched', statusMessageId: opus.id, dispatchedAt: 101 }, + { targetId: 'codex', phase: 'settled', statusMessageId: failure.id, dispatchedAt: 102 }, + ]); + assert.deepEqual((await store.getById(codex.id)).lifecycle.inputMessageIds, ['message-old']); + assert.equal((await store.commitLifecycleAppendRejection(rejection)).kind, 'replayed'); + }); + + test('publishes a lifecycle response to cursor reads only with its terminal body', async () => { + const threadId = 'thread-redis-terminal-visibility'; + const processing = ( + await store.appendAndObservePriorFrontier( + canonicalFixture({ + userId: 'owner-redis', + threadId, + catId: 'opus', + content: '', + mentions: [], + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: '100:terminal-visibility', + from: { kind: 'agent', catId: 'opus' }, + invocationId: 'inv-terminal-visibility', + targetId: 'opus', + inputEntryIds: ['entry-terminal-visibility'], + inputMessageIds: ['message-terminal-visibility'], + status: 'processing', + startedAt: 100, + }, + }), + ) + ).message; + + assert.equal((await redis.hget(`msg:${processing.id}`, 'visibilitySeq')) ?? null, null); + assert.deepEqual(await store.getByThreadAfter(threadId, undefined, undefined, 'owner-redis'), []); + + const terminal = await store.commitLifecycleResponseTerminal(processing.id, { + invocationId: 'inv-terminal-visibility', + status: 'failed', + completedAt: 200, + reason: 'provider_error', + content: + '@opus 处理失败(provider_error)。\n\n来源消息:message-terminal-visibility。\n\nProvider quota exhausted.', + mentions: [], + origin: 'stream', + }); + + assert.equal(terminal.kind, 'applied'); + assert.equal(typeof terminal.message.visibilitySeq, 'number'); + assert.equal( + await redis.zscore(`msg:visibility:${threadId}`, processing.id), + String(terminal.message.visibilitySeq), + ); + const visible = await store.getByThreadAfter(threadId, undefined, undefined, 'owner-redis'); + assert.deepEqual( + visible.map((message) => message.id), + [processing.id], + ); + assert.match(visible[0].content, /@opus 处理失败/); + assert.match(visible[0].content, /来源消息:message-terminal-visibility/); + assert.match(visible[0].content, /Provider quota exhausted/); + }); + + test('repairs terminal lifecycle responses omitted by the first single-ledger rollout', async () => { + const threadId = 'thread-redis-terminal-visibility-repair'; + const response = await store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId, + catId: 'opus', + content: 'already terminal', + mentions: [], + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: '100:terminal-repair', + from: { kind: 'agent', catId: 'opus' }, + invocationId: 'inv-terminal-repair', + targetId: 'opus', + inputEntryIds: ['entry-terminal-repair'], + inputMessageIds: ['message-terminal-repair'], + status: 'completed', + startedAt: 90, + completedAt: 100, + }, + }), + ); + await redis.zrem(`msg:visibility:${threadId}`, response.id); + await redis.hdel(`msg:${response.id}`, 'visibilitySeq'); + await redis.hdel(`msg:visibility-meta:${threadId}`, 'terminalResponseRepair'); + + const visible = await store.getByThreadAfter(threadId, undefined, undefined, 'owner-redis'); + + assert.deepEqual( + visible.map((message) => message.id), + [response.id], + ); + assert.equal(typeof visible[0].visibilitySeq, 'number'); + assert.equal(await redis.hget(`msg:visibility-meta:${threadId}`, 'terminalResponseRepair'), '1'); + }); + + test('preserves a child settle that races with parent response terminalization', async () => { + const processing = await store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId: 'thread-redis-parent-child-race', + catId: 'opus', + content: '', + mentions: [], + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: '0000000000100:response-parent-child-race', + from: { kind: 'agent', catId: 'opus' }, + invocationId: 'invocation-parent-child-race', + targetId: 'opus', + inputEntryIds: ['entry-parent'], + inputMessageIds: ['message-parent'], + status: 'processing', + startedAt: 100, + dispatchRefs: [{ targetId: 'codex', phase: 'dispatched', statusMessageId: 'child-response' }], + }, + }), + ); + let injectedChildSettle = false; + const racingRedis = new Proxy(redis, { + get(target, property) { + if (property === 'eval') { + return async (...args) => { + const script = args[0]; + if ( + !injectedChildSettle && + typeof script === 'string' && + script.includes("existing.status == 'processing'") && + script.includes('existingRaw ~= ARGV[14]') + ) { + injectedChildSettle = true; + const settled = await store.advanceLifecycleInputDispatch(processing.id, { + orderKey: processing.lifecycle.orderKey, + targetId: 'codex', + phase: 'settled', + statusMessageId: 'child-response', + }); + assert.equal(settled.kind, 'applied'); + } + return target.eval(...args); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const racingStore = new RedisMessageStore(racingRedis, { ttlSeconds: 0 }); + + const terminal = await racingStore.commitLifecycleResponseTerminal(processing.id, { + invocationId: 'invocation-parent-child-race', + status: 'completed', + completedAt: 200, + content: 'parent complete', + mentions: [], + origin: 'stream', + }); + + assert.equal(injectedChildSettle, true, 'test must settle the child after the parent read and before its write'); + assert.equal(terminal.kind, 'applied'); + assert.equal(terminal.message.lifecycle.status, 'completed'); + assert.deepEqual(terminal.message.lifecycle.dispatchRefs, [ + { targetId: 'codex', phase: 'settled', statusMessageId: 'child-response' }, + ]); + }); + + test('atomically publishes the exact targetless input followed by one replay-safe failure result', async () => { + const source = await store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId: 'thread-redis-lifecycle', + catId: null, + content: '请继续', + mentions: [], + timestamp: 90, + deliveryStatus: 'queued', + }), + ); + const input = { + sourceMessageId: source.id, + expectedEntryId: 'entry-redis-targetless', + requestedTargets: [], + reason: 'no_available_target', + content: '没有可用成员可以处理这条消息。', + contentBlocks: [{ type: 'text', text: '没有可用成员可以处理这条消息。' }], + failedAt: 100, + }; + + const applied = await store.commitLifecyclePreAdmissionFailure(input); + assert.equal(applied.kind, 'applied'); + assert.equal(applied.inputMessage.deliveryStatus, 'delivered'); + assert.equal(applied.inputMessage.lifecycle.kind, 'input'); + assert.equal(applied.failureMessage.lifecycle.kind, 'delivery_failure'); + assert.equal(applied.failureMessage.lifecycle.inputMessageId, source.id); + assert.deepEqual( + (await store.getByThread(source.threadId)).map((message) => message.id), + [source.id, applied.failureMessage.id], + ); + + const replayed = await store.commitLifecyclePreAdmissionFailure(input); + assert.equal(replayed.kind, 'replayed'); + assert.equal(replayed.failureMessage.id, applied.failureMessage.id); + assert.equal((await store.getByThread(source.threadId)).length, 2); + }); + + test('keeps public agent speech visible and atomically records its failed dispatch result', async () => { + const source = await store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId: 'thread-redis-wake-failure', + catId: 'opus', + content: '@codex please review', + mentions: ['codex'], + timestamp: 90, + origin: 'callback', + lifecycle: { + kind: 'input', + orderKey: '0000000000090:agent-wake', + dispatchRefs: [], + }, + }), + ); + + const input = { + sourceMessageId: source.id, + expectedEntryId: 'entry-redis-wake', + requestedTargets: ['codex'], + reason: 'invalid_explicit_target', + content: '消息未能送达:指定的接收对象当前无效。', + failedAt: 100, + }; + const applied = await store.commitLifecyclePreAdmissionFailure(input); + + assert.equal(applied.kind, 'applied'); + assert.equal(applied.inputMessage.deliveryStatus, undefined); + assert.equal(applied.inputMessage.deliveredAt, undefined); + assert.deepEqual(applied.inputMessage.lifecycle.dispatchRefs, [ + { targetId: 'codex', phase: 'settled', statusMessageId: applied.failureMessage.id, dispatchedAt: 100 }, + ]); + assert.deepEqual( + (await store.getByThread(source.threadId)).map((message) => message.id), + [source.id, applied.failureMessage.id], + ); + + const replayed = await store.commitLifecyclePreAdmissionFailure(input); + assert.equal(replayed.kind, 'replayed'); + assert.equal(replayed.failureMessage.id, applied.failureMessage.id); + }); + + test('rejects an invalid failure timestamp without mutating the queued source', async () => { + const source = await store.append( + canonicalFixture({ + userId: 'owner-redis', + threadId: 'thread-redis-invalid', + catId: null, + content: 'invalid terminal time', + mentions: [], + timestamp: 90, + deliveryStatus: 'queued', + }), + ); + + const result = await store.commitLifecyclePreAdmissionFailure({ + sourceMessageId: source.id, + expectedEntryId: 'entry-redis-invalid', + requestedTargets: [], + reason: 'no_available_target', + content: 'invalid', + failedAt: -1, + }); + + assert.deepEqual({ kind: result.kind, reason: result.reason }, { kind: 'conflict', reason: 'invalid_failure' }); + const unchanged = await store.getById(source.id); + assert.equal(unchanged.deliveryStatus, 'queued'); + assert.equal(unchanged.lifecycle, undefined); + }); + }, +); diff --git a/packages/api/test/message-lifecycle-store.test.js b/packages/api/test/message-lifecycle-store.test.js new file mode 100644 index 0000000000..db27d07486 --- /dev/null +++ b/packages/api/test/message-lifecycle-store.test.js @@ -0,0 +1,854 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; + +const processingLifecycle = { + kind: 'response', + orderKey: '0000000000100:response-1', + from: { kind: 'agent', catId: 'opus' }, + invocationId: 'invocation-1', + targetId: 'opus', + inputEntryIds: ['entry-1'], + inputMessageIds: ['message-1'], + status: 'processing', + startedAt: 100, +}; + +function terminalPatch(overrides = {}) { + return { + invocationId: 'invocation-1', + status: 'completed', + completedAt: 200, + content: 'final body', + contentBlocks: [{ type: 'text', text: 'final body' }], + mentions: [], + origin: 'stream', + ...overrides, + }; +} + +describe('MessageStore lifecycle response terminal CAS', () => { + test('settles the next-hop dispatch owned by a completed response source', async () => { + const { MessageStore, commitLifecycleResponseFromAppendInput } = await import( + '../dist/domains/cats/services/stores/ports/MessageStore.js' + ); + const store = new MessageStore(); + const source = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '@codex please continue', + mentions: ['codex'], + timestamp: 100, + lifecycle: { + ...processingLifecycle, + status: 'completed', + completedAt: 100, + dispatchRefs: [], + }, + }), + ); + const child = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'codex', + content: '', + mentions: [], + timestamp: 110, + replyTo: source.id, + lifecycle: { + kind: 'response', + orderKey: '0000000000110:child-response', + from: { kind: 'agent', catId: 'codex' }, + invocationId: 'child-invocation', + targetId: 'codex', + inputEntryIds: ['entry-child'], + inputMessageIds: [source.id], + status: 'processing', + startedAt: 110, + }, + }), + ); + assert.equal( + store.advanceLifecycleInputDispatch(source.id, { + orderKey: source.lifecycle.orderKey, + producerInvocationId: source.lifecycle.producerInvocationId, + targetId: 'codex', + phase: 'dispatched', + statusMessageId: child.id, + dispatchedAt: 110, + }).kind, + 'applied', + ); + + await commitLifecycleResponseFromAppendInput( + store, + child.id, + 'child-invocation', + { status: 'failed', completedAt: 120, reason: 'provider_failed' }, + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'codex', + content: 'Error: provider failed', + mentions: [], + timestamp: 110, + }), + ); + + assert.deepEqual(store.getById(source.id).lifecycle.dispatchRefs, [ + { targetId: 'codex', phase: 'settled', statusMessageId: child.id, dispatchedAt: 110 }, + ]); + assert.equal(store.getById(child.id).replyTo, source.id, 'terminal commit must preserve exact source messageRef'); + }); + + test('stores a delivery failure as a first-class History result and rejects malformed failure identity', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const failure = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: null, + content: '没有可用成员可以处理这条消息。', + mentions: [], + timestamp: 101, + lifecycle: { + kind: 'delivery_failure', + orderKey: '0000000000101:failure-1', + from: { kind: 'system', service: 'message_delivery' }, + status: 'failed', + sourceEntryId: 'entry-1', + inputMessageId: 'message-1', + requestedTargets: [], + reason: 'no_available_target', + createdAt: 101, + }, + }), + ); + + assert.equal(failure.lifecycle.kind, 'delivery_failure'); + assert.equal(failure.lifecycle.inputMessageId, 'message-1'); + assert.throws( + () => + store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: null, + content: 'invalid', + mentions: [], + timestamp: 102, + lifecycle: { ...failure.lifecycle, reason: 'made_up_reason' }, + }), + ), + /lifecycle metadata is invalid/, + ); + }); + + test('replaces one processing bubble in place and replays only the exact terminal', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const processing = ( + await store.appendAndObservePriorFrontier( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '', + mentions: [], + timestamp: 100, + lifecycle: processingLifecycle, + }), + ) + ).message; + + assert.deepEqual(await store.getByThreadAfter('thread-1', undefined, undefined, 'owner-1'), []); + + const applied = store.commitLifecycleResponseTerminal(processing.id, terminalPatch()); + assert.equal(applied.kind, 'applied'); + assert.equal(applied.message.id, processing.id); + assert.equal(applied.message.content, 'final body'); + assert.equal(applied.message.lifecycle.status, 'completed'); + assert.equal(applied.message.lifecycle.completedAt, 200); + assert.equal(typeof applied.message.visibilitySeq, 'number'); + assert.deepEqual( + (await store.getByThreadAfter('thread-1', undefined, undefined, 'owner-1')).map((message) => message.id), + [processing.id], + ); + + const replayed = store.commitLifecycleResponseTerminal(processing.id, terminalPatch()); + assert.equal(replayed.kind, 'replayed'); + + const conflicting = store.commitLifecycleResponseTerminal( + processing.id, + terminalPatch({ status: 'failed', completedAt: 201, reason: 'provider_failed' }), + ); + assert.deepEqual( + { kind: conflicting.kind, reason: conflicting.reason }, + { kind: 'conflict', reason: 'different_terminal' }, + ); + assert.equal(store.getById(processing.id).content, 'final body'); + assert.equal(store.getById(processing.id).lifecycle.status, 'completed'); + }); + + test('atomically completes the same response bubble with its outbound ledger admission', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const store = new MessageStore(); + const queue = new InvocationQueue(); + const processing = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '', + mentions: [], + timestamp: 100, + lifecycle: processingLifecycle, + }), + ); + const input = { + threadId: 'thread-1', + userId: 'owner-1', + kind: 'message_wake', + from: { kind: 'agent', catId: 'opus' }, + ownerAuthProvenance: 'strict', + content: '@codex review', + intent: 'execute', + targetCats: ['codex'], + messageId: processing.id, + sourceId: processing.id, + sourceCategory: 'a2a', + a2aTriggerMessageId: processing.id, + autoExecute: true, + priority: 'normal', + }; + + const applied = await queue.terminalizeResponseAndEnqueueDurable( + store, + processing.id, + terminalPatch({ content: '@codex review', mentions: ['codex'] }), + input, + ); + + assert.equal(applied.outcome, 'enqueued'); + assert.equal(applied.message.id, processing.id, 'completed final must reuse its processing bubble'); + assert.equal(applied.message.lifecycle.status, 'completed'); + assert.equal(applied.message.lifecycle.dispatchRefs, undefined); + assert.equal(applied.entries.length, 1); + assert.equal(applied.entries[0].payload.messageId, processing.id); + assert.equal( + ( + await queue.terminalizeResponseAndEnqueueDurable( + store, + processing.id, + terminalPatch({ content: '@codex review', mentions: ['codex'] }), + input, + ) + ).deduped, + true, + ); + assert.equal( + ( + await queue.terminalizeResponseAndEnqueueDurable( + store, + processing.id, + terminalPatch({ content: '@codex review', mentions: ['codex'] }), + input, + ) + ).message.id, + processing.id, + ); + + assert.ok(await queue.terminalizeEntryDurable('thread-1', 'owner-1', applied.entry.id)); + assert.equal( + store.advanceLifecycleInputDispatch(processing.id, { + orderKey: applied.message.lifecycle.orderKey, + producerInvocationId: applied.message.lifecycle.producerInvocationId, + targetId: 'codex', + phase: 'dispatched', + statusMessageId: 'response-codex', + dispatchedAt: 210, + }).kind, + 'applied', + ); + const afterDispatchReplay = await queue.terminalizeResponseAndEnqueueDurable( + store, + processing.id, + terminalPatch({ content: '@codex review', mentions: ['codex'] }), + input, + ); + assert.equal(afterDispatchReplay.deduped, true); + assert.deepEqual(afterDispatchReplay.entries, []); + assert.equal(queue.list('thread-1', 'owner-1').length, 0, 'History terminal truth must prevent re-admission'); + assert.equal(store.getByThread('thread-1', 10, 'owner-1').length, 1, 'no copied Agent message may be appended'); + }); + + test('fails closed on the wrong invocation and invalid terminal time', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const processing = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '', + mentions: [], + timestamp: 100, + lifecycle: processingLifecycle, + }), + ); + + const wrongOwner = store.commitLifecycleResponseTerminal( + processing.id, + terminalPatch({ invocationId: 'invocation-2' }), + ); + assert.deepEqual( + { kind: wrongOwner.kind, reason: wrongOwner.reason }, + { kind: 'conflict', reason: 'invocation_mismatch' }, + ); + + const invalidTime = store.commitLifecycleResponseTerminal(processing.id, terminalPatch({ completedAt: 99 })); + assert.deepEqual( + { kind: invalidTime.kind, reason: invalidTime.reason }, + { kind: 'conflict', reason: 'invalid_terminal' }, + ); + assert.equal(store.getById(processing.id).lifecycle.status, 'processing'); + }); + + test('rejects malformed lifecycle metadata at every append boundary', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const invalid = { + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '', + mentions: [], + timestamp: 100, + lifecycle: { ...processingLifecycle, completedAt: 101 }, + }; + for (const append of [ + (store) => store.append(invalid), + (store) => store.appendIfThreadFrontier(invalid, null), + (store) => store.appendAndObservePriorFrontier(invalid), + ]) { + const store = new MessageStore(); + assert.throws(() => append(store), /lifecycle metadata is invalid/); + assert.equal(store.size, 0); + } + }); +}); + +describe('MessageStore lifecycle input dispatch CAS', () => { + test('atomically attaches one Queue input to every exact processing Active Run', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const input = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: null, + content: 'append this', + mentions: ['opus', 'codex'], + timestamp: 90, + }), + ); + const response = (targetId, invocationId) => + store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: targetId, + content: '', + mentions: [], + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: `100:${invocationId}`, + from: { kind: 'agent', catId: targetId }, + invocationId, + targetId, + inputEntryIds: ['entry-old'], + inputMessageIds: ['message-old'], + status: 'processing', + startedAt: 100, + }, + }), + ); + const opus = response('opus', 'turn-opus'); + const codex = response('codex', 'turn-codex'); + const admission = { + threadId: 'thread-1', + entryId: 'entry-append', + inputMessageIds: [input.id], + runs: [ + { targetId: 'opus', invocationId: 'turn-opus', responseMessageId: opus.id, dispatchedAt: 101 }, + { targetId: 'codex', invocationId: 'turn-codex', responseMessageId: codex.id, dispatchedAt: 102 }, + ], + }; + + const applied = store.commitLifecycleAppendAdmission(admission); + assert.equal(applied.kind, 'applied'); + assert.deepEqual(store.getById(input.id).lifecycle.dispatchRefs, [ + { targetId: 'opus', phase: 'dispatched', statusMessageId: opus.id, dispatchedAt: 101 }, + { targetId: 'codex', phase: 'dispatched', statusMessageId: codex.id, dispatchedAt: 102 }, + ]); + assert.deepEqual(store.getById(opus.id).lifecycle.inputEntryIds, ['entry-old', 'entry-append']); + assert.deepEqual(store.getById(codex.id).lifecycle.inputMessageIds, ['message-old', input.id]); + assert.equal(store.commitLifecycleAppendAdmission(admission).kind, 'replayed'); + + const wrongRun = store.commitLifecycleAppendAdmission({ + ...admission, + runs: [{ targetId: 'opus', invocationId: 'turn-stale', responseMessageId: opus.id, dispatchedAt: 103 }], + }); + assert.deepEqual(wrongRun, { kind: 'conflict', reason: 'response_lifecycle_conflict' }); + + const failure = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: null, + content: 'codex carrier closed', + mentions: [], + timestamp: 110, + lifecycle: { + kind: 'delivery_failure', + orderKey: '110:failure-codex', + from: { kind: 'system', service: 'message_delivery' }, + status: 'failed', + sourceEntryId: 'entry-append', + inputMessageId: input.id, + requestedTargets: ['codex'], + reason: 'control_carrier_replaced', + createdAt: 110, + }, + }), + ); + const rejection = { + threadId: 'thread-1', + entryId: 'entry-append', + inputMessageIds: [input.id], + failureMessageIds: [failure.id], + run: { targetId: 'codex', invocationId: 'turn-codex', responseMessageId: codex.id }, + }; + assert.equal(store.commitLifecycleAppendRejection(rejection).kind, 'applied'); + assert.deepEqual(store.getById(input.id).lifecycle.dispatchRefs, [ + { targetId: 'opus', phase: 'dispatched', statusMessageId: opus.id, dispatchedAt: 101 }, + { targetId: 'codex', phase: 'settled', statusMessageId: failure.id, dispatchedAt: 102 }, + ]); + assert.deepEqual(store.getById(codex.id).lifecycle.inputEntryIds, ['entry-old']); + assert.deepEqual(store.getById(codex.id).lifecycle.inputMessageIds, ['message-old']); + assert.equal(store.commitLifecycleAppendRejection(rejection).kind, 'replayed'); + }); + + test('publishes agent speech with a durable ledger wake in the same append', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + let observedAppend; + const store = new MessageStore({ + onAppend: (message) => { + observedAppend = structuredClone(message); + }, + }); + const queue = new InvocationQueue(); + const admission = await queue.appendAndEnqueueDurable( + store, + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '@codex please review', + mentions: ['codex'], + timestamp: 90, + origin: 'callback', + }), + { + userId: 'owner-1', + threadId: 'thread-1', + kind: 'message_wake', + from: { kind: 'agent', catId: 'opus' }, + ownerAuthProvenance: 'strict', + intent: 'execute', + content: '@codex please review', + targetCats: ['codex'], + sourceCategory: 'a2a', + autoExecute: true, + priority: 'normal', + }, + ); + assert.equal(admission.outcome, 'enqueued'); + const source = admission.message; + + assert.equal(source.deliveryStatus, undefined); + assert.equal(admission.entries.length, 1); + assert.equal(admission.entries[0].payload.messageId, source.id); + assert.deepEqual(source.lifecycle.dispatchRefs, []); + assert.equal(source.lifecycle.kind, 'input'); + assert.deepEqual(source.from, { kind: 'agent', catId: 'opus' }); + assert.deepEqual(observedAppend, source, 'append listeners must never observe speech without its wake admission'); + assert.deepEqual( + store.getByThread('thread-1', 10, 'owner-1').map((message) => message.id), + [source.id], + ); + }); + + test('advances one target monotonically while preserving the exact response bubble', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const input = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: null, + content: 'please inspect this', + mentions: ['opus'], + timestamp: 90, + }), + ); + const dispatchedPatch = { + orderKey: '0000000000090:message-1', + from: { kind: 'user', userId: 'owner-1' }, + targetId: 'opus', + phase: 'dispatched', + statusMessageId: 'response-1', + dispatchedAt: 100, + }; + + const dispatched = store.advanceLifecycleInputDispatch(input.id, dispatchedPatch); + assert.equal(dispatched.kind, 'applied'); + assert.deepEqual(dispatched.message.lifecycle.dispatchRefs, [ + { targetId: 'opus', phase: 'dispatched', statusMessageId: 'response-1', dispatchedAt: 100 }, + ]); + assert.equal(store.advanceLifecycleInputDispatch(input.id, dispatchedPatch).kind, 'replayed'); + + const settled = store.advanceLifecycleInputDispatch(input.id, { + ...dispatchedPatch, + phase: 'settled', + }); + assert.equal(settled.kind, 'applied'); + assert.deepEqual(settled.message.lifecycle.dispatchRefs, [ + { targetId: 'opus', phase: 'settled', statusMessageId: 'response-1', dispatchedAt: 100 }, + ]); + assert.equal( + store.advanceLifecycleInputDispatch(input.id, { ...dispatchedPatch, phase: 'settled' }).kind, + 'replayed', + ); + }); + + test('records actual target dispatch on a completed response without replacing that response lifecycle', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const response = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '@codex please continue', + mentions: ['codex'], + timestamp: 100, + lifecycle: { + ...processingLifecycle, + status: 'completed', + completedAt: 100, + dispatchRefs: [], + }, + }), + ); + + const dispatched = store.advanceLifecycleInputDispatch(response.id, { + orderKey: processingLifecycle.orderKey, + from: processingLifecycle.from, + targetId: 'codex', + phase: 'dispatched', + statusMessageId: 'response-2', + dispatchedAt: 105, + }); + assert.equal(dispatched.kind, 'applied'); + assert.equal(dispatched.message.lifecycle.kind, 'response'); + assert.equal(dispatched.message.lifecycle.invocationId, 'invocation-1'); + assert.deepEqual(dispatched.message.lifecycle.dispatchRefs, [ + { targetId: 'codex', phase: 'dispatched', statusMessageId: 'response-2', dispatchedAt: 105 }, + ]); + }); + + for (const status of ['failed', 'canceled', 'interrupted']) { + test(`records downstream dispatch on a ${status} response without changing its own terminal status`, async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const response = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'bengal', + content: 'provider returned its terminal result', + mentions: [], + timestamp: 100, + lifecycle: { + ...processingLifecycle, + targetId: 'bengal', + status, + completedAt: 100, + dispatchRefs: [], + }, + }), + ); + + const dispatched = store.advanceLifecycleInputDispatch(response.id, { + orderKey: processingLifecycle.orderKey, + from: processingLifecycle.from, + targetId: 'opus', + phase: 'dispatched', + statusMessageId: 'response-predecessor', + dispatchedAt: 105, + }); + + assert.equal(dispatched.kind, 'applied'); + assert.equal(dispatched.message.lifecycle.kind, 'response'); + assert.equal(dispatched.message.lifecycle.status, status); + assert.deepEqual(dispatched.message.lifecycle.dispatchRefs, [ + { + targetId: 'opus', + phase: 'dispatched', + statusMessageId: 'response-predecessor', + dispatchedAt: 105, + }, + ]); + }); + } + + test('records actual target dispatch while the response source is still processing', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const response = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: 'dispatching a multi-mention now', + mentions: [], + timestamp: 100, + lifecycle: { + ...processingLifecycle, + dispatchRefs: [], + }, + }), + ); + + const dispatched = store.advanceLifecycleInputDispatch(response.id, { + orderKey: processingLifecycle.orderKey, + from: processingLifecycle.from, + targetId: 'codex', + phase: 'dispatched', + statusMessageId: 'response-2', + dispatchedAt: 105, + }); + assert.equal(dispatched.kind, 'applied'); + assert.equal(dispatched.message.lifecycle.kind, 'response'); + assert.equal(dispatched.message.lifecycle.status, 'processing'); + assert.deepEqual(dispatched.message.lifecycle.dispatchRefs, [ + { targetId: 'codex', phase: 'dispatched', statusMessageId: 'response-2', dispatchedAt: 105 }, + ]); + }); + + test('rejects skipped, conflicting, and regressing target transitions', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const input = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: null, + content: 'please inspect this', + mentions: ['opus'], + timestamp: 90, + }), + ); + const base = { + orderKey: '0000000000090:message-1', + from: { kind: 'user', userId: 'owner-1' }, + targetId: 'opus', + statusMessageId: 'response-1', + dispatchedAt: 100, + }; + + const skipped = store.advanceLifecycleInputDispatch(input.id, { ...base, phase: 'settled' }); + assert.deepEqual( + { kind: skipped.kind, reason: skipped.reason }, + { kind: 'conflict', reason: 'invalid_transition' }, + ); + assert.equal(store.advanceLifecycleInputDispatch(input.id, { ...base, phase: 'dispatched' }).kind, 'applied'); + + const wrongBubble = store.advanceLifecycleInputDispatch(input.id, { + ...base, + phase: 'settled', + statusMessageId: 'response-2', + }); + assert.deepEqual( + { kind: wrongBubble.kind, reason: wrongBubble.reason }, + { kind: 'conflict', reason: 'status_message_mismatch' }, + ); + + assert.equal(store.advanceLifecycleInputDispatch(input.id, { ...base, phase: 'settled' }).kind, 'applied'); + const regressed = store.advanceLifecycleInputDispatch(input.id, { ...base, phase: 'dispatched' }); + assert.deepEqual( + { kind: regressed.kind, reason: regressed.reason }, + { kind: 'conflict', reason: 'invalid_transition' }, + ); + }); +}); + +describe('MessageStore lifecycle pre-admission failure transaction', () => { + test('keeps public agent speech visible and records its failed delivery result', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const source = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: 'opus', + content: '@codex please review', + mentions: ['codex'], + timestamp: 90, + origin: 'callback', + lifecycle: { + kind: 'input', + orderKey: '90:entry-wake', + dispatchRefs: [], + }, + }), + ); + + const input = { + sourceMessageId: source.id, + expectedEntryId: 'entry-wake', + requestedTargets: ['codex'], + reason: 'invalid_explicit_target', + content: '消息未能送达:指定的接收对象当前无效。', + failedAt: 100, + }; + const applied = store.commitLifecyclePreAdmissionFailure(input); + + assert.equal(applied.kind, 'applied'); + assert.equal(applied.inputMessage.deliveryStatus, undefined); + assert.equal(applied.inputMessage.deliveredAt, undefined); + assert.equal(applied.inputMessage.queueCustody, undefined, 'History must not mirror Queue state'); + assert.equal(applied.inputMessage.lifecycle.kind, 'input'); + assert.deepEqual(applied.inputMessage.lifecycle.dispatchRefs, [ + { targetId: 'codex', phase: 'settled', statusMessageId: applied.failureMessage.id, dispatchedAt: 100 }, + ]); + assert.equal(applied.failureMessage.lifecycle.inputMessageId, source.id); + assert.deepEqual( + store.getByThread('thread-1').map((message) => message.id), + [source.id, applied.failureMessage.id], + ); + + const replayed = store.commitLifecyclePreAdmissionFailure(input); + assert.equal(replayed.kind, 'replayed'); + assert.equal(replayed.failureMessage.id, applied.failureMessage.id); + assert.deepEqual(replayed.inputMessage.lifecycle.dispatchRefs, applied.inputMessage.lifecycle.dispatchRefs); + }); + + test('records one rejected target while leaving its pending sibling only in Queue', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const store = new MessageStore(); + const queue = new InvocationQueue(); + const admission = await queue.appendAndEnqueueDurable( + store, + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + from: { kind: 'user', userId: 'owner-1' }, + content: '@codex @kimi please review', + mentions: ['codex', 'kimi'], + timestamp: 90, + deliveryStatus: 'queued', + }), + canonicalTestQueueInput({ + userId: 'owner-1', + threadId: 'thread-1', + from: { kind: 'user', userId: 'owner-1' }, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + content: '@codex @kimi please review', + targetCats: ['codex', 'kimi'], + intent: 'execute', + }), + ); + const source = admission.message; + const kimiEntry = admission.entries.find((entry) => entry.targets.includes('kimi')); + assert.ok(kimiEntry); + + const applied = store.commitLifecyclePreAdmissionFailure({ + sourceMessageId: source.id, + expectedEntryId: kimiEntry.id, + requestedTargets: ['kimi'], + reason: 'invalid_explicit_target', + content: '消息未能送达:指定的接收对象当前无效。', + failedAt: 100, + }); + + assert.equal(applied.kind, 'applied'); + assert.equal(applied.inputMessage.queueCustody, undefined, 'History must not mirror Queue state'); + assert.deepEqual(applied.inputMessage.lifecycle.dispatchRefs, [ + { targetId: 'kimi', phase: 'settled', statusMessageId: applied.failureMessage.id, dispatchedAt: 100 }, + ]); + assert.deepEqual(admission.entries[0].targets, ['codex', 'kimi']); + assert.deepEqual(applied.failureMessage.lifecycle.requestedTargets, ['kimi']); + }); + + test('atomically publishes the exact targetless input followed by one replay-safe failure result', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const source = store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + threadId: 'thread-1', + catId: null, + content: '请继续', + mentions: [], + timestamp: 90, + deliveryStatus: 'queued', + }), + ); + + const input = { + sourceMessageId: source.id, + expectedEntryId: 'entry-targetless', + requestedTargets: [], + reason: 'no_available_target', + content: '没有可用成员可以处理这条消息。', + contentBlocks: [{ type: 'text', text: '没有可用成员可以处理这条消息。' }], + failedAt: 100, + }; + const applied = store.commitLifecyclePreAdmissionFailure(input); + + assert.equal(applied.kind, 'applied'); + assert.equal(applied.inputMessage.deliveryStatus, 'delivered'); + assert.equal(applied.inputMessage.queueCustody, undefined, 'History must not mirror Queue state'); + assert.equal(applied.inputMessage.lifecycle.kind, 'input'); + assert.equal(applied.failureMessage.lifecycle.kind, 'delivery_failure'); + assert.equal(applied.failureMessage.lifecycle.inputMessageId, source.id); + assert.deepEqual( + store.getByThread('thread-1').map((message) => message.id), + [source.id, applied.failureMessage.id], + ); + + const replayed = store.commitLifecyclePreAdmissionFailure(input); + assert.equal(replayed.kind, 'replayed'); + assert.equal(replayed.failureMessage.id, applied.failureMessage.id); + assert.equal(store.getByThread('thread-1').length, 2); + + const conflict = store.commitLifecyclePreAdmissionFailure({ + ...input, + reason: 'invalid_explicit_target', + }); + assert.deepEqual( + { kind: conflict.kind, reason: conflict.reason }, + { kind: 'conflict', reason: 'different_failure' }, + ); + }); +}); diff --git a/packages/api/test/message-selection-cli-quote.test.js b/packages/api/test/message-selection-cli-quote.test.js index dfaa8ae699..4dbaa41588 100644 --- a/packages/api/test/message-selection-cli-quote.test.js +++ b/packages/api/test/message-selection-cli-quote.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; await import('tsx/esm'); const { @@ -25,7 +26,7 @@ function makeThread(overrides = {}) { } function makeMessage(overrides = {}) { - return { + return canonicalTestMessageInput({ id: 'message-1', threadId: 'thread-source', userId: 'user-1', @@ -35,7 +36,7 @@ function makeMessage(overrides = {}) { timestamp: 100, deliveryStatus: 'delivered', ...overrides, - }; + }); } function createResolver({ messages = [makeMessage()] } = {}) { diff --git a/packages/api/test/message-selection-resolver.test.js b/packages/api/test/message-selection-resolver.test.js index cbf439cf73..237154bddb 100644 --- a/packages/api/test/message-selection-resolver.test.js +++ b/packages/api/test/message-selection-resolver.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; await import('tsx/esm'); const { @@ -23,7 +24,7 @@ function makeThread(overrides = {}) { } function makeMessage(overrides = {}) { - return { + return canonicalTestMessageInput({ id: 'message-1', threadId: 'thread-source', userId: 'user-1', @@ -33,28 +34,7 @@ function makeMessage(overrides = {}) { timestamp: 100, deliveryStatus: 'delivered', ...overrides, - }; -} - -function makeQueueCustody(ownerUserId = 'user-1') { - return { - version: 1, - entryId: `entry-${ownerUserId}`, - revision: 1, - ownerUserId, - intent: 'managed command wake', - status: 'queued', - allTargetCats: ['opus5'], - pendingTargetCats: ['opus5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 100, - updatedAt: 100, - }; + }); } function createResolver({ thread = makeThread(), messages = [makeMessage()] } = {}) { @@ -113,8 +93,8 @@ describe('MessageSelectionResolver admission', () => { ); assert.deepEqual(Object.keys(result.carrier.items[0]).sort(), ['kind', 'messageId']); assert.deepEqual(Object.keys(result.carrier.items[1]).sort(), ['kind', 'messageId']); - assert.deepEqual(result.items[0].author, { kind: 'user', userId: 'user-1' }); - assert.deepEqual(result.items[1].author, { kind: 'cat', catId: 'codex-sol' }); + assert.deepEqual(result.items[0].from, { kind: 'user', userId: 'user-1' }); + assert.deepEqual(result.items[1].from, { kind: 'agent', catId: 'codex-sol' }); }); it('anchors an exact Quote and stores a domain-separated digest of the full bubble projection', async () => { @@ -260,16 +240,24 @@ describe('MessageSelectionResolver admission', () => { it('resolves a visible scheduler-authored managed-hold receipt while rejecting other system rows', async () => { const managedReceipt = makeMessage({ id: 'managed-receipt', - userId: 'scheduler', + userId: 'user-1', catId: null, + from: { kind: 'system', service: 'hold-ball' }, content: '[定时任务] 持球唤醒(命令完成)', - deliveryStatus: 'queued', - queueCustody: makeQueueCustody(), + deliveryStatus: 'delivered', + deliveredAt: 100, source: { connector: 'hold-ball', label: '持球结果', icon: '🏓', - meta: { taskId: 'hold-ball-task-1', threadId: 'thread-source', catId: 'opus5', wakeWhen: true }, + meta: { + managedHold: true, + phase: 'wake', + taskId: 'hold-ball-task-1', + threadId: 'thread-source', + catId: 'opus5', + wakeWhen: true, + }, }, }); const genericScheduler = makeMessage({ @@ -314,31 +302,36 @@ describe('MessageSelectionResolver admission', () => { connector: 'hold-ball', label: '持球结果', icon: '🏓', - meta: { taskId: 'hold-ball-task-1', threadId: 'thread-source', catId: 'opus5', wakeWhen: true }, + meta: { + managedHold: true, + phase: 'wake', + taskId: 'hold-ball-task-1', + threadId: 'thread-source', + catId: 'opus5', + wakeWhen: true, + }, }; const hidden = makeMessage({ id: 'hidden-managed-receipt', - userId: 'scheduler', + userId: 'user-1', catId: null, - deliveryStatus: 'queued', - queueCustody: makeQueueCustody(), + from: { kind: 'system', service: 'hold-ball' }, extra: { scheduler: { hiddenTrigger: true } }, source: managedSource, }); const foreignOwner = makeMessage({ id: 'foreign-managed-receipt', - userId: 'scheduler', + userId: 'user-owner', catId: null, + from: { kind: 'system', service: 'hold-ball' }, content: 'owner-A command result', - deliveryStatus: 'queued', - queueCustody: makeQueueCustody('user-owner'), source: managedSource, }); const ownerlessLegacy = makeMessage({ id: 'ownerless-managed-receipt', userId: 'scheduler', catId: null, - deliveryStatus: 'queued', + from: { kind: 'system', service: 'hold-ball' }, source: managedSource, }); const { resolver } = createResolver({ diff --git a/packages/api/test/message-selection-rich-text-quote.test.js b/packages/api/test/message-selection-rich-text-quote.test.js index 2e2cc91301..189c454ff8 100644 --- a/packages/api/test/message-selection-rich-text-quote.test.js +++ b/packages/api/test/message-selection-rich-text-quote.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; await import('tsx/esm'); const { MessageSelectionResolver, digestMessageBundleQuoteProjection, projectMessageBundleQuoteSourceV2 } = @@ -30,7 +31,7 @@ function makeThread(overrides = {}) { } function makeMessage(overrides = {}) { - return { + return canonicalTestMessageInput({ id: 'message-1', threadId: 'thread-source', userId: 'user-1', @@ -40,7 +41,7 @@ function makeMessage(overrides = {}) { timestamp: 100, deliveryStatus: 'delivered', ...overrides, - }; + }); } function createResolver(messages = [makeMessage()], thread = makeThread()) { diff --git a/packages/api/test/message-store-delivery-guard.test.js b/packages/api/test/message-store-delivery-guard.test.js index b16019e884..09f339e14f 100644 --- a/packages/api/test/message-store-delivery-guard.test.js +++ b/packages/api/test/message-store-delivery-guard.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; // ── In-memory MessageStore: markCanceled guard (deterministic RED) ── @@ -16,15 +17,17 @@ describe('in-memory MessageStore markCanceled guard (PR #1193)', () => { it('markCanceled on delivered message is no-op (guard parity with Redis)', async () => { const memStore = new MessageStore(); const base = Date.now(); - const msg = await memStore.append({ - userId: 'u1', - catId: null, - content: 'test', - mentions: [], - timestamp: base, - threadId: 'thread-mem-guard', - deliveryStatus: 'queued', - }); + const msg = await memStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'test', + mentions: [], + timestamp: base, + threadId: 'thread-mem-guard', + deliveryStatus: 'queued', + }), + ); memStore.markDelivered(msg.id, base + 100); @@ -36,14 +39,16 @@ describe('in-memory MessageStore markCanceled guard (PR #1193)', () => { it('markCanceled on immediate/no-status message is no-op', async () => { const memStore = new MessageStore(); - const msg = await memStore.append({ - userId: 'u1', - catId: null, - content: 'immediate', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-mem-imm', - }); + const msg = await memStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'immediate', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-mem-imm', + }), + ); const result = memStore.markCanceled(msg.id); assert.equal(result?.deliveryTransitioned, false, 'in-memory CAS no-op must report applied=false'); @@ -52,15 +57,17 @@ describe('in-memory MessageStore markCanceled guard (PR #1193)', () => { it('markCanceled on already-canceled message reports applied=false (CAS idempotency parity)', async () => { const memStore = new MessageStore(); - const msg = await memStore.append({ - userId: 'u1', - catId: null, - content: 'test', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-mem-idem', - deliveryStatus: 'queued', - }); + const msg = await memStore.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'test', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-mem-idem', + deliveryStatus: 'queued', + }), + ); const first = memStore.markCanceled(msg.id); assert.ok(first, 'first cancel must succeed'); diff --git a/packages/api/test/message-store.test.js b/packages/api/test/message-store.test.js index 320b6817e9..f84f7f74ec 100644 --- a/packages/api/test/message-store.test.js +++ b/packages/api/test/message-store.test.js @@ -5,19 +5,22 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('MessageStore', () => { test('append() stores message and returns with id', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const result = store.append({ - userId: 'user-1', - catId: null, - content: 'Hello', - mentions: [], - timestamp: Date.now(), - }); + const result = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'Hello', + mentions: [], + timestamp: Date.now(), + }), + ); assert.ok(typeof result.id === 'string'); assert.ok(result.id.length > 0); @@ -30,14 +33,16 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const append = (overrides) => - store.append({ - userId: 'owner-1', - catId: null, - content: 'candidate', - mentions: [], - threadId: 'thread-a', - ...overrides, - }); + store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content: 'candidate', + mentions: [], + threadId: 'thread-a', + ...overrides, + }), + ); append({ content: 'before', timestamp: 99 }); const lower = append({ content: 'lower', timestamp: 100 }); @@ -69,13 +74,15 @@ describe('MessageStore', () => { const store = new MessageStore(); for (let timestamp = 1; timestamp <= 75; timestamp += 1) { - store.append({ - userId: 'owner-1', - catId: null, - content: `message ${timestamp}`, - mentions: [], - timestamp, - }); + store.append( + canonicalTestMessageInput({ + userId: 'owner-1', + catId: null, + content: `message ${timestamp}`, + mentions: [], + timestamp, + }), + ); } assert.equal(store.listOwnerMessagesInWindow('owner-1', 1, 75).length, 75); @@ -122,6 +129,7 @@ describe('MessageStore', () => { for (const metadata of [ { deliveredAt: undefined }, { timelineOrderAt: undefined }, + { timelinePublishedAtAppend: true }, { deliveredAt: 101, deliveryStatus: 'delivered' }, { deliveryStatus: 'delivered' }, { deliveryStatus: 'canceled' }, @@ -153,14 +161,16 @@ describe('MessageStore', () => { const store = new MessageStore(); for (const timestamp of [0, 1, 8_640_000_000_000_000]) { - const stored = store.append({ - userId: 'user-1', - catId: null, - content: 'valid Date input', - mentions: [], - timestamp, - deliveryStatus: 'queued', - }); + const stored = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'valid Date input', + mentions: [], + timestamp, + deliveryStatus: 'queued', + }), + ); assert.equal(stored.timestamp, timestamp); assert.equal(stored.deliveryStatus, 'queued'); assert.equal(stored.deliveredAt, undefined); @@ -172,14 +182,16 @@ describe('MessageStore', () => { test('prepareQueueAdmission adopts only a legacy-visible source and is idempotent', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const legacy = store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'approved carrier persisted before Queue admission', - mentions: ['codex-terra'], - timestamp: 100, - threadId: 'thread-target', - }); + const legacy = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex-sol', + content: 'approved carrier persisted before Queue admission', + mentions: ['codex-terra'], + timestamp: 100, + threadId: 'thread-target', + }), + ); const prepared = store.prepareQueueAdmission(legacy.id); assert.equal(prepared.kind, 'prepared'); @@ -187,118 +199,78 @@ describe('MessageStore', () => { assert.equal(store.prepareQueueAdmission(legacy.id).kind, 'existing'); assert.deepEqual(store.prepareQueueAdmission('missing-message'), { kind: 'not_found' }); - const delivered = store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'already terminal', - mentions: ['codex-terra'], - timestamp: 101, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); + const delivered = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex-sol', + content: 'already terminal', + mentions: ['codex-terra'], + timestamp: 101, + threadId: 'thread-target', + deliveryStatus: 'queued', + }), + ); store.markDelivered(delivered.id, 102); assert.deepEqual(store.prepareQueueAdmission(delivered.id), { kind: 'conflict' }); - const canceled = store.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'canceled source', - mentions: ['codex-terra'], - timestamp: 103, - threadId: 'thread-target', - deliveryStatus: 'queued', - }); + const canceled = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex-sol', + content: 'canceled source', + mentions: ['codex-terra'], + timestamp: 103, + threadId: 'thread-target', + deliveryStatus: 'queued', + }), + ); store.markCanceled(canceled.id); assert.deepEqual(store.prepareQueueAdmission(canceled.id), { kind: 'conflict' }); }); - test('fan-out admission intent is idempotent, blocks legacy delivery, and is replaced by full custody', async () => { + test('fan-out admission is idempotent and publishes owner work only at first delivery', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const store = new MessageStore(); - const message = store.append({ + const queue = new InvocationQueue(); + const message = canonicalTestMessageInput({ userId: 'user-1', - catId: 'opus', - content: 'durable pre-CAS fan-out', + from: { kind: 'user', userId: 'user-1' }, + content: 'durable fan-out', mentions: ['codex', 'codex-terra'], timestamp: 200, threadId: 'thread-admission', deliveryStatus: 'queued', + idempotencyKey: 'fanout-admission-1', }); const admission = { - version: 1, - admissionId: `queue-custody:${message.id}`, - ownerUserId: 'user-1', + userId: 'user-1', + threadId: 'thread-admission', + kind: 'conversation_input', + from: { kind: 'user', userId: 'user-1' }, ownerAuthProvenance: 'unknown', + content: 'durable fan-out', intent: 'execute', targetCats: ['codex'], - requestedTargetCats: ['codex', 'codex-terra'], - callerCatId: 'opus', a2aParentInvocationId: 'parent-1', priority: 'normal', - createdAt: 200, - }; - - assert.equal(store.initializeQueueCustodyAdmission(message.id, admission).kind, 'initialized'); - assert.equal(store.initializeQueueCustodyAdmission(message.id, structuredClone(admission)).kind, 'existing'); - assert.equal(store.initializeQueueCustodyAdmission(message.id, { ...admission, targetCats: [] }).kind, 'conflict'); - assert.equal(store.markDelivered(message.id, 201).deliveryTransitioned, false); - assert.equal(store.getById(message.id).deliveryStatus, 'queued'); - - const custody = { - version: 1, - entryId: `fanout:${message.id}`, - revision: 1, - ownerUserId: 'user-1', - ownerAuthProvenance: 'unknown', - carrierByTargetCatId: { - codex: { - entryId: 'carrier-codex', - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'opus', - a2aParentInvocationId: 'parent-1', - a2aTriggerMessageId: message.id, - autoExecute: true, - createdAt: 200, - }, - }, - carrierStateByTargetCatId: { codex: { status: 'queued' } }, - intent: 'execute', - status: 'queued', - allTargetCats: ['codex', 'codex-terra'], - pendingTargetCats: ['codex'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: ['codex-terra'], - handledByCatIds: [], - priority: 'normal', - createdAt: 200, - updatedAt: 200, + autoExecute: true, }; - assert.equal(store.initializeQueueCustody(message.id, custody).kind, 'initialized'); - assert.equal(store.getById(message.id).queueCustodyAdmission, undefined); - assert.deepEqual(store.getById(message.id).queueCustody.allTargetCats, ['codex', 'codex-terra']); - const canceled = store.append({ - userId: 'user-1', - catId: 'opus', - content: 'cancel pre-CAS fan-out', - mentions: ['codex'], - timestamp: 202, - threadId: 'thread-admission', - deliveryStatus: 'queued', - }); - assert.equal( - store.initializeQueueCustodyAdmission(canceled.id, { - ...admission, - admissionId: `queue-custody:${canceled.id}`, - requestedTargetCats: ['codex'], - }).kind, - 'initialized', - ); - assert.equal(store.markCanceled(canceled.id).deliveryStatus, 'canceled'); - assert.equal(store.getById(canceled.id).queueCustodyAdmission, undefined); + const first = await queue.appendAndEnqueueDurable(store, message, admission); + const replay = await queue.appendAndEnqueueDurable(store, message, admission); + assert.equal(first.outcome, 'enqueued'); + assert.equal(replay.outcome, 'enqueued'); + assert.equal(replay.deduped, true); + assert.equal(replay.message.id, first.message.id); + assert.equal(first.message.timelinePublishedAtAppend, undefined); + assert.equal(store.getById(first.message.id).deliveryStatus, 'queued'); + assert.deepEqual(store.getByThread('thread-admission', 20, 'user-1'), []); + assert.equal(queue.list('thread-admission', 'user-1').length, 1); + const delivered = store.markDelivered(first.message.id, 201); + assert.equal(delivered.deliveryTransitioned, true); + assert.equal(delivered.timelineOrderAt, 201); + assert.equal(store.getById(first.message.id).deliveryStatus, 'delivered'); }); test('markDelivered rejects unsafe effective-order timestamps before state mutation and permits a valid retry', async () => { @@ -315,14 +287,16 @@ describe('MessageStore', () => { for (const [index, deliveredAt] of invalidTimestamps.entries()) { const store = new MessageStore(); - const queued = store.append({ - userId: 'user-1', - catId: null, - content: `queued ${index}`, - mentions: [], - timestamp: 100 + index, - deliveryStatus: 'queued', - }); + const queued = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: `queued ${index}`, + mentions: [], + timestamp: 100 + index, + deliveryStatus: 'queued', + }), + ); const before = structuredClone(queued); assert.throws(() => store.markDelivered(queued.id, deliveredAt), { @@ -343,45 +317,6 @@ describe('MessageStore', () => { } }); - test('queue custody delivery rejects unsafe effective-order timestamps before revision or state mutation', async () => { - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - const store = new MessageStore(); - const custody = { - version: 1, - entryId: 'entry-1', - revision: 1, - intent: 'timestamp admission', - status: 'terminal', - allTargetCats: ['opus'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: ['opus'], - handledByCatIds: [], - priority: 'normal', - createdAt: 1_000, - updatedAt: 1_100, - }; - const queued = store.append({ - userId: 'user-1', - catId: null, - content: 'queued with custody', - mentions: ['opus'], - timestamp: 1_000, - deliveryStatus: 'queued', - queueCustody: custody, - }); - const next = { ...custody, revision: 2, updatedAt: 1_200 }; - const before = structuredClone(store.getById(queued.id)); - - assert.throws(() => store.transitionQueueCustody(queued.id, { expectedRevision: 1, next, deliveredAt: 1_200.5 }), { - name: 'RangeError', - message: /non-negative integer ECMAScript Date/, - }); - assert.deepEqual(store.getById(queued.id), before); - }); - test('admitted timestamp classes preserve sortable-ID and delivery-cursor monotonicity', async () => { const { generateSortableId } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { DeliveryCursorStore } = await import('../dist/domains/cats/services/stores/ports/DeliveryCursorStore.js'); @@ -400,15 +335,17 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const callbackMsg = store.append({ - userId: 'user-1', - catId: 'opus', - content: 'Callback content remains canonical', - mentions: [], - timestamp: 1, - origin: 'callback', - extra: { rich: { v: 1, blocks: [{ id: 'callback-card', kind: 'card', v: 1, title: 'Callback' }] } }, - }); + const callbackMsg = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'Callback content remains canonical', + mentions: [], + timestamp: 1, + origin: 'callback', + extra: { rich: { v: 1, blocks: [{ id: 'callback-card', kind: 'card', v: 1, title: 'Callback' }] } }, + }), + ); const updated = store.augmentStreamMetadata(callbackMsg.id, { thinking: 'stream thinking', @@ -443,13 +380,15 @@ describe('MessageStore', () => { const store = new MessageStore(); for (let i = 0; i < 5; i++) { - store.append({ - userId: 'user-1', - catId: null, - content: `Message ${i}`, - mentions: [], - timestamp: i, - }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: `Message ${i}`, + mentions: [], + timestamp: i, + }), + ); } const recent = store.getRecent(3); @@ -464,27 +403,33 @@ describe('MessageStore', () => { const store = new MessageStore(); - store.append({ - userId: 'user-1', - catId: null, - content: '@opus help', - mentions: ['opus'], - timestamp: 1, - }); - store.append({ - userId: 'user-1', - catId: null, - content: '@codex review', - mentions: ['codex'], - timestamp: 2, - }); - store.append({ - userId: 'user-1', - catId: null, - content: '@opus and @codex', - mentions: ['opus', 'codex'], - timestamp: 3, - }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus help', + mentions: ['opus'], + timestamp: 1, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@codex review', + mentions: ['codex'], + timestamp: 2, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus and @codex', + mentions: ['opus', 'codex'], + timestamp: 3, + }), + ); const opusMentions = store.getMentionsFor('opus', 10); assert.equal(opusMentions.length, 2); @@ -501,13 +446,15 @@ describe('MessageStore', () => { const store = new MessageStore({ maxMessages: 5 }); for (let i = 0; i < 8; i++) { - store.append({ - userId: 'user-1', - catId: null, - content: `Message ${i}`, - mentions: [], - timestamp: i, - }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: `Message ${i}`, + mentions: [], + timestamp: i, + }), + ); } assert.equal(store.size, 5); @@ -520,9 +467,33 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: null, content: 'A from user-1', mentions: [], timestamp: 1 }); - store.append({ userId: 'user-2', catId: null, content: 'B from user-2', mentions: [], timestamp: 2 }); - store.append({ userId: 'user-1', catId: 'opus', content: 'C from user-1 opus', mentions: [], timestamp: 3 }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'A from user-1', + mentions: [], + timestamp: 1, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + content: 'B from user-2', + mentions: [], + timestamp: 2, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'C from user-1 opus', + mentions: [], + timestamp: 3, + }), + ); const user1 = store.getRecent(10, 'user-1'); assert.equal(user1.length, 2); @@ -542,8 +513,24 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: null, content: '@opus from user-1', mentions: ['opus'], timestamp: 1 }); - store.append({ userId: 'user-2', catId: null, content: '@opus from user-2', mentions: ['opus'], timestamp: 2 }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus from user-1', + mentions: ['opus'], + timestamp: 1, + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + content: '@opus from user-2', + mentions: ['opus'], + timestamp: 2, + }), + ); const user1Mentions = store.getMentionsFor('opus', 10, 'user-1'); assert.equal(user1Mentions.length, 1); @@ -558,30 +545,36 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ - userId: 'user-1', - catId: null, - content: '@opus in thread-A', - mentions: ['opus'], - timestamp: 1, - threadId: 'thread-A', - }); - store.append({ - userId: 'user-1', - catId: null, - content: '@opus in thread-B', - mentions: ['opus'], - timestamp: 2, - threadId: 'thread-B', - }); - store.append({ - userId: 'user-1', - catId: null, - content: '@opus in thread-A again', - mentions: ['opus'], - timestamp: 3, - threadId: 'thread-A', - }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus in thread-A', + mentions: ['opus'], + timestamp: 1, + threadId: 'thread-A', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus in thread-B', + mentions: ['opus'], + timestamp: 2, + threadId: 'thread-B', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus in thread-A again', + mentions: ['opus'], + timestamp: 3, + threadId: 'thread-A', + }), + ); // With threadId: only thread-A mentions const threadA = store.getMentionsFor('opus', 10, undefined, 'thread-A'); @@ -603,30 +596,36 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ - userId: 'user-1', - catId: null, - content: '@opus u1-tA', - mentions: ['opus'], - timestamp: 1, - threadId: 'thread-A', - }); - store.append({ - userId: 'user-2', - catId: null, - content: '@opus u2-tA', - mentions: ['opus'], - timestamp: 2, - threadId: 'thread-A', - }); - store.append({ - userId: 'user-1', - catId: null, - content: '@opus u1-tB', - mentions: ['opus'], - timestamp: 3, - threadId: 'thread-B', - }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus u1-tA', + mentions: ['opus'], + timestamp: 1, + threadId: 'thread-A', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + content: '@opus u2-tA', + mentions: ['opus'], + timestamp: 2, + threadId: 'thread-A', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@opus u1-tB', + mentions: ['opus'], + timestamp: 3, + threadId: 'thread-B', + }), + ); // userId + threadId: only user-1 in thread-A const filtered = store.getMentionsFor('opus', 10, 'user-1', 'thread-A'); @@ -638,9 +637,9 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'u', catId: null, content: 'old', mentions: [], timestamp: 100 }); - store.append({ userId: 'u', catId: null, content: 'mid', mentions: [], timestamp: 200 }); - store.append({ userId: 'u', catId: null, content: 'new', mentions: [], timestamp: 300 }); + store.append(canonicalTestMessageInput({ userId: 'u', catId: null, content: 'old', mentions: [], timestamp: 100 })); + store.append(canonicalTestMessageInput({ userId: 'u', catId: null, content: 'mid', mentions: [], timestamp: 200 })); + store.append(canonicalTestMessageInput({ userId: 'u', catId: null, content: 'new', mentions: [], timestamp: 300 })); const before = store.getBefore(300, 10); assert.equal(before.length, 2); @@ -652,9 +651,15 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'alice', catId: null, content: 'alice old', mentions: [], timestamp: 100 }); - store.append({ userId: 'bob', catId: null, content: 'bob old', mentions: [], timestamp: 150 }); - store.append({ userId: 'alice', catId: null, content: 'alice new', mentions: [], timestamp: 200 }); + store.append( + canonicalTestMessageInput({ userId: 'alice', catId: null, content: 'alice old', mentions: [], timestamp: 100 }), + ); + store.append( + canonicalTestMessageInput({ userId: 'bob', catId: null, content: 'bob old', mentions: [], timestamp: 150 }), + ); + store.append( + canonicalTestMessageInput({ userId: 'alice', catId: null, content: 'alice new', mentions: [], timestamp: 200 }), + ); const before = store.getBefore(200, 10, 'alice'); assert.equal(before.length, 1); @@ -676,13 +681,15 @@ describe('MessageStore', () => { ); const store = new MessageStore(); - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'hi', - mentions: [], - timestamp: 1, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'hi', + mentions: [], + timestamp: 1, + }), + ); assert.equal(msg.threadId, DEFAULT_THREAD_ID); }); @@ -690,14 +697,16 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'u1', - catId: null, - content: 'hi', - mentions: [], - timestamp: 1, - threadId: 'thread-abc', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'hi', + mentions: [], + timestamp: 1, + threadId: 'thread-abc', + }), + ); assert.equal(msg.threadId, 'thread-abc'); }); @@ -705,25 +714,29 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const first = store.append({ - userId: 'u1', - catId: null, - content: 'kickoff', - mentions: [], - timestamp: 10, - threadId: 'thread-abc', - idempotencyKey: 'backlog:b1:attempt:a1', - }); + const first = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'kickoff', + mentions: [], + timestamp: 10, + threadId: 'thread-abc', + idempotencyKey: 'backlog:b1:attempt:a1', + }), + ); - const second = store.append({ - userId: 'u1', - catId: null, - content: 'kickoff retried', - mentions: [], - timestamp: 20, - threadId: 'thread-abc', - idempotencyKey: 'backlog:b1:attempt:a1', - }); + const second = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: null, + content: 'kickoff retried', + mentions: [], + timestamp: 20, + threadId: 'thread-abc', + idempotencyKey: 'backlog:b1:attempt:a1', + }), + ); assert.equal(first.id, second.id); assert.equal(store.size, 1); @@ -734,10 +747,37 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'u', catId: null, content: 'A', mentions: [], timestamp: 1, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'B', mentions: [], timestamp: 2, threadId: 'th-2' }); - store.append({ userId: 'u', catId: null, content: 'C', mentions: [], timestamp: 3, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'D', mentions: [], timestamp: 4 }); // default thread + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'A', + mentions: [], + timestamp: 1, + threadId: 'th-1', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'B', + mentions: [], + timestamp: 2, + threadId: 'th-2', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'C', + mentions: [], + timestamp: 3, + threadId: 'th-1', + }), + ); + store.append(canonicalTestMessageInput({ userId: 'u', catId: null, content: 'D', mentions: [], timestamp: 4 })); // default thread const th1 = store.getByThread('th-1'); assert.equal(th1.length, 2); @@ -757,10 +797,46 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'u', catId: null, content: 'A', mentions: [], timestamp: 100, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'B', mentions: [], timestamp: 200, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'C', mentions: [], timestamp: 300, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'X', mentions: [], timestamp: 250, threadId: 'th-2' }); // different thread + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'A', + mentions: [], + timestamp: 100, + threadId: 'th-1', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'B', + mentions: [], + timestamp: 200, + threadId: 'th-1', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'C', + mentions: [], + timestamp: 300, + threadId: 'th-1', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'X', + mentions: [], + timestamp: 250, + threadId: 'th-2', + }), + ); // different thread const before300 = store.getByThreadBefore('th-1', 300, 10); assert.equal(before300.length, 2); @@ -772,24 +848,28 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); for (let index = 0; index < 100; index += 1) { - store.append({ - userId: 'u', - catId: null, - content: `other ${index}`, - mentions: [], - timestamp: index, - threadId: 'other-thread', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: `other ${index}`, + mentions: [], + timestamp: index, + threadId: 'other-thread', + }), + ); } for (let index = 0; index < 600; index += 1) { - store.append({ - userId: 'u', - catId: null, - content: `target ${index}`, - mentions: [], - timestamp: index + 100, - threadId: 'target-thread', - }); + store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: `target ${index}`, + mentions: [], + timestamp: index + 100, + threadId: 'target-thread', + }), + ); } const page = store.getByThreadBeforeBounded('target-thread', Number.MAX_SAFE_INTEGER, 500, undefined, 'u', 2_000); @@ -807,14 +887,16 @@ describe('MessageStore', () => { { type: 'text', text: 'hello' }, { type: 'image', url: '/uploads/test.png' }, ]; - const msg = store.append({ - userId: 'u', - catId: null, - content: 'hello', - mentions: [], - timestamp: 1, - contentBlocks: blocks, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: null, + content: 'hello', + mentions: [], + timestamp: 1, + contentBlocks: blocks, + }), + ); assert.deepEqual(msg.contentBlocks, blocks); }); @@ -826,14 +908,16 @@ describe('MessageStore', () => { { id: 'tool-1', type: 'tool_use', label: 'opus → Read', detail: '{"path":"/a.ts"}', timestamp: 1000 }, { id: 'toolr-1', type: 'tool_result', label: 'opus ← result', detail: 'file content...', timestamp: 1001 }, ]; - const msg = store.append({ - userId: 'u', - catId: 'opus', - content: 'done', - mentions: [], - timestamp: 1, - toolEvents, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: 'opus', + content: 'done', + mentions: [], + timestamp: 1, + toolEvents, + }), + ); assert.deepEqual(msg.toolEvents, toolEvents); // Verify toolEvents round-trip via getByThread @@ -846,14 +930,16 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'u', - catId: 'opus', - content: 'hi', - mentions: [], - timestamp: 1, - toolEvents: [{ id: 't1', type: 'tool_use', label: 'test', timestamp: 1 }], - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: 'opus', + content: 'hi', + mentions: [], + timestamp: 1, + toolEvents: [{ id: 't1', type: 'tool_use', label: 'test', timestamp: 1 }], + }), + ); assert.ok(msg.toolEvents); const deleted = store.hardDelete(msg.id, 'admin'); @@ -865,14 +951,16 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'u', - catId: 'opus', - content: 'response', - mentions: [], - timestamp: 1, - thinking: 'secret reasoning that must not survive hard delete', - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u', + catId: 'opus', + content: 'response', + mentions: [], + timestamp: 1, + thinking: 'secret reasoning that must not survive hard delete', + }), + ); assert.equal(msg.thinking, 'secret reasoning that must not survive hard delete'); const deleted = store.hardDelete(msg.id, 'admin'); @@ -886,16 +974,36 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: 'opus', content: 'hello', mentions: [], timestamp: 1, threadId: 'th' }); - store.append({ - userId: 'scheduler', - catId: 'system', - content: '[定时任务] reminder', - mentions: [], - timestamp: 2, - threadId: 'th', - }); - store.append({ userId: 'user-2', catId: null, content: 'other user', mentions: [], timestamp: 3, threadId: 'th' }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: 1, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: '[定时任务] reminder', + mentions: [], + timestamp: 2, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + content: 'other user', + mentions: [], + timestamp: 3, + threadId: 'th', + }), + ); const msgs = store.getByThread('th', 50, 'user-1'); assert.equal(msgs.length, 2, 'should include own message + scheduler message'); @@ -907,16 +1015,36 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: 'opus', content: 'hello', mentions: [], timestamp: 100, threadId: 'th' }); - store.append({ - userId: 'scheduler', - catId: 'system', - content: '[定时任务] reminder', - mentions: [], - timestamp: 200, - threadId: 'th', - }); - store.append({ userId: 'user-1', catId: null, content: 'follow-up', mentions: [], timestamp: 300, threadId: 'th' }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: 100, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: '[定时任务] reminder', + mentions: [], + timestamp: 200, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'follow-up', + mentions: [], + timestamp: 300, + threadId: 'th', + }), + ); const msgs = store.getByThreadBefore('th', 350, 50, undefined, 'user-1'); assert.equal(msgs.length, 3, 'should include all own messages + scheduler'); @@ -927,23 +1055,36 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const first = store.append({ - userId: 'user-1', - catId: null, - content: 'start', - mentions: [], - timestamp: 100, - threadId: 'th', - }); - store.append({ - userId: 'scheduler', - catId: 'system', - content: '[定时任务] digest', - mentions: [], - timestamp: 200, - threadId: 'th', - }); - store.append({ userId: 'user-2', catId: null, content: 'other', mentions: [], timestamp: 300, threadId: 'th' }); + const first = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'start', + mentions: [], + timestamp: 100, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: '[定时任务] digest', + mentions: [], + timestamp: 200, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + content: 'other', + mentions: [], + timestamp: 300, + threadId: 'th', + }), + ); const msgs = store.getByThreadAfter('th', first.id, undefined, 'user-1'); assert.equal(msgs.length, 1, 'should include scheduler message after cursor'); @@ -954,74 +1095,72 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const makeQueueCustody = (ownerUserId) => ({ - version: 1, - entryId: `entry-${ownerUserId}`, - revision: 1, - ownerUserId, - intent: 'managed command wake', - status: 'queued', - allTargetCats: ['opus5'], - pendingTargetCats: ['opus5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 200, - updatedAt: 200, - }); const source = { connector: 'hold-ball', label: '持球结果', icon: '🏓', - meta: { taskId: 'hold-ball-task-1', threadId: 'th-managed', catId: 'opus5', wakeWhen: true }, + meta: { + managedHold: true, + phase: 'wake', + taskId: 'hold-ball-task-1', + threadId: 'th-managed', + catId: 'opus5', + wakeWhen: true, + }, }; - const receipt = store.append({ - userId: 'scheduler', - catId: null, - content: '[定时任务] managed result', - mentions: [], - timestamp: 200, - threadId: 'th-managed', - deliveryStatus: 'queued', - queueCustody: makeQueueCustody('user-1'), - source, - }); - const hidden = store.append({ - userId: 'scheduler', - catId: null, - content: '[定时任务] hidden trigger', - mentions: [], - timestamp: 201, - threadId: 'th-managed', - deliveryStatus: 'queued', - queueCustody: makeQueueCustody('user-1'), - extra: { scheduler: { hiddenTrigger: true } }, - source, - }); - const foreignOwned = store.append({ - userId: 'scheduler', - catId: null, - content: 'owner-A command result', - mentions: [], - timestamp: 202, - threadId: 'th-managed', - deliveryStatus: 'queued', - queueCustody: makeQueueCustody('user-owner'), - source, - }); - const ownerless = store.append({ - userId: 'scheduler', - catId: null, - content: 'legacy ownerless result', - mentions: [], - timestamp: 203, - threadId: 'th-managed', - deliveryStatus: 'queued', - source, - }); + const receipt = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + from: { kind: 'system', service: 'hold-ball' }, + catId: null, + content: '[定时任务] managed result', + mentions: [], + timestamp: 200, + threadId: 'th-managed', + deliveryStatus: 'queued', + source, + }), + ); + const hidden = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + from: { kind: 'system', service: 'hold-ball' }, + catId: null, + content: '[定时任务] hidden trigger', + mentions: [], + timestamp: 201, + threadId: 'th-managed', + deliveryStatus: 'queued', + extra: { scheduler: { hiddenTrigger: true } }, + source, + }), + ); + const foreignOwned = store.append( + canonicalTestMessageInput({ + userId: 'user-owner', + from: { kind: 'system', service: 'hold-ball' }, + catId: null, + content: 'owner-A command result', + mentions: [], + timestamp: 202, + threadId: 'th-managed', + deliveryStatus: 'queued', + source, + }), + ); + const ownerless = store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + from: { kind: 'system', service: 'hold-ball' }, + catId: null, + content: 'legacy ownerless result', + mentions: [], + timestamp: 203, + threadId: 'th-managed', + deliveryStatus: 'queued', + source, + }), + ); assert.deepEqual(store.getByThreadAfter('th-managed', undefined, undefined, 'user-1'), []); assert.deepEqual( @@ -1037,19 +1176,8 @@ describe('MessageStore', () => { ); const terminalize = (message, deliveredAt) => { - const result = store.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: { - ...message.queueCustody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - failedByCatIds: ['opus5'], - updatedAt: deliveredAt, - }, - deliveredAt, - }); - assert.equal(result.kind, 'updated'); + const result = store.markDelivered(message.id, deliveredAt); + assert.equal(result.deliveryTransitioned, true); }; terminalize(receipt, 300); terminalize(hidden, 301); @@ -1087,15 +1215,26 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: 'opus', content: 'legit', mentions: [], timestamp: 1, threadId: 'th' }); - store.append({ - userId: 'scheduler', - catId: 'opus', - content: 'forged system message', - mentions: [], - timestamp: 2, - threadId: 'th', - }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'legit', + mentions: [], + timestamp: 1, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'opus', + content: 'forged system message', + mentions: [], + timestamp: 2, + threadId: 'th', + }), + ); const msgs = store.getByThread('th', 50, 'user-1'); assert.equal(msgs.length, 1, 'forged scheduler message must NOT bypass userId filter'); @@ -1106,15 +1245,26 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: null, content: 'hello', mentions: [], timestamp: 1, threadId: 'th' }); - store.append({ - userId: 'system', - catId: null, - content: 'Error: stream_idle_stall: Gemini stopped responding', - mentions: [], - timestamp: 2, - threadId: 'th', - }); + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'hello', + mentions: [], + timestamp: 1, + threadId: 'th', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'Error: stream_idle_stall: Gemini stopped responding', + mentions: [], + timestamp: 2, + threadId: 'th', + }), + ); const msgs = store.getByThread('th', 50, 'user-1'); assert.equal(msgs.length, 2, 'should include own message + persisted system error'); diff --git a/packages/api/test/messages-decision-notification-policy.test.js b/packages/api/test/messages-decision-notification-policy.test.js index 7535376774..166399a49a 100644 --- a/packages/api/test/messages-decision-notification-policy.test.js +++ b/packages/api/test/messages-decision-notification-policy.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { shouldMarkDecisionNotification } from '../dist/routes/messages.js'; +import { shouldMarkDecisionNotification } from '../dist/domains/cats/services/push/decision-notification-policy.js'; describe('messages decision notification policy', () => { it('marks review/merge contexts as decision-required', () => { diff --git a/packages/api/test/messages-decision-notification-route.test.js b/packages/api/test/messages-decision-notification-route.test.js deleted file mode 100644 index 1f3a51536d..0000000000 --- a/packages/api/test/messages-decision-notification-route.test.js +++ /dev/null @@ -1,155 +0,0 @@ -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; -import webpush from 'web-push'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); -const { initPushNotificationService } = await import('../dist/domains/cats/services/push/PushNotificationService.js'); - -function buildDeps() { - const invocationQueue = new InvocationQueue(); - return { - registry: new InvocationRegistry(), - messageStore: { - append: mock.fn(async (msg) => ({ id: `msg-${Date.now()}`, ...msg })), - getByThread: mock.fn(async () => []), - getByThreadBefore: mock.fn(async () => []), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - router: { - resolveTargetsAndIntent: mock.fn(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - })), - routeExecution: mock.fn(async function* () { - yield { - type: 'text', - catId: 'opus', - content: '请你拍板是否合入', - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - route: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - }, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - tryStartThread: mock.fn(() => new AbortController()), - tryStartThreadAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - cancel: mock.fn(() => ({ cancelled: true, catIds: ['opus'] })), - isDeleting: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'created', - invocationId: 'inv-stub', - })), - update: mock.fn(async () => {}), - }, - invocationQueue, - threadStore: { - get: mock.fn(async () => ({ - id: 'thread-1', - title: 'Test Thread', - createdBy: 'test-user', - })), - updateTitle: mock.fn(async () => {}), - }, - }; -} - -describe('POST /api/messages decision notification route policy', () => { - let app; - let deps; - let notifyUserMock; - - beforeEach(async () => { - deps = buildDeps(); - - const keys = webpush.generateVAPIDKeys(); - const pushService = initPushNotificationService({ - subscriptionStore: { - listAll: async () => [], - listByUser: async () => [], - upsert: async () => {}, - remove: async () => false, - removeForUser: async () => 0, - }, - vapidPublicKey: keys.publicKey, - vapidPrivateKey: keys.privateKey, - vapidSubject: 'mailto:test@example.com', - }); - notifyUserMock = mock.fn(async () => {}); - pushService.notifyUser = notifyUserMock; - - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('marks push as decision-required when cat reply asks for decision', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '继续', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - await new Promise((resolve) => setTimeout(resolve, 30)); - - assert.equal(notifyUserMock.mock.calls.length, 1); - const payload = notifyUserMock.mock.calls[0].arguments[1]; - assert.ok(payload.tag.startsWith('cat-decision-'), `expected decision tag, got ${payload.tag}`); - assert.equal(payload.data?.requiresDecision, true); - assert.match(payload.body, /请你拍板|合入/); - }); - - it('does not classify as decision when assistant emits no text chunks', async () => { - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { - type: 'tool_use', - catId: 'opus', - toolName: 'read_file', - toolInput: { path: 'README.md' }, - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '请你决定这个 PR 是否合入', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - await new Promise((resolve) => setTimeout(resolve, 30)); - - assert.equal(notifyUserMock.mock.calls.length, 1); - const payload = notifyUserMock.mock.calls[0].arguments[1]; - assert.ok( - payload.tag.startsWith('cat-reply-'), - `expected reply tag when assistant has no text, got ${payload.tag}`, - ); - assert.equal(payload.data?.requiresDecision, undefined); - }); -}); diff --git a/packages/api/test/messages-delivery-mode.test.js b/packages/api/test/messages-delivery-mode.test.js deleted file mode 100644 index 16042b127f..0000000000 --- a/packages/api/test/messages-delivery-mode.test.js +++ /dev/null @@ -1,2085 +0,0 @@ -/** - * POST /api/messages deliveryMode tests (F39) - * Tests queue/force/immediate routing logic. - */ - -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); -const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); -const { buildCapsuleFromRouteState, completeCapsuleForSeal } = await import( - '../dist/domains/cats/services/agents/invocation/CollaborationContinuityCapsule.js' -); - -const OPUS_SLOT_KEY = JSON.stringify(['thread-1', 'opus']); -const CODEX_SLOT_KEY = JSON.stringify(['thread-1', 'codex']); - -function deferred() { - let resolve; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} - -/** Build a complete deps object for messagesRoutes */ -function buildDeps(overrides = {}) { - const invocationQueue = new InvocationQueue(); - return { - registry: new InvocationRegistry(), - messageStore: { - append: mock.fn(async (msg) => ({ id: `msg-${Date.now()}`, ...msg })), - getByThread: mock.fn(async () => []), - getByThreadBefore: mock.fn(async () => []), - // Whole-message selection resolves the canonical bubble group, so the timeline this double - // exposes must contain whatever source record the individual test stubbed via getById. - getByThreadAfter: mock.fn(async function getByThreadAfter(threadId) { - const source = await this.getById?.('source-message-1'); - return source && source.threadId === threadId ? [source] : []; - }), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - router: { - resolveTargetsAndIntent: mock.fn(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - })), - resolveExplicitTargets: mock.fn(async (targetCats) => targetCats), - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - route: mock.fn(async function* () { - yield { type: 'done' }; - }), - }, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - tryStartThread: mock.fn(() => new AbortController()), - tryStartThreadAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - cancel: mock.fn(() => ({ cancelled: true, catIds: ['opus'] })), - cancelAll: mock.fn(() => ({ catIds: ['opus'], executionIds: [] })), - cancelInvocation: mock.fn(() => ['opus']), - isDeleting: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'created', - invocationId: 'inv-stub', - })), - update: mock.fn(async () => {}), - get: mock.fn(async () => null), - }, - invocationQueue, - queueProcessor: { - clearPause: mock.fn(), - onInvocationComplete: mock.fn(async () => {}), - enqueueContinuation: mock.fn(async () => ({ outcome: 'enqueued' })), - }, - threadStore: { - get: mock.fn(async () => ({ - id: 'thread-1', - title: 'Test Thread', - createdBy: 'test-user', - })), - updateTitle: mock.fn(async () => {}), - }, - ...overrides, - }; -} - -function wireRealExecutionOwners(deps) { - const tracker = new InvocationTracker(); - deps.invocationTracker = tracker; - const processor = new QueueProcessor( - /** @type {any} */ ({ - queue: deps.invocationQueue, - invocationTracker: tracker, - invocationRecordStore: deps.invocationRecordStore, - router: deps.router, - socketManager: deps.socketManager, - messageStore: { - ...deps.messageStore, - getById: mock.fn(async () => null), - }, - log: { - info: mock.fn(), - warn: mock.fn(), - error: mock.fn(), - }, - }), - ); - deps.queueProcessor = processor; - return { tracker, processor }; -} - -describe('POST /api/messages deliveryMode', () => { - let app; - let deps; - - beforeEach(async () => { - deps = buildDeps(); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('queue mode + active invocation → enqueues and returns 202', async () => { - // Simulate active invocation - deps.invocationTracker.has.mock.mockImplementation(() => true); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '你好猫猫', - threadId: 'thread-1', - deliveryMode: 'queue', - }, - }); - - assert.equal(res.statusCode, 202); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued'); - assert.equal(body.merged, false); - assert.ok(body.entryId); - assert.equal(body.queuePosition, 1); - assert.match(body.userMessageId, /^msg-/); - - // Should NOT have created InvocationRecord (queued, not executing) - assert.equal(deps.invocationRecordStore.create.mock.calls.length, 0); - - // Should have written user message to messageStore - assert.equal(deps.messageStore.append.mock.calls.length, 1); - const queuedWrite = deps.messageStore.append.mock.calls[0].arguments[0]; - assert.equal(queuedWrite.deliveryStatus, 'queued'); - assert.equal(queuedWrite.queueCustody.entryId, body.entryId); - assert.equal(queuedWrite.queueCustody.status, 'queued'); - assert.deepEqual(queuedWrite.queueCustody.pendingTargetCats, ['opus']); - - // Should have emitted queue_updated to user - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const queueUpdate = emitCalls.find((c) => c.arguments[1] === 'queue_updated'); - assert.ok(queueUpdate, 'should emit queue_updated'); - assert.equal(queueUpdate.arguments[2].action, 'enqueued'); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1')[0].ownerAuthProvenance, 'strict'); - assert.equal( - Object.hasOwn(queueUpdate.arguments[2].queue[0], 'ownerAuthProvenance'), - false, - 'internal auth provenance must not enter queue_updated payloads', - ); - }); - - it('queue mode replay with same idempotencyKey does not append duplicate message', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - const first = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '会重放', - threadId: 'thread-1', - deliveryMode: 'queue', - idempotencyKey: '11111111-1111-4111-8111-111111111111', - }, - }); - assert.equal(first.statusCode, 202); - const firstBody = JSON.parse(first.body); - - const replay = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '会重放', - threadId: 'thread-1', - deliveryMode: 'queue', - idempotencyKey: '11111111-1111-4111-8111-111111111111', - }, - }); - assert.equal(replay.statusCode, 202); - const replayBody = JSON.parse(replay.body); - - assert.equal(deps.messageStore.append.mock.calls.length, 1, 'replay should not append again'); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 1, 'replay should not add a new queue row'); - assert.equal(replayBody.entryId, firstBody.entryId, 'replay should point to existing queue entry'); - assert.equal(replayBody.userMessageId, firstBody.userMessageId, 'replay should reuse original user message'); - }); - - it('F294 admits one refs-only Bundle message and routes only the explicit cats', async () => { - const sourceBody = 'private source body must never enter the durable target summary'; - deps.messageStore.getById = mock.fn(async (messageId) => - messageId === 'source-message-1' - ? { - id: messageId, - threadId: 'source-thread', - userId: 'user-1', - catId: 'opus', - content: sourceBody, - mentions: [], - timestamp: 1000, - } - : null, - ); - deps.threadStore.get = mock.fn(async (threadId) => ({ - id: threadId, - title: threadId === 'source-thread' ? 'Source Thread' : 'Target Thread', - createdBy: 'user-1', - participants: [], - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '', - threadId: 'thread-1', - deliveryMode: 'immediate', - idempotencyKey: '22222222-2222-4222-8222-222222222222', - messageBundle: { - sourceThreadId: 'source-thread', - note: 'please focus on the source decision', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: ['opus'], - }, - }, - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(res.statusCode, 200, res.body); - assert.equal(deps.router.resolveTargetsAndIntent.mock.calls.length, 0, 'Bundle prose must not drive routing'); - assert.deepEqual(deps.router.resolveExplicitTargets.mock.calls[0].arguments[0], ['opus']); - const initialAppend = deps.messageStore.append.mock.calls[0].arguments[0]; - assert.deepEqual(initialAppend.mentions, ['opus']); - assert.deepEqual(initialAppend.extra.messageBundle, { - v: 1, - sourceThreadId: 'source-thread', - note: 'please focus on the source decision', - items: [{ kind: 'message', messageId: 'source-message-1' }], - }); - assert.equal(initialAppend.content.includes(sourceBody), false, 'durable summary must not copy source bodies'); - assert.match(initialAppend.content, /Source Thread/); - const prompt = deps.router.routeExecution.mock.calls[0].arguments[1]; - assert.notEqual(prompt, initialAppend.content); - assert.match(prompt, /\[Message Bundle\]/); - assert.match(prompt, /Bundle ID: /); - assert.match(prompt, /Source thread: "Source Thread" \(source-thread\)/); - assert.match(prompt, /Source author: cat:@opus/); - assert.match(prompt, /Source message ref: source-message-1/); - assert.match(prompt, /Bundle note by user:user-1:\nplease focus on the source decision/); - assert.match(prompt, /private source body must never enter the durable target summary/); - const routeOptions = deps.router.routeExecution.mock.calls[0].arguments[6]; - assert.deepEqual(routeOptions.persistedPromptMessageIds, [initialAppend.id ?? JSON.parse(res.body).userMessageId]); - assert.equal(routeOptions.persistedPromptMessages[0].messageId, JSON.parse(res.body).userMessageId); - assert.equal(routeOptions.persistedPromptMessages[0].forceExplicitProjection, true); - assert.deepEqual(deps.router.routeExecution.mock.calls[0].arguments[4], ['opus']); - }); - - it('F294 immediate replay reuses one Bundle identity without a second append or wake', async () => { - deps.messageStore.getById = mock.fn(async () => ({ - id: 'source-message-1', - threadId: 'source-thread', - userId: 'user-1', - catId: null, - content: 'source body', - mentions: [], - timestamp: 1000, - })); - deps.threadStore.get = mock.fn(async (threadId) => ({ - id: threadId, - title: threadId === 'source-thread' ? 'Source Thread' : 'Target Thread', - createdBy: 'user-1', - participants: [], - })); - - let createCount = 0; - let storedUserMessageId; - deps.invocationRecordStore.create = mock.fn(async () => ({ - outcome: createCount++ === 0 ? 'created' : 'duplicate', - invocationId: 'inv-f294-replay', - })); - deps.invocationRecordStore.update = mock.fn(async (_invocationId, patch) => { - if (patch.userMessageId) storedUserMessageId = patch.userMessageId; - }); - deps.invocationRecordStore.get = mock.fn(async () => ({ - invocationId: 'inv-f294-replay', - userMessageId: storedUserMessageId, - })); - - const payload = { - content: '', - threadId: 'thread-1', - deliveryMode: 'immediate', - idempotencyKey: '44444444-4444-4444-8444-444444444444', - messageBundle: { - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: ['opus'], - }, - }; - - const first = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload, - }); - await new Promise((resolve) => setImmediate(resolve)); - const replay = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload, - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(first.statusCode, 200, first.body); - assert.equal(replay.statusCode, 200, replay.body); - assert.equal(JSON.parse(replay.body).status, 'duplicate'); - assert.equal(JSON.parse(replay.body).messageBundleId, JSON.parse(first.body).messageBundleId); - assert.equal(deps.messageStore.append.mock.calls.length, 1, 'replay must not append a second Bundle card'); - assert.equal(deps.router.routeExecution.mock.calls.length, 1, 'replay must not wake the target cat twice'); - assert.equal( - deps.socketManager.emitToUser.mock.calls.filter((call) => call.arguments[1] === 'thread_updated').length, - 1, - 'replay must not republish participant mutation', - ); - }); - - it('F294 queue replay keeps one Bundle identity and persists the carrier in the initial append', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - deps.messageStore.getById = mock.fn(async () => ({ - id: 'source-message-1', - threadId: 'source-thread', - userId: 'user-1', - catId: null, - content: 'source body', - mentions: [], - timestamp: 1000, - })); - deps.threadStore.get = mock.fn(async (threadId) => ({ - id: threadId, - title: threadId === 'source-thread' ? 'Source Thread' : 'Target Thread', - createdBy: 'user-1', - participants: [], - })); - const payload = { - content: '', - threadId: 'thread-1', - deliveryMode: 'queue', - idempotencyKey: '33333333-3333-4333-8333-333333333333', - messageBundle: { - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: ['opus'], - }, - }; - - const first = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload, - }); - const replay = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload, - }); - - assert.equal(first.statusCode, 202, first.body); - assert.equal(replay.statusCode, 202, replay.body); - assert.equal(deps.messageStore.append.mock.calls.length, 1); - const initialAppend = deps.messageStore.append.mock.calls[0].arguments[0]; - assert.equal(initialAppend.deliveryStatus, 'queued'); - assert.deepEqual(initialAppend.extra.messageBundle.items, [{ kind: 'message', messageId: 'source-message-1' }]); - assert.equal(initialAppend.content.includes('source body'), false); - assert.equal(JSON.parse(replay.body).userMessageId, JSON.parse(first.body).userMessageId); - }); - - it('F294 rejects mixed carriers and unauthorized targets before any append, queue, or invocation', async () => { - deps.messageStore.getById = mock.fn(async () => ({ - id: 'source-message-1', - threadId: 'source-thread', - userId: 'user-1', - catId: null, - content: 'source body', - mentions: [], - timestamp: 1000, - })); - deps.threadStore.get = mock.fn(async (threadId) => ({ - id: threadId, - title: 'Foreign Target', - createdBy: threadId === 'thread-1' ? 'another-user' : 'user-1', - participants: [], - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: 'client-authored body is forbidden for a Bundle target message', - threadId: 'thread-1', - replyTo: 'reply-parent', - visibility: 'whisper', - whisperTo: ['opus'], - messageBundle: { - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: ['opus'], - }, - }, - }); - - assert.equal(res.statusCode, 400, res.body); - assert.equal(deps.messageStore.append.mock.calls.length, 0); - assert.equal(deps.invocationRecordStore.create.mock.calls.length, 0); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0); - assert.equal(deps.router.resolveTargetsAndIntent.mock.calls.length, 0); - }); - - it('F294 rejects an over-limit explicit target set before routing', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '', - threadId: 'thread-1', - messageBundle: { - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: Array.from({ length: 11 }, (_, index) => `cat-${index}`), - }, - }, - }); - - assert.equal(res.statusCode, 400, res.body); - assert.equal(deps.router.resolveExplicitTargets.mock.calls.length, 0); - assert.equal(deps.messageStore.append.mock.calls.length, 0); - }); - - it('F294 rejects an unauthorized target before reading sources or creating side effects', async () => { - deps.messageStore.getById = mock.fn(async () => { - throw new Error('source resolution must not run before target authorization'); - }); - deps.threadStore.get = mock.fn(async (threadId) => ({ - id: threadId, - title: 'Foreign Target', - createdBy: threadId === 'thread-1' ? 'another-user' : 'user-1', - participants: [], - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '', - threadId: 'thread-1', - messageBundle: { - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: ['opus'], - }, - }, - }); - - assert.equal(res.statusCode, 403, res.body); - assert.equal(deps.messageStore.getById.mock.calls.length, 0); - assert.equal(deps.router.resolveExplicitTargets.mock.calls.length, 0); - assert.equal(deps.messageStore.append.mock.calls.length, 0); - assert.equal(deps.invocationRecordStore.create.mock.calls.length, 0); - }); - - it('F294 rejects an unavailable explicit cat set without fallback or side effects', async () => { - deps.messageStore.getById = mock.fn(async () => ({ - id: 'source-message-1', - threadId: 'source-thread', - userId: 'user-1', - catId: null, - content: 'source body', - mentions: [], - timestamp: 1000, - })); - deps.threadStore.get = mock.fn(async (threadId) => ({ - id: threadId, - title: threadId === 'source-thread' ? 'Source Thread' : 'Target Thread', - createdBy: 'user-1', - participants: [], - })); - deps.router.resolveExplicitTargets.mock.mockImplementation(async () => []); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '', - threadId: 'thread-1', - messageBundle: { - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: ['missing-cat'], - }, - }, - }); - - assert.equal(res.statusCode, 400, res.body); - assert.equal(deps.router.resolveTargetsAndIntent.mock.calls.length, 0); - assert.equal(deps.messageStore.append.mock.calls.length, 0); - assert.equal(deps.invocationRecordStore.create.mock.calls.length, 0); - }); - - it('F294 rejects a Bundle when the queue is full without creating a ghost card or invocation', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - deps.threadStore.addParticipants = mock.fn(async () => {}); - deps.messageStore.getById = mock.fn(async () => ({ - id: 'source-message-1', - threadId: 'source-thread', - userId: 'user-1', - catId: null, - content: 'source body', - mentions: [], - timestamp: 1000, - })); - deps.threadStore.get = mock.fn(async (threadId) => ({ - id: threadId, - title: threadId === 'source-thread' ? 'Source Thread' : 'Target Thread', - createdBy: 'user-1', - participants: [], - })); - for (let i = 0; i < 5; i++) { - deps.invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: `existing ${i}`, - source: 'user', - targetCats: [`cat${i}`], - intent: 'execute', - }); - } - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '', - threadId: 'thread-1', - deliveryMode: 'queue', - messageBundle: { - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-message-1' }], - targetCats: ['opus'], - }, - }, - }); - - assert.equal(res.statusCode, 429, res.body); - assert.equal(deps.messageStore.append.mock.calls.length, 0); - assert.equal(deps.invocationRecordStore.create.mock.calls.length, 0); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 5); - assert.equal(deps.threadStore.addParticipants.mock.calls.length, 0); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some((call) => call.arguments[1] === 'thread_updated'), - false, - ); - }); - - it('F294 forbids Message Bundle submission through multipart instead of silently sending ordinary content', async () => { - const boundary = '----cat-cafe-f294-boundary'; - const payload = Buffer.concat([ - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="content"\r\n\r\nshould not send\r\n`), - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="threadId"\r\n\r\nthread-1\r\n`), - Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="messageBundle"\r\n\r\n${JSON.stringify({ sourceThreadId: 'source-thread', items: [{ kind: 'message', messageId: 'source-message-1' }], targetCats: ['opus'] })}\r\n`, - ), - Buffer.from(`--${boundary}--\r\n`), - ]); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { - 'x-cat-cafe-user': 'user-1', - 'content-type': `multipart/form-data; boundary=${boundary}`, - }, - payload, - }); - - assert.equal(res.statusCode, 400, res.body); - assert.deepEqual(JSON.parse(res.body), { error: 'Message Bundle does not support multipart uploads' }); - assert.equal(deps.messageStore.append.mock.calls.length, 0); - assert.equal(deps.invocationRecordStore.create.mock.calls.length, 0); - }); - - it('queue mode → same-user consecutive messages are independent entries (F175: no merge)', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - // First message - await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '第一条', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - - // Second message — same user, same target → independent entry (F175) - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '第二条', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - - assert.equal(res.statusCode, 202); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued'); - assert.equal(body.merged, false, 'F175: no merge, each message is independent'); - - // Queue should have 2 separate entries - const queue = deps.invocationQueue.list('thread-1', 'user-1'); - assert.equal(queue.length, 2); - assert.equal(queue[0].content, '第一条'); - assert.equal(queue[1].content, '第二条'); - }); - - it('queue mode → returns 429 when queue full (no ghost message)', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - // Fill queue to capacity (5 entries with different targets to prevent merge) - for (let i = 0; i < 5; i++) { - deps.invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: `msg ${i}`, - source: 'user', - targetCats: [`cat${i}`], - intent: 'execute', - }); - } - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: 'overflow', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - - assert.equal(res.statusCode, 429); - const body = JSON.parse(res.body); - assert.equal(body.code, 'QUEUE_FULL'); - - // Should NOT have written to messageStore (no ghost message) - assert.equal(deps.messageStore.append.mock.calls.length, 0); - - // Should have emitted queue_full_warning - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const fullWarning = emitCalls.find((c) => c.arguments[1] === 'queue_full_warning'); - assert.ok(fullWarning, 'should emit queue_full_warning'); - }); - - it('queue mode → messageStore failure rolls back queue entry', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - deps.messageStore.append.mock.mockImplementation(async () => { - throw new Error('DB write failed'); - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '会失败', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - - // Fastify catches the thrown error and returns 500 - assert.equal(res.statusCode, 500); - - // The queue should be empty (entry was rolled back) - const queue = deps.invocationQueue.list('thread-1', 'user-1'); - assert.equal(queue.length, 0, 'queue entry should be rolled back on messageStore failure'); - }); - - it('force mode → cancels active invocation then executes immediately', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - const _res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '强制发送', threadId: 'thread-1', deliveryMode: 'force' }, - }); - - // F-parallel-cancel (cloud #6): force = scoped preempt of the TARGET invocation → cancelInvocation - // (not cancelAll, which would also abort an unrelated side-dispatch in the same thread). - assert.ok( - deps.invocationTracker.cancelInvocation.mock.calls.length > 0, - 'force should cancelInvocation (scoped preempt) the active invocation', - ); - - // Should have broadcast cancel messages - const broadcastCalls = deps.socketManager.broadcastAgentMessage.mock.calls; - const cancelMsg = broadcastCalls.find((c) => c.arguments[0]?.type === 'system_info'); - assert.ok(cancelMsg, 'should broadcast cancel system_info'); - - // Should have proceeded to create InvocationRecord (immediate path) - assert.ok(deps.invocationRecordStore.create.mock.calls.length > 0); - }); - - it('force mode acquires the replacement through QueueProcessor joint ownership', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - const replacement = new AbortController(); - deps.queueProcessor.acquireExternalExecution = mock.fn((_threadId, _targetCats, _userId, options) => { - options.onOwnershipValidated?.(); - return replacement; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '原子替换', threadId: 'thread-1', deliveryMode: 'force' }, - }); - - assert.equal(res.statusCode, 200); - assert.equal(deps.queueProcessor.acquireExternalExecution.mock.calls.length, 1); - const acquisitionArgs = deps.queueProcessor.acquireExternalExecution.mock.calls[0].arguments; - assert.equal(acquisitionArgs[0], 'thread-1'); - assert.deepEqual(acquisitionArgs[1], ['opus']); - assert.equal(acquisitionArgs[2], 'user-1'); - assert.equal(acquisitionArgs[3].mode, 'replacement'); - assert.equal(acquisitionArgs[3].executionId, 'inv-stub'); - assert.equal(typeof acquisitionArgs[3].onOwnershipValidated, 'function'); - assert.equal(deps.invocationTracker.cancelInvocation.mock.calls.length, 1); - }); - - it('force mode cancels a same-user invocation that arrives while record creation is pending', async () => { - await app.close(); - deps = buildDeps(); - const forceCreate = deferred(); - deps.invocationRecordStore.create.mock.mockImplementation(async () => forceCreate.promise); - const { tracker } = wireRealExecutionOwners(deps); - - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - - const responsePromise = app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '创建记录期间也要完整抢占', threadId: 'thread-1', deliveryMode: 'force' }, - }); - while (deps.invocationRecordStore.create.mock.calls.length === 0) { - await new Promise((resolve) => setImmediate(resolve)); - } - - const lateController = tracker.startAll('thread-1', ['opus', 'codex'], 'user-1', 'inv-late'); - assert.equal(tracker.classifyExecutionId('thread-1', 'codex', 'inv-late'), 'matching'); - - forceCreate.resolve({ outcome: 'created', invocationId: 'inv-force' }); - const res = await responsePromise; - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(res.statusCode, 200); - assert.equal(lateController.signal.aborted, true, 'late same-user invocation must be aborted as one batch'); - assert.equal( - tracker.classifyExecutionId('thread-1', 'codex', 'inv-late'), - 'absent', - 'the non-primary sibling must not remain owned after force replacement', - ); - }); - - it('force mode rejects a foreign tracker owner before routing or clearing its pause state', async () => { - await app.close(); - deps = buildDeps(); - const { tracker, processor } = wireRealExecutionOwners(deps); - const foreignController = tracker.startAll('thread-1', ['opus'], 'user-b', 'inv-user-b'); - /** @type {any} */ (processor).pausedSlots.set(OPUS_SLOT_KEY, 'failed'); - /** @type {any} */ (processor).pauseEpoch.set(OPUS_SLOT_KEY, 3); - - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: { content: '不能抢走别人的猫', threadId: 'thread-1', deliveryMode: 'force' }, - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(res.statusCode, 409); - assert.equal(JSON.parse(res.body).code, 'INVOCATION_OWNER_CHANGED'); - assert.equal(deps.router.routeExecution.mock.calls.length, 0, 'rejected force request must not route'); - assert.equal(foreignController.signal.aborted, false); - assert.equal(tracker.getUserId('thread-1', 'opus'), 'user-b'); - assert.equal(tracker.classifyExecutionId('thread-1', 'opus', 'inv-user-b'), 'matching'); - assert.equal( - /** @type {any} */ (processor).pausedSlots.get(OPUS_SLOT_KEY), - 'failed', - 'foreign pause state must remain untouched', - ); - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(OPUS_SLOT_KEY), 3); - const terminalized = deps.invocationRecordStore.update.mock.calls.find( - (call) => call.arguments[0] === 'inv-stub' && call.arguments[1]?.status === 'canceled', - ); - assert.ok(terminalized, 'created replacement record must be terminalized before returning 409'); - }); - - it('force mode rejects a mixed-user target set before canceling any requester-owned sibling', async () => { - await app.close(); - deps = buildDeps(); - deps.router.resolveTargetsAndIntent = mock.fn(async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'execute' }, - })); - const { tracker, processor } = wireRealExecutionOwners(deps); - const requesterController = tracker.startAll('thread-1', ['opus'], 'user-a', 'inv-user-a'); - const foreignController = tracker.startAll('thread-1', ['codex'], 'user-b', 'inv-user-b'); - /** @type {any} */ (processor).pausedSlots.set(OPUS_SLOT_KEY, 'failed'); - /** @type {any} */ (processor).pauseEpoch.set(OPUS_SLOT_KEY, 5); - /** @type {any} */ (processor).pausedSlots.set(CODEX_SLOT_KEY, 'failed'); - /** @type {any} */ (processor).pauseEpoch.set(CODEX_SLOT_KEY, 6); - - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: { content: '@opus @codex 不允许部分抢占', threadId: 'thread-1', deliveryMode: 'force' }, - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(res.statusCode, 409); - assert.equal(JSON.parse(res.body).code, 'INVOCATION_OWNER_CHANGED'); - assert.equal(deps.router.routeExecution.mock.calls.length, 0, 'rejected mixed-user force must not route'); - assert.equal(requesterController.signal.aborted, false, 'requester-owned sibling must survive atomic rejection'); - assert.equal(foreignController.signal.aborted, false, 'foreign sibling must survive atomic rejection'); - assert.equal(tracker.classifyExecutionId('thread-1', 'opus', 'inv-user-a'), 'matching'); - assert.equal(tracker.classifyExecutionId('thread-1', 'codex', 'inv-user-b'), 'matching'); - assert.equal(/** @type {any} */ (processor).pausedSlots.get(OPUS_SLOT_KEY), 'failed'); - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(OPUS_SLOT_KEY), 5); - assert.equal(/** @type {any} */ (processor).pausedSlots.get(CODEX_SLOT_KEY), 'failed'); - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(CODEX_SLOT_KEY), 6); - const cancelMessages = deps.socketManager.broadcastAgentMessage.mock.calls.filter( - (call) => call.arguments[0]?.type === 'system_info', - ); - assert.equal(cancelMessages.length, 0, 'rejected mixed-user force must not broadcast partial cancellation'); - }); - - it('force mode rejects a foreign pre-start reservation without routing or partially releasing custody', async () => { - await app.close(); - deps = buildDeps(); - const foreignCreate = deferred(); - let createCalls = 0; - deps.invocationRecordStore.create.mock.mockImplementation(async () => { - createCalls += 1; - if (createCalls === 1) return foreignCreate.promise; - return { outcome: 'created', invocationId: 'inv-user-a' }; - }); - const { tracker, processor } = wireRealExecutionOwners(deps); - - deps.invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-b', - content: 'foreign queued execution', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - assert.equal((await processor.processNext('thread-1', 'user-b')).started, true); - const foreignReservation = /** @type {any} */ (processor).processingSlots.get(OPUS_SLOT_KEY); - assert.ok(foreignReservation); - /** @type {any} */ (processor).pausedSlots.set(OPUS_SLOT_KEY, 'failed'); - /** @type {any} */ (processor).pauseEpoch.set(OPUS_SLOT_KEY, 4); - - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: { content: '不能抢走排队中的猫', threadId: 'thread-1', deliveryMode: 'force' }, - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(res.statusCode, 409); - assert.equal(JSON.parse(res.body).code, 'INVOCATION_OWNER_CHANGED'); - assert.equal(deps.router.routeExecution.mock.calls.length, 0, 'rejected force request must not route'); - assert.equal( - /** @type {any} */ (processor).processingSlots.get(OPUS_SLOT_KEY), - foreignReservation, - 'foreign reservation must remain the exact slot owner', - ); - assert.equal(tracker.has('thread-1', 'opus'), false); - assert.equal( - /** @type {any} */ (processor).pausedSlots.get(OPUS_SLOT_KEY), - 'failed', - 'foreign pause state must remain untouched', - ); - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(OPUS_SLOT_KEY), 4); - const terminalized = deps.invocationRecordStore.update.mock.calls.find( - (call) => call.arguments[0] === 'inv-user-a' && call.arguments[1]?.status === 'canceled', - ); - assert.ok(terminalized, 'created replacement record must be terminalized before returning 409'); - - foreignCreate.resolve({ outcome: 'created', invocationId: 'inv-user-b' }); - }); - - it('non-preemptive immediate mode checks QueueProcessor reservation ownership before tracker start', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - const controller = new AbortController(); - deps.queueProcessor.acquireExternalExecution = mock.fn(() => controller); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '安全直发', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - - assert.equal(res.statusCode, 200); - assert.equal(deps.queueProcessor.acquireExternalExecution.mock.calls.length, 1); - assert.deepEqual(deps.queueProcessor.acquireExternalExecution.mock.calls[0].arguments, [ - 'thread-1', - ['opus'], - 'user-1', - { mode: 'non_preemptive' }, - ]); - }); - - it('immediate mode when no active → normal execution (no queue)', async () => { - // has() returns false → no active invocation - deps.invocationTracker.has.mock.mockImplementation(() => false); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '直接发送', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - - assert.equal(res.statusCode, 200); - const body = JSON.parse(res.body); - assert.equal(body.status, 'processing'); - assert.match(body.userMessageId, /^msg-/); - - // Should go through normal path - assert.ok(deps.invocationRecordStore.create.mock.calls.length > 0); - - // Queue should be empty - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0); - }); - - it('immediate startup watchdog releases slot when routeExecution never starts provider events', async (t) => { - t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: 0 }); - await app.close(); - deps = buildDeps({ invocationStartupWatchdogMs: 50 }); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - - deps.invocationTracker.has.mock.mockImplementation(() => false); - - let capturedSignal; - deps.router.routeExecution.mock.mockImplementation( - async function* (_userId, _content, _threadId, _messageId, _cats, _intent, options) { - capturedSignal = options.signal; - await new Promise(() => {}); - }, - ); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - - assert.equal(res.statusCode, 200); - assert.equal(JSON.parse(res.body).status, 'processing'); - - t.mock.timers.tick(51); - await Promise.resolve(); - await Promise.resolve(); - - assert.equal(capturedSignal?.aborted, true, 'watchdog should abort the stuck invocation'); - assert.equal(capturedSignal?.reason, 'startup_timeout'); - assert.ok(deps.invocationTracker.completeAll.mock.calls.length > 0, 'watchdog should release tracker slot'); - - const failedUpdate = deps.invocationRecordStore.update.mock.calls.find( - (c) => c.arguments[0] === 'inv-stub' && c.arguments[1]?.status === 'failed', - ); - assert.ok(failedUpdate, 'watchdog should mark the invocation record failed'); - - const completion = deps.queueProcessor.onInvocationComplete.mock.calls.find( - (c) => c.arguments[0] === 'thread-1' && c.arguments[1] === 'opus' && c.arguments[2] === 'failed', - ); - assert.ok(completion, 'watchdog should notify queue processor so queued work is not stuck'); - }); - - it('queue completion watchdog fires when terminal bookkeeping never settles', async (t) => { - t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: 0 }); - await app.close(); - const stuckCommit = deferred(); - deps = buildDeps({ - queueCompletionWatchdogMs: 50, - sessionContinuationCoordinator: { - prepareInvocationContext: mock.fn(async ({ content }) => ({ content, sessionPolicy: 'resume' })), - commitInvocationOutcome: mock.fn(() => stuckCommit.promise), - }, - }); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - for ( - let i = 0; - i < 10 && deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls.length === 0; - i++ - ) { - await Promise.resolve(); - } - assert.equal( - deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls.length, - 1, - 'test must reach the real post-completeAll bookkeeping window', - ); - assert.equal(deps.queueProcessor.onInvocationComplete.mock.calls.length, 0); - - t.mock.timers.tick(51); - await Promise.resolve(); - await Promise.resolve(); - - assert.equal( - deps.queueProcessor.onInvocationComplete.mock.calls.length, - 1, - 'a stuck terminal write/continuation commit must not permanently suppress queue drain notification', - ); - assert.deepEqual(deps.queueProcessor.onInvocationComplete.mock.calls[0].arguments.slice(0, 4), [ - 'thread-1', - 'opus', - 'succeeded', - 'inv-stub', - ]); - - stuckCommit.resolve(); - await Promise.resolve(); - await Promise.resolve(); - assert.equal( - deps.queueProcessor.onInvocationComplete.mock.calls.length, - 1, - 'normal finally must replay the same completion idempotently after the watchdog wins', - ); - }); - - it('default broadcast with queued leftovers but no active invocation → executes immediately', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.queueProcessor = { - isThreadBusy: mock.fn(() => true), - isCatBusy: mock.fn(() => false), - onInvocationComplete: mock.fn(async () => {}), - }; - deps.invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'queued-leftover', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: 'new broadcast', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - const body = JSON.parse(res.body); - assert.equal(body.status, 'processing'); - assert.ok(deps.invocationRecordStore.create.mock.calls.length > 0); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 1, 'leftover queue must not grow'); - }); - - it('TOCTOU degrade-to-queue replay with same idempotencyKey does not append duplicate message', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.invocationTracker.tryStartThreadAll.mock.mockImplementation(() => null); - - const first = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: 'TOCTOU replay', - threadId: 'thread-1', - deliveryMode: 'immediate', - idempotencyKey: '22222222-2222-4222-8222-222222222222', - }, - }); - assert.equal(first.statusCode, 202); - const firstBody = JSON.parse(first.body); - - const replay = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: 'TOCTOU replay', - threadId: 'thread-1', - deliveryMode: 'immediate', - idempotencyKey: '22222222-2222-4222-8222-222222222222', - }, - }); - assert.equal(replay.statusCode, 202); - const replayBody = JSON.parse(replay.body); - - assert.equal(deps.messageStore.append.mock.calls.length, 1, 'replay should not append again'); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 1, 'replay should not add a new queue row'); - assert.equal(replayBody.entryId, firstBody.entryId, 'replay should point to existing queue entry'); - assert.equal(replayBody.userMessageId, firstBody.userMessageId, 'replay should reuse original user message'); - }); - - it('aborted invocation does not emit spawn_started after stop wins the race', async () => { - const controller = new AbortController(); - let releaseRunningUpdate; - const runningUpdateGate = new Promise((resolve) => { - releaseRunningUpdate = resolve; - }); - - deps.invocationTracker.tryStartThreadAll.mock.mockImplementation(() => controller); - deps.invocationRecordStore.update.mock.mockImplementation(async (_id, data) => { - if (data?.status === 'running') { - await runningUpdateGate; - } - }); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { type: 'text', catId: 'opus', content: 'late', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '先发再停', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - - assert.equal(res.statusCode, 200); - - controller.abort('user_stop'); - releaseRunningUpdate(); - await new Promise((resolve) => setTimeout(resolve, 20)); - - const spawnStarted = deps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'spawn_started'); - assert.equal(spawnStarted, undefined); - }); - - it('immediate execution passes queueHasQueuedMessages fairness callback to routeExecution', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'queued-before', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '直接发送', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 20)); - assert.ok(deps.router.routeExecution.mock.calls.length > 0); - const call = deps.router.routeExecution.mock.calls[0]; - const options = call.arguments[6]; - assert.equal(options?.ownerAuthProvenance, 'strict'); - assert.equal(options?.humanDispositionInvocationOrigin, 'direct_owner'); - assert.equal(typeof options?.queueHasQueuedMessages, 'function'); - assert.equal(options.queueHasQueuedMessages('thread-1'), true); - assert.equal(options.queueHasQueuedMessages('thread-x'), false); - }); - - it('trusted browser compatibility fallback is carried as non-strict provenance', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { origin: 'http://localhost:3003', 'content-type': 'application/json' }, - payload: { content: '兼容入口', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 20)); - const options = deps.router.routeExecution.mock.calls[0].arguments[6]; - assert.equal(options.ownerAuthProvenance, 'compatibility_fallback'); - }); - - it('immediate direct execution applies pending continuation before routeExecution', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-pending', - createdAt: Date.now(), - seal: { sessionId: 'sess-pending', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const consumedContinuation = { threadId: 'thread-1', catId: 'opus', userId: 'user-1', capsule }; - deps.sessionContinuationCoordinator = { - prepareInvocationContext: mock.fn(async ({ content }) => ({ - content: `CONTINUATION\n\n${content}`, - consumedContinuation, - sessionPolicy: 'resume', - })), - commitInvocationOutcome: mock.fn(async () => {}), - }; - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '用户继续', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.sessionContinuationCoordinator.prepareInvocationContext.mock.calls.length, 1); - assert.deepEqual(deps.sessionContinuationCoordinator.prepareInvocationContext.mock.calls[0].arguments[0], { - threadId: 'thread-1', - catId: 'opus', - userId: 'user-1', - content: '用户继续', - }); - assert.equal(deps.router.routeExecution.mock.calls[0].arguments[1], 'CONTINUATION\n\n用户继续'); - - assert.equal(deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls.length, 1); - const commitInput = deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls[0].arguments[0]; - assert.equal(commitInput.finalStatus, 'succeeded'); - assert.equal(commitInput.consumedContinuation, consumedContinuation); - }); - - it('immediate direct execution restores consumed continuation through coordinator on failure', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-pending-fail', - createdAt: Date.now(), - seal: { sessionId: 'sess-pending-fail', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const consumedContinuation = { threadId: 'thread-1', catId: 'opus', userId: 'user-1', capsule }; - deps.sessionContinuationCoordinator = { - prepareInvocationContext: mock.fn(async ({ content }) => ({ - content: `CONTINUATION\n\n${content}`, - consumedContinuation, - sessionPolicy: 'resume', - })), - commitInvocationOutcome: mock.fn(async () => {}), - }; - deps.router.routeExecution.mock.mockImplementation(async function* () { - throw new Error('route failed'); - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '用户继续失败', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls.length, 1); - const commitInput = deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls[0].arguments[0]; - assert.equal(commitInput.finalStatus, 'failed'); - assert.equal(commitInput.consumedContinuation, consumedContinuation); - }); - - it('immediate execution schedules continuation when route emits seal capsule and succeeds', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-1', sessionSeq: 1, reason: 'threshold' }, - }, - ); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '触发 seal', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.queueProcessor.enqueueContinuation.mock.calls.length, 1); - const call = deps.queueProcessor.enqueueContinuation.mock.calls[0].arguments[0]; - assert.equal(call.threadId, 'thread-1'); - assert.equal(call.userId, 'user-1'); - assert.equal(call.catId, 'opus'); - assert.equal(call.capsule.seal.sessionId, 'sess-1'); - assert.equal(call.ownerAuthProvenance, 'strict'); - }); - - it('immediate success persists produced continuation even when it was already queued', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-seal-queued', - createdAt: Date.now(), - seal: { sessionId: 'sess-queued', sessionSeq: 1, reason: 'threshold' }, - }, - ); - deps.sessionContinuationCoordinator = { - prepareInvocationContext: mock.fn(async ({ content }) => ({ content, sessionPolicy: 'resume' })), - commitInvocationOutcome: mock.fn(async () => {}), - }; - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '触发 seal 并排队', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.queueProcessor.enqueueContinuation.mock.calls.length, 1); - assert.equal(deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls.length, 1); - const commitInput = deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls[0].arguments[0]; - assert.equal(commitInput.finalStatus, 'succeeded'); - assert.deepEqual(Array.from(commitInput.producedCapsules ?? []), [capsule]); - }); - - it('immediate success does not auto-enqueue produced continuation for reborn sessions', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-reborn-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-reborn', sessionSeq: 1, reason: 'threshold' }, - }, - ); - deps.sessionContinuationCoordinator = { - prepareInvocationContext: mock.fn(async ({ content }) => ({ content, sessionPolicy: 'reborn' })), - resolveSessionStrategy: mock.fn(async () => 'reborn'), - commitInvocationOutcome: mock.fn(async () => {}), - }; - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '触发 reborn seal', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - assert.deepEqual(deps.sessionContinuationCoordinator.resolveSessionStrategy.mock.calls[0].arguments, [ - 'thread-1', - 'opus', - 'user-1', - ]); - assert.equal(deps.queueProcessor.enqueueContinuation.mock.calls.length, 0); - assert.equal(deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls.length, 1); - const commitInput = deps.sessionContinuationCoordinator.commitInvocationOutcome.mock.calls[0].arguments[0]; - assert.equal(commitInput.finalStatus, 'succeeded'); - assert.deepEqual(Array.from(commitInput.producedCapsules ?? []), [capsule]); - }); - - it('immediate multi-cat execution schedules continuation for the capsule owner cat', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'execute' }, - })); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'codex', - mode: 'parallel', - a2aEnabled: true, - }), - { - invocationId: 'inv-codex-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-codex', sessionSeq: 1, reason: 'threshold' }, - }, - ); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '触发 codex seal', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.queueProcessor.enqueueContinuation.mock.calls.length, 1); - const call = deps.queueProcessor.enqueueContinuation.mock.calls[0].arguments[0]; - assert.equal(call.threadId, 'thread-1'); - assert.equal(call.userId, 'user-1'); - assert.equal(call.catId, 'codex'); - assert.equal(call.capsule.seal.sessionId, 'sess-codex'); - }); - - it('immediate multi-cat execution notifies queue completion with every target cat', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'execute' }, - })); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus @codex handle queued bodies', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - const completion = deps.queueProcessor.onInvocationComplete.mock.calls.find( - (c) => c.arguments[0] === 'thread-1' && c.arguments[1] === 'opus' && c.arguments[2] === 'succeeded', - ); - assert.ok(completion, 'expected queue completion notification'); - assert.equal(completion.arguments[3], 'inv-stub'); - assert.deepEqual( - completion.arguments[4], - ['opus', 'codex'], - 'queue completion must include all target cats so secondary queued_seen markers can close', - ); - }); - - it('immediate multi-cat execution notifies queue completion only for cats with done evidence', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.invocationTracker.resolveFinalStatus = mock.fn(() => 'succeeded'); - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'execute' }, - })); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus @codex codex gets canceled', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - const completion = deps.queueProcessor.onInvocationComplete.mock.calls.find( - (c) => c.arguments[0] === 'thread-1' && c.arguments[1] === 'opus' && c.arguments[2] === 'succeeded', - ); - assert.ok(completion, 'expected queue completion notification'); - assert.equal(completion.arguments[3], 'inv-stub'); - assert.deepEqual(completion.arguments[4], ['opus']); - const succeededUpdate = deps.invocationRecordStore.update.mock.calls.find( - (c) => c.arguments[0] === 'inv-stub' && c.arguments[1]?.status === 'succeeded', - ); - assert.ok(succeededUpdate, 'expected durable succeeded update'); - assert.deepEqual( - succeededUpdate.arguments[1].successfulCatIds, - ['opus'], - 'the durable parent record must carry the same exact per-target success evidence', - ); - }); - - it('preserves partial success when another targeted cat ends with a terminal error', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.invocationTracker.resolveFinalStatus = mock.fn(() => 'succeeded'); - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'execute' }, - })); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { - type: 'error', - catId: 'codex', - error: 'codex terminal failure', - errorDisposition: 'terminal', - timestamp: Date.now(), - }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus @codex preserve partial success', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const terminalWrites = deps.invocationRecordStore.update.mock.calls.filter( - (call) => call.arguments[0] === 'inv-stub' && ['failed', 'succeeded'].includes(call.arguments[1]?.status), - ); - assert.deepEqual( - terminalWrites.map((call) => call.arguments[1]), - [{ status: 'succeeded', successfulCatIds: ['opus'] }], - ); - const completion = deps.queueProcessor.onInvocationComplete.mock.calls.find( - (call) => call.arguments[0] === 'thread-1' && call.arguments[1] === 'opus', - ); - assert.equal(completion?.arguments[2], 'succeeded'); - assert.deepEqual(completion?.arguments[4], ['opus']); - }); - - it('targeted terminal error persists the primary failure instead of succeeded with an empty witness', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { - type: 'error', - catId: 'opus', - error: 'authoritative_compaction_unsupported:hook_authentication_unavailable', - errorDisposition: 'terminal', - timestamp: Date.now(), - }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus compact safely', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const terminalWrites = deps.invocationRecordStore.update.mock.calls.filter( - (call) => call.arguments[0] === 'inv-stub' && ['failed', 'succeeded'].includes(call.arguments[1]?.status), - ); - assert.deepEqual( - terminalWrites.map((call) => call.arguments[1]), - [ - { - status: 'failed', - error: 'authoritative_compaction_unsupported:hook_authentication_unavailable', - }, - ], - ); - const completion = deps.queueProcessor.onInvocationComplete.mock.calls.find( - (call) => call.arguments[0] === 'thread-1' && call.arguments[1] === 'opus', - ); - assert.equal(completion?.arguments[2], 'failed'); - assert.deepEqual(completion?.arguments[4], []); - }); - - it('immediate multi-cat execution schedules continuation for every sealed cat', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'execute' }, - })); - const opusCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'opus', - mode: 'parallel', - a2aEnabled: true, - }), - { - invocationId: 'inv-opus-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-opus', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const codexCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 'thread-1', - catId: 'codex', - mode: 'parallel', - a2aEnabled: true, - }), - { - invocationId: 'inv-codex-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-codex', sessionSeq: 1, reason: 'threshold' }, - }, - ); - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: opusCapsule }), - timestamp: Date.now(), - }; - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: codexCapsule }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '触发多猫 seal', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.queueProcessor.enqueueContinuation.mock.calls.length, 2); - const calls = deps.queueProcessor.enqueueContinuation.mock.calls - .map((call) => call.arguments[0]) - .sort((a, b) => a.catId.localeCompare(b.catId)); - assert.equal(calls[0].catId, 'codex'); - assert.equal(calls[0].capsule.seal.sessionId, 'sess-codex'); - assert.equal(calls[1].catId, 'opus'); - assert.equal(calls[1].capsule.seal.sessionId, 'sess-opus'); - }); - - // ── P1-1: multipart deliveryMode extraction ── - - it('multipart request with deliveryMode=force → cancels and executes immediately', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - const boundary = '----cat-cafe-test-boundary'; - const payload = Buffer.concat([ - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="content"\r\n\r\n强制发送\r\n`), - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="threadId"\r\n\r\nthread-1\r\n`), - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="deliveryMode"\r\n\r\nforce\r\n`), - Buffer.from(`--${boundary}--\r\n`), - ]); - - const _res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { - 'content-type': `multipart/form-data; boundary=${boundary}`, - 'x-cat-cafe-user': 'user-1', - }, - payload, - }); - - // Should cancel active invocation (force mode) - assert.ok( - deps.invocationTracker.cancelInvocation.mock.calls.length > 0, - 'multipart deliveryMode=force should cancelInvocation (scoped preempt) the active invocation', - ); - - // Should NOT queue — should proceed to immediate execution - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0, 'force mode should not enqueue'); - }); - - // ── P1-2: merged entry rollback race ── - - it('enqueued entry rollback preserves merged content when messageStore fails', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - // Make messageStore.append fail on FIRST call but simulate merge during the await - let callCount = 0; - deps.messageStore.append.mock.mockImplementation(async (msg) => { - callCount++; - if (callCount === 1) { - // Simulate concurrent request B merging into A's entry during A's await - // B arrives while A is waiting for messageStore.append - deps.invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'B的消息不应该丢失', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - throw new Error('DB write failed for A'); - } - return { id: `msg-${Date.now()}`, ...msg }; - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: 'A的消息', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - - // A's request failed - assert.equal(res.statusCode, 500); - - // B's merged content should still be in the queue (not removed by A's rollback) - const queue = deps.invocationQueue.list('thread-1', 'user-1'); - assert.ok(queue.length > 0, 'queue should not be empty — B merged content must survive'); - assert.ok(queue[0].content.includes('B的消息不应该丢失'), 'B message content should survive A rollback'); - }); - - // ── P1 bugfix: abort mid-loop → must NOT ack or mark succeeded ── - - it('bugfix: signal aborted mid-loop → should NOT ack cursors or mark succeeded', async () => { - // Create a controllable AbortController - const controller = new AbortController(); - - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.invocationTracker.start.mock.mockImplementation(() => controller); - deps.invocationTracker.startAll.mock.mockImplementation(() => controller); - deps.invocationTracker.tryStartThread.mock.mockImplementation(() => controller); - deps.invocationTracker.tryStartThreadAll.mock.mockImplementation(() => controller); - - // Router that yields one message, then aborts (simulating external force-cancel), - // then ends normally (no throw) — this is the exact scenario砚砚 identified. - deps.router.routeExecution.mock.mockImplementation(async function* () { - yield { type: 'text', catId: 'opus', content: 'partial output', timestamp: Date.now() }; - // External cancel happens here (e.g., force-send fromco-creator) - controller.abort(); - // Generator ends normally — no throw. The for-await break exits the loop, - // but post-loop code must NOT run ack+succeeded. - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '测试取消', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - - // Wait for background IIFE to complete - await new Promise((resolve) => setTimeout(resolve, 200)); - - // ackCollectedCursors should NOT be called (aborted invocation) - assert.equal(deps.router.ackCollectedCursors.mock.calls.length, 0, 'should NOT ack cursors for aborted invocation'); - - // invocationRecordStore.update should have 'canceled', NOT 'succeeded' - const updateCalls = deps.invocationRecordStore.update.mock.calls; - const succeededCall = updateCalls.find((c) => c.arguments[1]?.status === 'succeeded'); - assert.ok(!succeededCall, 'should NOT mark as succeeded when signal aborted'); - - const canceledCall = updateCalls.find((c) => c.arguments[1]?.status === 'canceled'); - assert.ok(canceledCall, 'should mark as canceled when signal aborted'); - }); - - it('F148 fix: abort after partial completion still acks collected cursors', async () => { - const controller = new AbortController(); - - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.invocationTracker.start.mock.mockImplementation(() => controller); - deps.invocationTracker.startAll.mock.mockImplementation(() => controller); - deps.invocationTracker.tryStartThread.mock.mockImplementation(() => controller); - deps.invocationTracker.tryStartThreadAll.mock.mockImplementation(() => controller); - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['gemini', 'opus'], - intent: { intent: 'execute' }, - })); - deps.router.routeExecution.mock.mockImplementation( - async function* (_userId, _content, _threadId, _messageId, _targetCats, _intent, opts) { - opts.cursorBoundaries.set('gemini', 'boundary-gemini-001'); - yield { type: 'text', catId: 'gemini', content: 'done', timestamp: Date.now() }; - yield { type: 'done', catId: 'gemini', timestamp: Date.now() }; - controller.abort('preempted'); - yield { type: 'text', catId: 'opus', content: 'partial', timestamp: Date.now() }; - }, - ); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@gemini @opus 测试取消后补 ack', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - const ackCalls = deps.router.ackCollectedCursors.mock.calls; - assert.equal(ackCalls.length, 1, 'should ack collected cursors for completed cats before abort'); - assert.equal(ackCalls[0].arguments[0], 'user-1'); - assert.equal(ackCalls[0].arguments[1], 'thread-1'); - const boundaries = ackCalls[0].arguments[2]; - assert.ok(boundaries instanceof Map, 'boundaries should be a Map'); - assert.equal(boundaries.get('gemini'), 'boundary-gemini-001'); - - const updateCalls = deps.invocationRecordStore.update.mock.calls; - const succeededCall = updateCalls.find((c) => c.arguments[1]?.status === 'succeeded'); - assert.ok(!succeededCall, 'should NOT mark as succeeded when signal aborted'); - const canceledCall = updateCalls.find((c) => c.arguments[1]?.status === 'canceled'); - assert.ok(canceledCall, 'should mark as canceled when signal aborted'); - }); - - it('F148 fix: exception after partial completion still acks collected cursors', async () => { - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['gemini', 'opus'], - intent: { intent: 'execute' }, - })); - deps.router.routeExecution.mock.mockImplementation( - async function* (_userId, _content, _threadId, _messageId, _targetCats, _intent, opts) { - opts.cursorBoundaries.set('gemini', 'boundary-gemini-002'); - yield { type: 'text', catId: 'gemini', content: 'done', timestamp: Date.now() }; - yield { type: 'done', catId: 'gemini', timestamp: Date.now() }; - throw new Error('ACP process crashed'); - }, - ); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@gemini @opus 测试异常后补 ack', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - const ackCalls = deps.router.ackCollectedCursors.mock.calls; - assert.equal(ackCalls.length, 1, 'should ack collected cursors before failing the invocation'); - const boundaries = ackCalls[0].arguments[2]; - assert.ok(boundaries instanceof Map, 'boundaries should be a Map'); - assert.equal(boundaries.get('gemini'), 'boundary-gemini-002'); - - const updateCalls = deps.invocationRecordStore.update.mock.calls; - const failedCall = updateCalls.find((c) => c.arguments[1]?.status === 'failed'); - assert.ok(failedCall, 'should mark invocation as failed on exception'); - }); - - it('default mode with active invocation → falls back to queue', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - // No deliveryMode specified → smart default - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '自动排队', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 202); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued'); - - // Should NOT have created InvocationRecord - assert.equal(deps.invocationRecordStore.create.mock.calls.length, 0); - }); -}); - -describe('POST /api/messages magic word instrumentation (F227 砚砚 P1 — detect→callback→messageId)', () => { - let app; - let deps; - let magicWordCalls; - - beforeEach(async () => { - magicWordCalls = []; - deps = buildDeps({ - onMagicWordDetected: (hits, threadId, catId, messageId, ownerUserId, messageExcerpt) => { - magicWordCalls.push({ hits, threadId, catId, messageId, ownerUserId, messageExcerpt }); - }, - }); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('fires onMagicWordDetected with the PERSISTED messageId (not guessed)', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); // queue path - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '这个方案是脚手架,得重写', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - assert.equal(res.statusCode, 202); - const persistedMessageId = JSON.parse(res.body).userMessageId; - - // tryDetectMagicWords is fire-and-forget (async dynamic import) — let it settle. - await new Promise((r) => setTimeout(r, 80)); - - assert.equal(magicWordCalls.length, 1, 'callback should fire exactly once'); - const call = magicWordCalls[0]; - assert.equal(call.hits[0].word, '脚手架'); - assert.equal(call.threadId, 'thread-1'); - // The whole point of the instrumentation-gap fix: messageId === persisted user message id - assert.equal(call.messageId, persistedMessageId); - assert.equal(call.ownerUserId, 'user-1', 'F227 P1: owner = the authenticated sender (queued path)'); - assert.ok(call.messageExcerpt?.includes('脚手架'), 'excerpt carries 原话 context'); - }); - - it('does not fire the callback when the message has no magic word', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => true); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '普通消息,没有触发词', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - assert.equal(res.statusCode, 202); - await new Promise((r) => setTimeout(r, 80)); - assert.equal(magicWordCalls.length, 0); - }); - - it('fires on the IMMEDIATE path too with the persisted messageId (砚砚 R2 P1: both paths)', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); // immediate path (no active invocation) - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '这个直接重写吧,脚手架', threadId: 'thread-1', deliveryMode: 'immediate' }, - }); - assert.equal(res.statusCode, 200); - const persistedMessageId = JSON.parse(res.body).userMessageId; - - await new Promise((r) => setTimeout(r, 80)); - - assert.equal(magicWordCalls.length, 1, 'immediate path should also fire the callback'); - assert.equal(magicWordCalls[0].hits[0].word, '脚手架'); - assert.equal(magicWordCalls[0].messageId, persistedMessageId); - assert.equal(magicWordCalls[0].ownerUserId, 'user-1', 'F227 P1: owner = the authenticated sender (immediate path)'); - }); -}); diff --git a/packages/api/test/messages-endpoint.test.js b/packages/api/test/messages-endpoint.test.js index 42f0f58413..9cbec6dae6 100644 --- a/packages/api/test/messages-endpoint.test.js +++ b/packages/api/test/messages-endpoint.test.js @@ -5,12 +5,14 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('GET /api/messages', () => { let app; let messageStore; let freshnessClosureStore; + let invocationQueue; + let queueLedgerStore; beforeEach(async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -21,15 +23,22 @@ describe('GET /api/messages', () => { const { InMemoryFreshnessClosureStore } = await import( '../dist/domains/cats/services/freshness/closure/FreshnessClosureStore.js' ); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const { InMemoryQueueLedgerStore } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js' + ); messageStore = new MessageStore(); freshnessClosureStore = new InMemoryFreshnessClosureStore(); + queueLedgerStore = new InMemoryQueueLedgerStore(); + invocationQueue = new InvocationQueue(queueLedgerStore); app = Fastify(); await app.register(messagesRoutes, { registry: new InvocationRegistry(), messageStore, socketManager: { broadcastAgentMessage: () => {} }, freshnessClosureStore, + invocationQueue, }); await app.ready(); }); @@ -46,20 +55,24 @@ describe('GET /api/messages', () => { }); it('returns messages with correct format', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'hello', - mentions: ['opus'], - timestamp: 1000, - }); - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'hi there', - mentions: [], - timestamp: 2000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'hello', + mentions: ['opus'], + timestamp: 1000, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'hi there', + mentions: [], + timestamp: 2000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -76,71 +89,73 @@ describe('GET /api/messages', () => { assert.equal(body.messages[1].content, 'hi there'); }); - it('F306 hydrates the durable semantic event used by the shared projector', async () => { - const semanticEvent = { - v: 1, - id: 'native-review:review-1:result', - kind: 'review', - occurredAt: 2_000, - reviewId: 'review-1', - stage: 'result', - summary: 'Review complete', - provenance: { provider: 'openai_codex', carrier: 'app_server' }, - }; - messageStore.append({ - userId: 'default-user', - catId: 'codex', - content: 'provider wire copy must not become hydration truth', - mentions: [], - timestamp: 2_000, - threadId: 'thread-semantic-hydration', - extra: { semanticEvent }, - }); + it('keeps structured routing warnings attached to the delivered input', async () => { + const routingWarnings = [ + { + kind: 'cat_not_found', + mention: '@missing-cat', + alternatives: [], + }, + ]; + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: '@missing-cat hello', + mentions: [], + timestamp: 1000, + extra: { routingWarnings }, + }), + ); - const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-semantic-hydration' }); + const res = await app.inject({ method: 'GET', url: '/api/messages' }); + const body = JSON.parse(res.body); - assert.equal(res.statusCode, 200); - assert.deepEqual(res.json().messages[0].extra.semanticEvent, semanticEvent); + assert.deepEqual(body.messages[0].extra.routingWarnings, routingWarnings); }); it('F247 F5 hydrates a durable cloud outbound receipt from its exact source without copying the body', async () => { - const source = messageStore.append({ - userId: 'default-user', - catId: 'codex-sol', - content: 'Please inspect the source-bound transport behavior', - mentions: ['gpt-pro'], - timestamp: 1_000, - threadId: 'thread-cloud-audit', - }); - messageStore.append({ - userId: 'system', - catId: null, - content: '已发送给 @gpt-pro,等待它从 ChatGPT 云端会话回写。', - mentions: [], - timestamp: 1_100, - threadId: 'thread-cloud-audit', - replyTo: source.id, - source: { - connector: 'cloud-bridge-status', - label: '云端猫投递', - icon: '☁️', - meta: { - presentation: 'system_notice', - noticeTone: 'info', - cloudBridgeOutboundReceipt: { - v: 1, - sourceMessageId: source.id, - sourceSender: { kind: 'cat', id: 'codex-sol', invocationId: 'inv-source-1' }, - dispatchInvocationId: 'inv-cloud-1', - targetCatId: 'gpt-pro', - status: 'sent', - transport: 'host', - hostMessageId: 'host-message-1', - idempotency: { keyKind: 'source_message_id', disposition: 'fresh' }, + const source = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'codex-sol', + content: 'Please inspect the source-bound transport behavior', + mentions: ['gpt-pro'], + timestamp: 1_000, + threadId: 'thread-cloud-audit', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: '已发送给 @gpt-pro,等待它从 ChatGPT 云端会话回写。', + mentions: [], + timestamp: 1_100, + threadId: 'thread-cloud-audit', + replyTo: source.id, + source: { + connector: 'cloud-bridge-status', + label: '云端猫投递', + icon: '☁️', + meta: { + presentation: 'system_notice', + noticeTone: 'info', + cloudBridgeOutboundReceipt: { + v: 1, + sourceMessageId: source.id, + sourceSender: { kind: 'cat', id: 'codex-sol', invocationId: 'inv-source-1' }, + dispatchInvocationId: 'inv-cloud-1', + targetCatId: 'gpt-pro', + status: 'sent', + transport: 'host', + hostMessageId: 'host-message-1', + idempotency: { keyKind: 'source_message_id', disposition: 'fresh' }, + }, }, }, - }, - }); + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-cloud-audit' }); const body = JSON.parse(res.body); @@ -154,29 +169,33 @@ describe('GET /api/messages', () => { }); it('F247 F5 never hydrates a reply preview from another thread even if a legacy receipt is malformed', async () => { - const privateSource = messageStore.append({ - userId: 'default-user', - catId: 'codex-sol', - content: 'private thread body must never cross the preview boundary', - mentions: [], - timestamp: 1_000, - threadId: 'thread-private-source', - }); - messageStore.append({ - userId: 'system', - catId: null, - content: 'legacy malformed cloud receipt', - mentions: [], - timestamp: 1_100, - threadId: 'thread-public-receipt', - replyTo: privateSource.id, - source: { - connector: 'cloud-bridge-status', - label: '云端猫投递', - icon: '☁️', - meta: { presentation: 'system_notice', noticeTone: 'info' }, - }, - }); + const privateSource = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'codex-sol', + content: 'private thread body must never cross the preview boundary', + mentions: [], + timestamp: 1_000, + threadId: 'thread-private-source', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'legacy malformed cloud receipt', + mentions: [], + timestamp: 1_100, + threadId: 'thread-public-receipt', + replyTo: privateSource.id, + source: { + connector: 'cloud-bridge-status', + label: '云端猫投递', + icon: '☁️', + meta: { presentation: 'system_notice', noticeTone: 'info' }, + }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-public-receipt' }); const receipt = res.json().messages.find((message) => message.source?.connector === 'cloud-bridge-status'); @@ -185,41 +204,39 @@ describe('GET /api/messages', () => { }); it('renders an authored queued cat seed without exposing queued user or system work', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'ordinary queued user work', - mentions: ['opus'], - timestamp: 1000, - threadId: 'thread-f128-seed', - deliveryStatus: 'queued', - }); - messageStore.append({ - userId: 'system', - catId: 'system', - content: 'queued internal system work', - mentions: [], - timestamp: 1050, - threadId: 'thread-f128-seed', - deliveryStatus: 'queued', - }); - messageStore.append({ - userId: 'default-user', - catId: 'codex-sol', - content: 'source-cat thread seed', - mentions: ['opus'], - timestamp: 1100, - threadId: 'thread-f128-seed', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-f128-seed', - intent: 'execute', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - createdAt: 1100, - updatedAt: 1100, + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'ordinary queued user work', + mentions: ['opus'], + timestamp: 1000, + threadId: 'thread-f128-seed', + deliveryStatus: 'queued', }), - }); + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: 'system', + content: 'queued internal system work', + mentions: [], + timestamp: 1050, + threadId: 'thread-f128-seed', + deliveryStatus: 'queued', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'codex-sol', + content: 'source-cat thread seed', + mentions: ['opus'], + timestamp: 1100, + threadId: 'thread-f128-seed', + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-f128-seed' }); const body = JSON.parse(res.body); @@ -230,172 +247,58 @@ describe('GET /api/messages', () => { assert.equal(body.messages[0].content, 'source-cat thread seed'); }); - it('F264 publishes a steered user message in place without making it prompt-delivered', async () => { - const steered = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'steered follow-up stays visible while the replacement runs', - mentions: ['opus'], - timestamp: 1200, - threadId: 'thread-steer-publication', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-steer-publication', - status: 'queued', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - steerRequestedByCatIds: ['opus'], - createdAt: 1200, - updatedAt: 1250, + it('keeps a pre-admission steered user message out of History', async () => { + const steered = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'steered follow-up stays visible while the replacement runs', + mentions: ['opus'], + timestamp: 1200, + threadId: 'thread-steer-publication', + deliveryStatus: 'queued', }), - }); + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-steer-publication' }); const body = JSON.parse(res.body); - assert.equal(body.messages.length, 1); - assert.equal(body.messages[0].id, steered.id); - assert.equal(body.messages[0].type, 'user'); - assert.equal(body.messages[0].timestamp, 1200); - assert.equal(body.messages[0].deliveredAt, undefined); - assert.deepEqual(body.messages[0].extra.queueReceipt.targets, [{ catId: 'opus', state: 'steering' }]); + assert.equal(body.messages.length, 0); assert.equal(messageStore.getById(steered.id).deliveryStatus, 'queued'); }); - it('F264 publishes untouched durable queued user work with its unread receipt', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'queued follow-up remains at its authored timeline position', - mentions: ['opus'], - timestamp: 1300, - threadId: 'thread-queued-publication', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-queued-publication', - status: 'queued', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - createdAt: 1300, - updatedAt: 1300, + it('keeps untouched durable queued user work out of History until admission', async () => { + const queued = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'queued follow-up remains at its authored timeline position', + mentions: ['opus'], + timestamp: 1300, + threadId: 'thread-queued-publication', + deliveryStatus: 'queued', }), - }); + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-queued-publication' }); const body = JSON.parse(res.body); - assert.equal(body.messages.length, 1); - assert.equal(body.messages[0].id, queued.id); - assert.equal(body.messages[0].deliveredAt, undefined); - assert.deepEqual(body.messages[0].extra.queueReceipt.targets, [{ catId: 'opus', state: 'queued' }]); - }); - - it('publishes owner-bound queued connector intake before its cat turn completes and keeps authored order', async () => { - const intake = messageStore.append({ - userId: 'default-user', - catId: null, - content: '[F292 Host-authored meeting-intake envelope]', - mentions: ['codex-sol'], - timestamp: 1_400, - threadId: 'thread-queued-connector-publication', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-queued-connector-publication', - allTargetCats: ['codex-sol'], - pendingTargetCats: ['codex-sol'], - createdAt: 1_400, - updatedAt: 1_400, - }), - source: { - connector: 'feishu', - label: '飞书会议入站 / 录音豆', - icon: 'feishu', - }, - }); - const reply = messageStore.append({ - userId: 'default-user', - catId: 'codex-sol', - content: '猫已经开始处理', - mentions: [], - timestamp: 1_500, - threadId: 'thread-queued-connector-publication', - }); - - const queuedResponse = await app.inject({ - method: 'GET', - url: '/api/messages?threadId=thread-queued-connector-publication', - }); - const queuedBody = queuedResponse.json(); - - assert.deepEqual( - queuedBody.messages.map((message) => message.id), - [intake.id, reply.id], - ); - assert.equal(queuedBody.messages[0].type, 'connector'); - assert.equal(queuedBody.messages[0].source.label, '飞书会议入站 / 录音豆'); - assert.deepEqual(queuedBody.messages[0].extra.queueReceipt.targets, [{ catId: 'codex-sol', state: 'queued' }]); - - await messageStore.transitionQueueCustody(intake.id, { - expectedRevision: 1, - deliveredAt: 2_000, - next: { - ...intake.queueCustody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - seenByCatIds: ['codex-sol'], - bodyExposures: [ - { - targetCatId: 'codex-sol', - invocationId: 'inv-queued-connector-publication', - seenAt: 1_450, - }, - ], - handledByCatIds: ['codex-sol'], - targetOutcomeByCatId: { - 'codex-sol': { - invocationId: 'inv-queued-connector-publication', - disposition: 'completed_with_turn', - evidenceRef: { - kind: 'invocation_lineage', - invocationId: 'inv-queued-connector-publication', - }, - handledAt: 2_000, - }, - }, - updatedAt: 2_000, - }, - }); - const deliveredResponse = await app.inject({ - method: 'GET', - url: '/api/messages?threadId=thread-queued-connector-publication', - }); - const deliveredBody = deliveredResponse.json(); - assert.deepEqual( - deliveredBody.messages.map((message) => message.id), - [intake.id, reply.id], - ); - assert.equal(deliveredBody.messages[0].deliveredAt, 2_000); - assert.equal(deliveredBody.messages[0].timelineOrderAt, 1_400); + assert.equal(body.messages.length, 0); + assert.equal(messageStore.getById(queued.id).deliveryStatus, 'queued'); }); it('F264 keeps canceled queued user work out of browser history after F5', async () => { - const canceled = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'withdrawn follow-up must not reappear', - mentions: ['opus'], - timestamp: 1350, - threadId: 'thread-canceled-publication', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-canceled-publication', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - createdAt: 1350, - updatedAt: 1350, + const canceled = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'withdrawn follow-up must not reappear', + mentions: ['opus'], + timestamp: 1350, + threadId: 'thread-canceled-publication', + deliveryStatus: 'queued', }), - }); + ); messageStore.markCanceled(canceled.id); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-canceled-publication' }); @@ -405,15 +308,17 @@ describe('GET /api/messages', () => { }); it('returns separate delivery and timeline-order timestamps for terminal published cat speech', async () => { - const speech = messageStore.append({ - userId: 'default-user', - catId: 'codex-sol', - content: 'published before recipient execution finishes', - mentions: ['opus'], - timestamp: 1100, - threadId: 'thread-published-order', - deliveryStatus: 'queued', - }); + const speech = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'codex-sol', + content: 'published before recipient execution finishes', + mentions: ['opus'], + timestamp: 1100, + threadId: 'thread-published-order', + deliveryStatus: 'queued', + }), + ); messageStore.markDelivered(speech.id, 1500); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-published-order' }); @@ -424,18 +329,20 @@ describe('GET /api/messages', () => { }); it('preserves explicit post flag with stream identity in history response', async () => { - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'standalone post', - mentions: [], - timestamp: 2000, - threadId: 'thread-explicit', - extra: { - isExplicitPost: true, - stream: { invocationId: 'inv-parent', turnInvocationId: 'turn-explicit' }, - }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'standalone post', + mentions: [], + timestamp: 2000, + threadId: 'thread-explicit', + extra: { + isExplicitPost: true, + stream: { invocationId: 'inv-parent', turnInvocationId: 'turn-explicit' }, + }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-explicit' }); const body = JSON.parse(res.body); @@ -448,106 +355,95 @@ describe('GET /api/messages', () => { }); }); - it('F264 hydrates a terminal receipt at the original user-message position', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'message sent during the turn', - mentions: ['opus'], - timestamp: 1500, - threadId: 'thread-receipt', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-receipt', - intent: 'execute', - status: 'processing', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-receipt' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-receipt', seenAt: 1600 }], - processingStartedAt: 1550, - createdAt: 1500, - updatedAt: 1550, + it('publishes a queued user source only at actual delivery without a Queue receipt', async () => { + const admission = await invocationQueue.appendAndEnqueueDurable( + messageStore, + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'message sent during the turn', + mentions: ['opus'], + timestamp: 1500, + threadId: 'thread-receipt', + deliveryStatus: 'queued', }), - }); - await messageStore.transitionQueueCustody(queued.id, { - expectedRevision: 1, - deliveredAt: 1700, - next: { - ...queued.queueCustody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - seenInvocationIdByCatId: {}, - handledByCatIds: ['opus'], - targetOutcomeByCatId: { - opus: { - invocationId: 'inv-receipt', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-receipt' }, - handledAt: 1700, - }, - }, - updatedAt: 1700, + { + userId: 'default-user', + threadId: 'thread-receipt', + kind: 'conversation_input', + from: { kind: 'user', userId: 'default-user' }, + ownerAuthProvenance: 'strict', + content: 'message sent during the turn', + intent: 'execute', + targetCats: ['opus'], + priority: 'normal', + autoExecute: false, }, - }); + ); + assert.equal(admission.outcome, 'enqueued'); + const queued = admission.message; + const entry = admission.entry; + assert.ok(entry); + assert.equal(queued.timelinePublishedAtAppend, undefined); + + const beforeDelivery = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-receipt' }); + assert.deepEqual(JSON.parse(beforeDelivery.body).messages, []); + + assert.equal( + ( + await invocationQueue.reconcileQueuedMessageTargetsDurable( + queued.threadId, + queued.userId, + entry.id, + [], + ['opus'], + {}, + ) + ).outcome, + 'updated', + ); + await messageStore.markDelivered(queued.id, 1700); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-receipt' }); const body = JSON.parse(res.body); - assert.deepEqual(body.messages[0].extra.queueReceipt, { - version: 1, - entryId: 'entry-receipt', - targets: [ - { - catId: 'opus', - state: 'handled', - invocationId: 'inv-receipt', - seenAt: 1600, - outcome: { - invocationId: 'inv-receipt', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-receipt' }, - handledAt: 1700, - }, - }, - ], - reminderAttempts: [], - }); + assert.equal(body.messages.length, 1); + assert.equal(body.messages[0].extra?.queueReceipt, undefined); assert.equal(body.messages[0].deliveredAt, 1700); - assert.equal(body.messages[0].timelineOrderAt, 1500); + assert.equal(body.messages[0].timelineOrderAt, 1700); }); it('ADR-042 hydrates original freshness and supplement reply provenance', async () => { - const original = messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'published original', - mentions: [], - timestamp: 2000, - threadId: 'thread-supplement', - extra: { - turnExecution: { - invocationId: 'child-ordinary-1', - parentInvocationId: 'parent-supplement-1', - executionKind: 'ordinary', - }, - auxiliaryTurnExecutions: [ - { - invocationId: 'child-routing-guard-1', + const original = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'published original', + mentions: [], + timestamp: 2000, + threadId: 'thread-supplement', + extra: { + turnExecution: { + invocationId: 'child-ordinary-1', parentInvocationId: 'parent-supplement-1', - executionKind: 'routing_guard', + executionKind: 'ordinary', + }, + auxiliaryTurnExecutions: [ + { + invocationId: 'child-routing-guard-1', + parentInvocationId: 'parent-supplement-1', + executionKind: 'routing_guard', + }, + ], + freshness: { + kind: 'published_with_unseen', + priorFrontierMessageId: 'msg-late', + generatedWithUnseen: ['msg-late'], + lineageId: 'temporary', }, - ], - freshness: { - kind: 'published_with_unseen', - priorFrontierMessageId: 'msg-late', - generatedWithUnseen: ['msg-late'], - lineageId: 'temporary', }, - }, - }); + }), + ); messageStore.updateExtra(original.id, { freshness: { kind: 'published_with_unseen', @@ -556,29 +452,31 @@ describe('GET /api/messages', () => { lineageId: original.id, }, }); - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'additive supplement', - mentions: [], - timestamp: 3000, - threadId: 'thread-supplement', - replyTo: original.id, - extra: { - freshness: { kind: 'fresh', priorFrontierMessageId: original.id }, - turnExecution: { - invocationId: 'child-supplement-1', - parentInvocationId: 'parent-supplement-1', - executionKind: 'freshness_supplement', - }, - supplement: { - lineageId: original.id, - supplementId: `f254-supplement:${original.id}:1`, - seq: 1, - originalMessageId: original.id, + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'additive supplement', + mentions: [], + timestamp: 3000, + threadId: 'thread-supplement', + replyTo: original.id, + extra: { + freshness: { kind: 'fresh', priorFrontierMessageId: original.id }, + turnExecution: { + invocationId: 'child-supplement-1', + parentInvocationId: 'parent-supplement-1', + executionKind: 'freshness_supplement', + }, + supplement: { + lineageId: original.id, + supplementId: `f254-supplement:${original.id}:1`, + seq: 1, + originalMessageId: original.id, + }, }, - }, - }); + }), + ); const offered = await freshnessClosureStore.offerSupplement({ lineageId: original.id, originalMessageId: original.id, @@ -645,14 +543,16 @@ describe('GET /api/messages', () => { }); it('ADR-042 repairs a historically committed decline control output during hydration', async () => { - const original = messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'published original before protocol repair', - mentions: [], - timestamp: 2000, - threadId: 'thread-supplement-control-recovery', - }); + const original = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'published original before protocol repair', + mentions: [], + timestamp: 2000, + threadId: 'thread-supplement-control-recovery', + }), + ); const offered = await freshnessClosureStore.offerSupplement({ lineageId: original.id, originalMessageId: original.id, @@ -668,23 +568,25 @@ describe('GET /api/messages', () => { invocationId: 'inv-supplement-control-recovery', now: 2200, }); - const leaked = messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: '\n\nStop hook feedback must stay internal.', - mentions: [], - timestamp: 2300, - threadId: 'thread-supplement-control-recovery', - replyTo: original.id, - extra: { - supplement: { - lineageId: original.id, - supplementId: claimed.id, - seq: 1, - originalMessageId: original.id, + const leaked = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: '\n\nStop hook feedback must stay internal.', + mentions: [], + timestamp: 2300, + threadId: 'thread-supplement-control-recovery', + replyTo: original.id, + extra: { + supplement: { + lineageId: original.id, + supplementId: claimed.id, + seq: 1, + originalMessageId: original.id, + }, }, - }, - }); + }), + ); await freshnessClosureStore.commitSupplement(claimed.id, { invocationId: 'inv-supplement-control-recovery', messageId: leaked.id, @@ -733,18 +635,20 @@ describe('GET /api/messages', () => { terminalKind: 'transcript_done', }, }; - messageStore.append({ - userId: 'default-user', - catId: 'fable-5', - content: '买到了,正在回家。', - mentions: [], - timestamp: 2000, - threadId: 'thread-recovery', - extra: { - stream: { invocationId: 'inv-recovery-1', turnInvocationId: 'inv-recovery-1' }, - recovery, - }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'fable-5', + content: '买到了,正在回家。', + mentions: [], + timestamp: 2000, + threadId: 'thread-recovery', + extra: { + stream: { invocationId: 'inv-recovery-1', turnInvocationId: 'inv-recovery-1' }, + recovery, + }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-recovery' }); const body = JSON.parse(res.body); @@ -762,39 +666,77 @@ describe('GET /api/messages', () => { }); it('maps canonical system messages to type=system', async () => { - messageStore.append({ - userId: 'system', - catId: 'system', - content: '🐺 狼人请睁眼', - mentions: [], - timestamp: 1000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: 'system', + content: '🐺 狼人请睁眼', + mentions: [], + timestamp: 1000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); assert.equal(body.messages.length, 1); assert.equal(body.messages[0].type, 'system'); - assert.equal(body.messages[0].catId, 'system'); + assert.equal(body.messages[0].catId, null); + assert.deepEqual(body.messages[0].from, { kind: 'system', service: 'test-fixture' }); + }); + + it('hydrates lifecycle delivery failures as error system messages', async () => { + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: 'system', + content: '没有可用成员可以处理这条消息。', + mentions: [], + timestamp: 100, + threadId: 'thread-delivery-failure', + lifecycle: { + kind: 'delivery_failure', + orderKey: '100:source-1:failure', + from: { kind: 'system', service: 'message_delivery' }, + status: 'failed', + sourceEntryId: 'entry-1', + inputMessageId: 'source-1', + requestedTargets: [], + reason: 'no_available_target', + createdAt: 100, + }, + }), + ); + + const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-delivery-failure' }); + const body = JSON.parse(res.body); + + assert.equal(body.messages.length, 1); + assert.equal(body.messages[0].type, 'system'); + assert.equal(body.messages[0].variant, 'error'); }); it('returns persisted system error messages with catId=null as type=system', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'ping gemini', - mentions: ['gemini'], - timestamp: 1000, - threadId: 'thread-1', - }); - messageStore.append({ - userId: 'system', - catId: null, - content: 'Error: stream_idle_stall: Gemini stopped responding', - mentions: [], - timestamp: 2000, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'ping gemini', + mentions: ['gemini'], + timestamp: 1000, + threadId: 'thread-1', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'Error: stream_idle_stall: Gemini stopped responding', + mentions: [], + timestamp: 2000, + threadId: 'thread-1', + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-1' }); const body = JSON.parse(res.body); @@ -806,20 +748,22 @@ describe('GET /api/messages', () => { }); it('keeps persisted source-backed notices on the connector path even when userId=system', async () => { - messageStore.append({ - userId: 'system', - catId: null, - content: '想交接给 @codex?把它单独放到新起一行开头,才能触发交接。', - mentions: [], - timestamp: 2500, - threadId: 'thread-connector-notice', - source: { - connector: 'inline-mention-hint', - label: '路由提示', - icon: '💡', - meta: { presentation: 'system_notice', noticeTone: 'info' }, - }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: '想交接给 @codex?把它单独放到新起一行开头,才能触发交接。', + mentions: [], + timestamp: 2500, + threadId: 'thread-connector-notice', + source: { + connector: 'inline-mention-hint', + label: '路由提示', + icon: '💡', + meta: { presentation: 'system_notice', noticeTone: 'info' }, + }, + }), + ); const res = await app.inject({ method: 'GET', @@ -835,18 +779,20 @@ describe('GET /api/messages', () => { }); it('maps a2a_routing system messages to type=system with extra.systemKind', async () => { - messageStore.append({ - userId: 'system', - catId: null, - content: '布偶猫 → 缅因猫', - mentions: [], - timestamp: 3000, - threadId: 'thread-routing', - extra: { - systemKind: 'a2a_routing', - a2aRouting: { fromCatId: 'codex', targetCatId: 'opus-47', invocationId: 'inv-routing' }, - }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: '布偶猫 → 缅因猫', + mentions: [], + timestamp: 3000, + threadId: 'thread-routing', + extra: { + systemKind: 'a2a_routing', + a2aRouting: { fromCatId: 'codex', targetCatId: 'opus-47', invocationId: 'inv-routing' }, + }, + }), + ); const res = await app.inject({ method: 'GET', @@ -868,13 +814,15 @@ describe('GET /api/messages', () => { it('respects limit parameter', async () => { for (let i = 0; i < 10; i++) { - messageStore.append({ - userId: 'default-user', - catId: null, - content: `msg ${i}`, - mentions: [], - timestamp: 1000 + i, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: `msg ${i}`, + mentions: [], + timestamp: 1000 + i, + }), + ); } const res = await app.inject({ @@ -888,13 +836,15 @@ describe('GET /api/messages', () => { it('supports cursor pagination with before', async () => { for (let i = 0; i < 5; i++) { - messageStore.append({ - userId: 'default-user', - catId: null, - content: `msg ${i}`, - mentions: [], - timestamp: 1000 + i * 100, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: `msg ${i}`, + mentions: [], + timestamp: 1000 + i * 100, + }), + ); } // Get messages before timestamp 1300 (should get msg 0, 1, 2) @@ -912,13 +862,15 @@ describe('GET /api/messages', () => { it('pagination covers all messages without gaps (regression: slice direction)', async () => { // Insert 6 messages with distinct timestamps for (let i = 0; i < 6; i++) { - messageStore.append({ - userId: 'default-user', - catId: null, - content: `msg ${i}`, - mentions: [], - timestamp: 1000 + i * 100, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: `msg ${i}`, + mentions: [], + timestamp: 1000 + i * 100, + }), + ); } // Page 1: most recent 2 @@ -965,13 +917,15 @@ describe('GET /api/messages', () => { it('composite cursor handles same-timestamp messages without gaps', async () => { // All messages at the same timestamp (simulates burst writes) for (let i = 0; i < 4; i++) { - messageStore.append({ - userId: 'default-user', - catId: null, - content: `burst ${i}`, - mentions: [], - timestamp: 5000, // all same timestamp - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: `burst ${i}`, + mentions: [], + timestamp: 5000, // all same timestamp + }), + ); } // First page: most recent 2 @@ -1000,17 +954,19 @@ describe('GET /api/messages', () => { }); it('returns toolEvents when message has them (缅因猫 R2 P1-2)', async () => { - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'I read the file', - mentions: [], - timestamp: 3000, - toolEvents: [ - { id: 'tool-1', type: 'tool_use', label: 'opus → Read', detail: '{"path":"/a.ts"}', timestamp: 3000 }, - { id: 'toolr-1', type: 'tool_result', label: 'opus ← result', detail: 'file content...', timestamp: 3001 }, - ], - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'I read the file', + mentions: [], + timestamp: 3000, + toolEvents: [ + { id: 'tool-1', type: 'tool_use', label: 'opus → Read', detail: '{"path":"/a.ts"}', timestamp: 3000 }, + { id: 'toolr-1', type: 'tool_result', label: 'opus ← result', detail: 'file content...', timestamp: 3001 }, + ], + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1025,13 +981,15 @@ describe('GET /api/messages', () => { }); it('omits toolEvents when message has none', async () => { - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'just text', - mentions: [], - timestamp: 4000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'just text', + mentions: [], + timestamp: 4000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1040,19 +998,21 @@ describe('GET /api/messages', () => { }); it('preserves stream invocation identity for persisted assistant messages', async () => { - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'persisted stream bubble', - mentions: [], - timestamp: 4500, - origin: 'stream', - extra: { - stream: { - invocationId: 'inv-stream-1', + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'persisted stream bubble', + mentions: [], + timestamp: 4500, + origin: 'stream', + extra: { + stream: { + invocationId: 'inv-stream-1', + }, }, - }, - }); + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1061,20 +1021,24 @@ describe('GET /api/messages', () => { }); it('filters by userId', async () => { - messageStore.append({ - userId: 'alice', - catId: null, - content: 'alice msg', - mentions: [], - timestamp: 1000, - }); - messageStore.append({ - userId: 'bob', - catId: null, - content: 'bob msg', - mentions: [], - timestamp: 2000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'alice msg', + mentions: [], + timestamp: 1000, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'bob', + catId: null, + content: 'bob msg', + mentions: [], + timestamp: 2000, + }), + ); const res = await app.inject({ method: 'GET', @@ -1089,19 +1053,21 @@ describe('GET /api/messages', () => { // ── F97: Connector message type mapping ────────────────────────── it('maps message with source field to type=connector', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'GitHub Review 通知', - mentions: ['opus'], - timestamp: 9000, - source: { - connector: 'github-review', - label: 'GitHub Review', - icon: '🔔', - url: 'https://github.com/org/repo/pull/42', - }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'GitHub Review 通知', + mentions: ['opus'], + timestamp: 9000, + source: { + connector: 'github-review', + label: 'GitHub Review', + icon: '🔔', + url: 'https://github.com/org/repo/pull/42', + }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1117,20 +1083,22 @@ describe('GET /api/messages', () => { }); it('includes source.meta in API response (F098-C: needed for direction parsing)', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'review notification', - mentions: ['opus'], - timestamp: 9002, - source: { - connector: 'github-review', - label: 'GitHub Review', - icon: '🔔', - url: 'https://github.com/org/repo/pull/42', - meta: { targets: ['codex', 'gpt52'], initiator: 'opus' }, - }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'review notification', + mentions: ['opus'], + timestamp: 9002, + source: { + connector: 'github-review', + label: 'GitHub Review', + icon: '🔔', + url: 'https://github.com/org/repo/pull/42', + meta: { targets: ['codex', 'gpt52'], initiator: 'opus' }, + }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1146,14 +1114,16 @@ describe('GET /api/messages', () => { }); it('serializes deliveredAt when present (F098-D P3 regression)', async () => { - const stored = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'queued message', - mentions: [], - timestamp: 5000, - deliveryStatus: 'queued', - }); + const stored = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'queued message', + mentions: [], + timestamp: 5000, + deliveryStatus: 'queued', + }), + ); messageStore.markDelivered(stored.id, 12000); const res = await app.inject({ method: 'GET', url: '/api/messages' }); @@ -1163,13 +1133,15 @@ describe('GET /api/messages', () => { }); it('omits deliveredAt when not set (F098-D P3 regression)', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'immediate message', - mentions: [], - timestamp: 6000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'immediate message', + mentions: [], + timestamp: 6000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1178,15 +1150,17 @@ describe('GET /api/messages', () => { }); it('serializes extra.targetCats when present (F098-C1 regression)', async () => { - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'review done', - mentions: ['codex'], - origin: 'callback', - timestamp: 7000, - extra: { targetCats: ['codex', 'gpt52'] }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'review done', + mentions: ['codex'], + origin: 'callback', + timestamp: 7000, + extra: { targetCats: ['codex', 'gpt52'] }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1199,13 +1173,15 @@ describe('GET /api/messages', () => { }); it('message without source and without catId is type=user', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'normal user message', - mentions: [], - timestamp: 9001, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'normal user message', + mentions: [], + timestamp: 9001, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -1214,505 +1190,6 @@ describe('GET /api/messages', () => { }); }); -describe('POST /api/messages/:messageId/queue-targets/:targetCatId/retry', () => { - let app; - let messageStore; - let retryCalls; - let authorityDecision; - let authorityDecisions; - let authorityCalls; - let commitDecision; - let commitDecisions; - let commitCalls; - let invocationQueue; - let authorityImplementation; - let WaitContinuationRetryPreflight; - - beforeEach(async () => { - const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - const { InvocationRegistry } = await import( - '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' - ); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - ({ WaitContinuationRetryPreflight } = await import( - '../dist/domains/ball-custody/WaitContinuationRetryPreflight.js' - )); - messageStore = new MessageStore(); - retryCalls = []; - authorityDecision = { ok: true, kind: 'user' }; - authorityDecisions = []; - authorityCalls = []; - commitDecision = { outcome: 'committed' }; - commitDecisions = []; - commitCalls = []; - invocationQueue = { getEntrySnapshotForUserById: () => null }; - authorityImplementation = undefined; - app = Fastify(); - await app.register(messagesRoutes, { - registry: new InvocationRegistry(), - messageStore, - socketManager: { broadcastAgentMessage: () => {}, emitToUser: () => {} }, - invocationQueue, - queueProcessor: { - retryFailedTarget: async (...args) => { - retryCalls.push(args); - const commitAuthority = args[5]; - if (typeof commitAuthority === 'function') { - const committed = await commitAuthority([]); - if (committed.outcome !== 'committed') return committed; - } - return { outcome: 'retried', attemptId: 'entry-retry:opus:2' }; - }, - }, - retryAuthorityPreflight: { - preflight: async (input) => { - authorityCalls.push(input); - if (authorityImplementation) return authorityImplementation.preflight(input); - return authorityDecisions.shift() ?? authorityDecision; - }, - }, - retryAuthorityCommitter: { - commit: async (input) => { - commitCalls.push(input); - return commitDecisions.shift() ?? commitDecision; - }, - }, - }); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('projects the current owner-only retry fence for an inline recovery card', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: '@gpt-pro hello', - mentions: ['gpt-pro'], - timestamp: 1_000, - threadId: 'thread-f247-recovery', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-recovery', - allTargetCats: ['gpt-pro'], - pendingTargetCats: ['gpt-pro'], - failedByCatIds: ['gpt-pro'], - targetAttempts: [ - { - id: 'entry-recovery:gpt-pro:1', - targetCatId: 'gpt-pro', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - - const response = await app.inject({ - method: 'GET', - url: `/api/messages/${queued.id}/queue-targets/gpt-pro/retry-authority`, - }); - - assert.equal(response.statusCode, 200); - assert.deepEqual(JSON.parse(response.body), { attemptId: 'entry-recovery:gpt-pro:1' }); - assert.equal(authorityCalls.length, 1); - assert.equal(authorityCalls[0].message.id, queued.id); - assert.equal(retryCalls.length, 0); - }); - - it('projects pending target state without granting retry or disclosing it to another owner', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: '@gpt-pro hello', - mentions: ['gpt-pro'], - timestamp: 1_000, - threadId: 'thread-f247-pending', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-pending', - allTargetCats: ['gpt-pro'], - pendingTargetCats: ['gpt-pro'], - targetAttempts: [ - { - id: 'entry-pending:gpt-pro:1', - targetCatId: 'gpt-pro', - sequence: 1, - state: 'queued', - createdAt: 1_000, - updatedAt: 1_100, - }, - ], - }), - }); - const url = `/api/messages/${queued.id}/queue-targets/gpt-pro/retry-authority`; - const response = await app.inject({ method: 'GET', url }); - assert.equal(response.statusCode, 409); - assert.equal(JSON.parse(response.body).targetState, 'queued'); - assert.equal(JSON.parse(response.body).attemptId, undefined); - const otherOwner = await app.inject({ method: 'GET', url, headers: { 'x-cat-cafe-user': 'different-user' } }); - assert.equal(otherOwner.statusCode, 404); - assert.equal(JSON.parse(otherOwner.body).targetState, undefined); - assert.equal(authorityCalls.length, 1); - assert.equal(retryCalls.length, 0); - }); - - it('does not project a retry fence when current authority has gone stale', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: '@gpt-pro hello', - mentions: ['gpt-pro'], - timestamp: 1_000, - threadId: 'thread-f247-stale', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-stale', - allTargetCats: ['gpt-pro'], - pendingTargetCats: ['gpt-pro'], - failedByCatIds: ['gpt-pro'], - targetAttempts: [ - { - id: 'entry-stale:gpt-pro:1', - targetCatId: 'gpt-pro', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - authorityDecision = { ok: false, reason: 'authority_witness_changed' }; - - const response = await app.inject({ - method: 'GET', - url: `/api/messages/${queued.id}/queue-targets/gpt-pro/retry-authority`, - }); - - assert.equal(response.statusCode, 409); - assert.equal(JSON.parse(response.body).code, 'QUEUE_RETRY_AUTHORITY_STALE'); - assert.equal(retryCalls.length, 0); - }); - - it('does not disclose another owner retry fence', async () => { - const queued = messageStore.append({ - userId: 'owner-user', - catId: null, - content: '@gpt-pro hello', - mentions: ['gpt-pro'], - timestamp: 1_000, - threadId: 'thread-f247-other-owner', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-other-owner', - allTargetCats: ['gpt-pro'], - pendingTargetCats: ['gpt-pro'], - failedByCatIds: ['gpt-pro'], - targetAttempts: [ - { - id: 'entry-other-owner:gpt-pro:1', - targetCatId: 'gpt-pro', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - - const response = await app.inject({ - method: 'GET', - url: `/api/messages/${queued.id}/queue-targets/gpt-pro/retry-authority`, - headers: { 'x-cat-cafe-user': 'different-user' }, - }); - - assert.equal(response.statusCode, 404); - assert.equal(JSON.parse(response.body).code, 'QUEUE_MESSAGE_NOT_FOUND'); - assert.equal(authorityCalls.length, 0); - assert.equal(retryCalls.length, 0); - }); - - it('retries the immutable source message target through its failed attempt fence', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'keep this exact authored text', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-f1308', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-retry', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - failedByCatIds: ['opus'], - targetAttempts: [ - { - id: 'entry-retry:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - - const response = await app.inject({ - method: 'POST', - url: `/api/messages/${queued.id}/queue-targets/opus/retry`, - headers: { 'content-type': 'application/json' }, - payload: { attemptId: 'entry-retry:opus:1' }, - }); - - assert.equal(response.statusCode, 202); - assert.deepEqual(JSON.parse(response.body), { - status: 'retry_queued', - entryId: 'entry-retry', - targetCatId: 'opus', - attemptId: 'entry-retry:opus:2', - }); - assert.deepEqual(retryCalls[0].slice(0, 5), [ - 'thread-f1308', - 'default-user', - 'entry-retry', - 'opus', - 'entry-retry:opus:1', - ]); - assert.equal(typeof retryCalls[0][5], 'function'); - assert.equal(authorityCalls.length, 1); - assert.equal(commitCalls.length, 1); - assert.equal(authorityCalls[0].message.id, queued.id); - assert.equal(authorityCalls[0].requestingUserId, 'default-user'); - assert.equal(authorityCalls[0].targetCatId, 'opus'); - assert.equal(messageStore.getById(queued.id).content, 'keep this exact authored text'); - }); - - it('rejects stale authority before Queue or custody retry mutation', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: 'system', - content: 'historical wait outcome', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-stale-wait', - deliveryStatus: 'queued', - source: { connector: 'github-wait', meta: { waitContinuationCarrier: { v: 1 } } }, - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-stale-wait', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - failedByCatIds: ['opus'], - targetAttempts: [ - { - id: 'entry-stale-wait:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - authorityDecision = { ok: false, reason: 'stale_generation' }; - - const response = await app.inject({ - method: 'POST', - url: `/api/messages/${queued.id}/queue-targets/opus/retry`, - headers: { 'content-type': 'application/json' }, - payload: { attemptId: 'entry-stale-wait:opus:1' }, - }); - - assert.equal(response.statusCode, 409); - assert.deepEqual(JSON.parse(response.body), { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: 'stale_generation', - }); - assert.equal(authorityCalls.length, 1); - assert.deepEqual(retryCalls, []); - }); - - it('rejects a persisted source parse failure before Queue reopen or custody append', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'legacy connector source must not be reclassified as user-authored work', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-invalid-source', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-invalid-source', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - failedByCatIds: ['opus'], - targetAttempts: [ - { - id: 'entry-invalid-source:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - messageStore.getById(queued.id).sourceParseFailure = true; - authorityImplementation = new WaitContinuationRetryPreflight({ - taskStore: { - get: async () => { - throw new Error('invalid connector source must fail before Task lookup'); - }, - }, - }); - - const response = await app.inject({ - method: 'POST', - url: `/api/messages/${queued.id}/queue-targets/opus/retry`, - headers: { 'content-type': 'application/json' }, - payload: { attemptId: 'entry-invalid-source:opus:1' }, - }); - - assert.equal(response.statusCode, 409); - assert.deepEqual(JSON.parse(response.body), { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: 'legacy_unattributed', - }); - assert.equal(authorityCalls.length, 1); - assert.deepEqual(retryCalls, []); - const stored = messageStore.getById(queued.id); - assert.equal(stored.queueCustody.revision, 1); - assert.equal(stored.queueCustody.targetAttempts.length, 1); - }); - - it('rejects authority that changes after route preflight at the retry mutation boundary', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: 'system', - content: 'authority changes before retry mutation', - mentions: ['opus'], - timestamp: 1_000, - threadId: 'thread-racing-wait', - deliveryStatus: 'queued', - source: { connector: 'github-wait', meta: { waitContinuationCarrier: { v: 1 } } }, - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-racing-wait', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - failedByCatIds: ['opus'], - targetAttempts: [ - { - id: 'entry-racing-wait:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - authorityDecision = { ok: true, kind: 'wait_containing_task' }; - commitDecision = { outcome: 'authority_stale', reason: 'outcome_mismatch' }; - - const response = await app.inject({ - method: 'POST', - url: `/api/messages/${queued.id}/queue-targets/opus/retry`, - headers: { 'content-type': 'application/json' }, - payload: { attemptId: 'entry-racing-wait:opus:1' }, - }); - - assert.equal(response.statusCode, 409); - assert.deepEqual(JSON.parse(response.body), { - error: 'This target no longer has current retry authority', - code: 'QUEUE_RETRY_AUTHORITY_STALE', - reason: 'outcome_mismatch', - }); - assert.equal(authorityCalls.length, 1); - assert.equal(commitCalls.length, 1); - assert.equal(retryCalls.length, 1); - }); - - it('resolves a cross-thread failed target to its target-thread carrier before retrying', async () => { - const queued = messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'retry the target-thread carrier', - mentions: ['codex'], - timestamp: 1_000, - threadId: 'thread-source', - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'cross-thread:source-message', - receiptScope: 'cross_thread_delivery', - allTargetCats: ['codex'], - pendingTargetCats: ['codex'], - failedByCatIds: ['codex'], - carrierByTargetCatId: { - codex: { - entryId: 'target-carrier-codex', - source: 'agent', - sourceCategory: 'a2a', - a2aTriggerMessageId: 'source-message', - autoExecute: true, - createdAt: 1_000, - }, - }, - targetAttempts: [ - { - id: 'cross-thread:source-message:codex:1', - targetCatId: 'codex', - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - invocationQueue.getEntrySnapshotForUserById = (userId, entryId) => - userId === 'default-user' && entryId === 'target-carrier-codex' - ? { id: entryId, threadId: 'thread-target', userId } - : null; - - const response = await app.inject({ - method: 'POST', - url: `/api/messages/${queued.id}/queue-targets/codex/retry`, - headers: { 'content-type': 'application/json' }, - payload: { attemptId: 'cross-thread:source-message:codex:1' }, - }); - - assert.equal(response.statusCode, 202); - assert.deepEqual(retryCalls[0].slice(0, 5), [ - 'thread-target', - 'default-user', - 'target-carrier-codex', - 'codex', - 'cross-thread:source-message:codex:1', - ]); - assert.equal(typeof retryCalls[0][5], 'function'); - }); -}); - // Auto-summary disabled (clowder-ai#343): summaries no longer merged into GET timeline. // SummaryStore still persisted for memory infrastructure / future Thread Recap. describe('GET /api/messages — summary NOT in timeline (clowder-ai#343)', () => { @@ -1745,20 +1222,24 @@ describe('GET /api/messages — summary NOT in timeline (clowder-ai#343)', () => }); it('summaries exist in store but do NOT appear in timeline', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'hello', - mentions: [], - timestamp: 1000, - }); - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'hi there', - mentions: [], - timestamp: 2000, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'hello', + mentions: [], + timestamp: 1000, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'hi there', + mentions: [], + timestamp: 2000, + }), + ); summaryStore.create({ threadId: 'default', topic: '测试纪要', @@ -1810,13 +1291,15 @@ describe('GET /api/messages summary + pagination contract', () => { // Auto-summary disabled (clowder-ai#343): summaries no longer merged into timeline it('timeline does NOT inject summaries (clowder-ai#343)', async () => { for (let i = 0; i < 5; i++) { - messageStore.append({ - userId: 'default-user', - catId: null, - content: `msg ${i}`, - mentions: [], - timestamp: 1000 + i * 100, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: `msg ${i}`, + mentions: [], + timestamp: 1000 + i * 100, + }), + ); } summaryStore.create({ threadId: 'default', @@ -2009,30 +1492,72 @@ describe('GET /api/messages timeline visibility policy', () => { if (app) await app.close(); }); - it('preserves typed context_briefing messages in API response', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'user msg', - mentions: [], - timestamp: 1000, - }); - messageStore.append({ - userId: 'system', - catId: null, - content: 'briefing nav', - mentions: [], - timestamp: 2000, - origin: 'briefing', - extra: { systemKind: 'context_briefing' }, - }); - messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'cat reply', - mentions: [], - timestamp: 3000, + it('does not publish a queued user source record before admission', async () => { + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'still waiting in Queue', + mentions: ['opus'], + timestamp: 900, + threadId: 'thread-unadmitted-source', + deliveryStatus: 'queued', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'already admitted', + mentions: ['opus'], + timestamp: 1_000, + threadId: 'thread-unadmitted-source', + }), + ); + + const res = await app.inject({ + method: 'GET', + url: '/api/messages?threadId=thread-unadmitted-source', + headers: { 'x-cat-cafe-user': 'default-user' }, }); + const body = JSON.parse(res.body); + + assert.deepEqual( + body.messages.map((message) => message.content), + ['already admitted'], + ); + }); + + it('preserves typed context_briefing messages in API response', async () => { + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'user msg', + mentions: [], + timestamp: 1000, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'briefing nav', + mentions: [], + timestamp: 2000, + origin: 'briefing', + extra: { systemKind: 'context_briefing' }, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'cat reply', + mentions: [], + timestamp: 3000, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -2043,22 +1568,26 @@ describe('GET /api/messages timeline visibility policy', () => { assert.equal(body.messages[2].content, 'cat reply'); }); - it('filters routing-guard-failure connector messages from API response', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'user msg', - mentions: [], - timestamp: 1000, - }); - messageStore.append({ - userId: 'system', - catId: null, - content: 'route guard failed', - mentions: [], - timestamp: 2000, - source: { connector: 'routing-guard-failure', detail: 'test' }, - }); + it('keeps legacy routing-guard-failure rows out of History after their producer is retired', async () => { + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'user msg', + mentions: [], + timestamp: 1000, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'route guard failed', + mentions: [], + timestamp: 2000, + source: { connector: 'routing-guard-failure', detail: 'test' }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -2067,15 +1596,17 @@ describe('GET /api/messages timeline visibility policy', () => { }); it('preserves F233 duty briefing (origin=briefing without systemKind)', async () => { - messageStore.append({ - userId: 'system', - catId: null, - content: 'duty briefing', - mentions: [], - timestamp: 1000, - origin: 'briefing', - extra: { rich: { v: 1, blocks: [{ id: 'duty-briefing', kind: 'card', v: 1, title: 'Duty' }] } }, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'duty briefing', + mentions: [], + timestamp: 1000, + origin: 'briefing', + extra: { rich: { v: 1, blocks: [{ id: 'duty-briefing', kind: 'card', v: 1, title: 'Duty' }] } }, + }), + ); const res = await app.inject({ method: 'GET', url: '/api/messages' }); const body = JSON.parse(res.body); @@ -2089,37 +1620,43 @@ describe('GET /api/messages timeline visibility policy', () => { const threadId = 'thread-internal-cluster'; // Oldest visible message - messageStore.append({ - threadId, - userId: 'default-user', - catId: null, - content: 'oldest visible', - mentions: [], - timestamp: 100, - }); - - // 25 consecutive internal route-guard diagnostics - for (let i = 0; i < 25; i++) { - messageStore.append({ + messageStore.append( + canonicalTestMessageInput({ threadId, - userId: 'system', + userId: 'default-user', catId: null, - content: `route-guard-${i}`, + content: 'oldest visible', mentions: [], - timestamp: 200 + i, - source: { connector: 'routing-guard-failure', detail: 'test' }, - }); + timestamp: 100, + }), + ); + + // 25 consecutive internal route-guard diagnostics + for (let i = 0; i < 25; i++) { + messageStore.append( + canonicalTestMessageInput({ + threadId, + userId: 'system', + catId: null, + content: `route-guard-${i}`, + mentions: [], + timestamp: 200 + i, + source: { connector: 'routing-guard-failure', detail: 'test' }, + }), + ); } // Newest visible message - messageStore.append({ - threadId, - userId: 'default-user', - catId: null, - content: 'newest visible', - mentions: [], - timestamp: 300, - }); + messageStore.append( + canonicalTestMessageInput({ + threadId, + userId: 'default-user', + catId: null, + content: 'newest visible', + mentions: [], + timestamp: 300, + }), + ); // Request with limit=1, cursor before newest visible // This forces pagination to walk through the 25 internal messages @@ -2146,26 +1683,30 @@ describe('GET /api/messages timeline visibility policy', () => { const CLUSTER_SIZE = 300; // Only visible message — buried at the bottom behind the cluster - messageStore.append({ - threadId, - userId: 'default-user', - catId: null, - content: 'buried visible', - mentions: [], - timestamp: 100, - }); - - // 300 consecutive internal route-guard diagnostics on top - for (let i = 0; i < CLUSTER_SIZE; i++) { - messageStore.append({ + messageStore.append( + canonicalTestMessageInput({ threadId, - userId: 'system', + userId: 'default-user', catId: null, - content: `route-guard-${i}`, + content: 'buried visible', mentions: [], - timestamp: 200 + i, - source: { connector: 'routing-guard-failure', detail: 'test' }, - }); + timestamp: 100, + }), + ); + + // 300 consecutive internal route-guard diagnostics on top + for (let i = 0; i < CLUSTER_SIZE; i++) { + messageStore.append( + canonicalTestMessageInput({ + threadId, + userId: 'system', + catId: null, + content: `route-guard-${i}`, + mentions: [], + timestamp: 200 + i, + source: { connector: 'routing-guard-failure', detail: 'test' }, + }), + ); } // Cursor after all messages — forces scan through the entire cluster @@ -2185,26 +1726,30 @@ describe('GET /api/messages timeline visibility policy', () => { it('returns hasMore=false when store is exhausted after filtering', async () => { const threadId = 'thread-exhausted'; - messageStore.append({ - threadId, - userId: 'default-user', - catId: null, - content: 'only visible', - mentions: [], - timestamp: 100, - }); - - // A few internal route-guard diagnostics after - for (let i = 0; i < 3; i++) { - messageStore.append({ + messageStore.append( + canonicalTestMessageInput({ threadId, - userId: 'system', + userId: 'default-user', catId: null, - content: `route-guard-${i}`, + content: 'only visible', mentions: [], - timestamp: 200 + i, - source: { connector: 'routing-guard-failure', detail: 'test' }, - }); + timestamp: 100, + }), + ); + + // A few internal route-guard diagnostics after + for (let i = 0; i < 3; i++) { + messageStore.append( + canonicalTestMessageInput({ + threadId, + userId: 'system', + catId: null, + content: `route-guard-${i}`, + mentions: [], + timestamp: 200 + i, + source: { connector: 'routing-guard-failure', detail: 'test' }, + }), + ); } const res = await app.inject({ diff --git a/packages/api/test/messages-f108b-whisper-dispatch.test.js b/packages/api/test/messages-f108b-whisper-dispatch.test.js deleted file mode 100644 index 0aef358bdd..0000000000 --- a/packages/api/test/messages-f108b-whisper-dispatch.test.js +++ /dev/null @@ -1,383 +0,0 @@ -/** - * F108 Phase B: Whisper/targeted messages use slot-level delivery mode. - * - * Core behavior: - * - Whisper to idle cat → immediate (side-dispatch), even if another cat is busy - * - Whisper to busy cat → queue (same as before) - * - Broadcast (no whisper) → thread-level check (any busy → queue) - */ - -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; -import './helpers/setup-cat-registry.js'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); - -function buildDeps(overrides = {}) { - const invocationQueue = new InvocationQueue(); - return { - registry: new InvocationRegistry(), - messageStore: { - append: mock.fn(async (msg) => ({ id: `msg-${Date.now()}`, ...msg })), - getByThread: mock.fn(async () => []), - getByThreadBefore: mock.fn(async () => []), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - router: { - resolveTargetsAndIntent: mock.fn(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - })), - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - route: mock.fn(async function* () { - yield { type: 'done' }; - }), - }, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - tryStartThread: mock.fn(() => new AbortController()), - tryStartThreadAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - // Slot-aware has(): thread-level returns true (opus busy), slot-level varies - has: mock.fn((threadId, catId) => { - if (catId === 'opus') return true; // opus is busy - if (catId === 'codex') return false; // codex is idle - // No catId → thread-level: true (something is busy) - return !catId ? true : false; - }), - cancel: mock.fn(() => ({ cancelled: true, catIds: ['opus'] })), - cancelAll: mock.fn(() => ({ catIds: ['opus'], executionIds: [] })), - cancelInvocation: mock.fn(() => ['opus']), - isDeleting: mock.fn(() => false), - getActiveSlots: mock.fn(() => [{ catId: 'opus', startedAt: Date.now() }]), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'created', - invocationId: 'inv-stub', - })), - update: mock.fn(async () => {}), - get: mock.fn(async () => null), - }, - invocationQueue, - queueProcessor: { - hasActiveExecution: mock.fn(() => true), - hasActiveExecutionForCat: mock.fn((_threadId, catId) => catId === 'opus'), - // Legacy broad busy truth deliberately includes queued leftovers. Admission - // must not use it as proof that a target has a dequeue owner. - isCatBusy: mock.fn((_threadId, catId) => catId === 'opus'), - clearPause: mock.fn(() => {}), - onInvocationComplete: mock.fn(async () => {}), - }, - threadStore: { - get: mock.fn(async () => ({ - id: 'thread-1', - title: 'Test Thread', - createdBy: 'test-user', - })), - updateTitle: mock.fn(async () => {}), - }, - ...overrides, - }; -} - -describe('F108B: whisper slot-aware delivery mode', () => { - let app; - let deps; - - beforeEach(async () => { - deps = buildDeps(); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('whisper to idle cat (codex) → immediate dispatch, not queued', async () => { - // opus is busy, codex is idle. Whisper targets codex. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['codex'], - intent: { intent: 'execute' }, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '缅因猫你反思一下', - threadId: 'thread-1', - visibility: 'whisper', - whisperTo: ['codex'], - // No deliveryMode → server should auto-decide based on target slot - }, - }); - - assert.equal(res.statusCode, 200, `Expected 200 (immediate), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'processing', 'whisper to idle cat should be immediate, not queued'); - - // Should have created InvocationRecord (immediate path) - assert.ok( - deps.invocationRecordStore.create.mock.calls.length > 0, - 'immediate dispatch should create InvocationRecord', - ); - - // Queue should be empty - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0, 'whisper to idle cat should not enqueue'); - }); - - it('whisper to busy cat (opus) → queued', async () => { - // opus is busy. Whisper targets opus. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '布偶猫这个 bug 先放一放', - threadId: 'thread-1', - visibility: 'whisper', - whisperTo: ['opus'], - }, - }); - - assert.equal(res.statusCode, 202, `Expected 202 (queued), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued', 'whisper to busy cat should queue'); - }); - - it('broadcast (no whisper) with active cat → still queued (thread-level)', async () => { - // opus is busy. No whisper → broadcast. Should queue. - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '大家注意', - threadId: 'thread-1', - // No visibility, no whisperTo → broadcast - }, - }); - - assert.equal(res.statusCode, 202, `Expected 202 (queued), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued', 'broadcast with active cat should queue'); - }); - - it('AC-B4: broadcast @mention to idle cat → immediate (slot-level check)', async () => { - // opus is busy, codex is idle. Message @mentions codex explicitly. - // resolveTargetsAndIntent returns hasMentions: true because @codex was parsed. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['codex'], - intent: { intent: 'execute' }, - hasMentions: true, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '@codex 帮我看看这个函数', - threadId: 'thread-1', - // No visibility, no whisperTo → broadcast, but with explicit @mention - }, - }); - - assert.equal(res.statusCode, 200, `Expected 200 (immediate), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'processing', '@mention to idle cat should dispatch immediately'); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0, '@mention to idle cat should not enqueue'); - }); - - it('P1: queued residue without an active target owner does not admit another @mention to Queue', async () => { - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['codex'], - intent: { intent: 'execute' }, - hasMentions: true, - })); - deps.queueProcessor.isCatBusy.mock.mockImplementation((_threadId, catId) => catId === 'codex'); - deps.queueProcessor.hasActiveExecutionForCat.mock.mockImplementation(() => false); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '@codex queued residue is not an execution owner', - threadId: 'thread-1', - }, - }); - - assert.equal(res.statusCode, 200, res.body); - assert.equal(JSON.parse(res.body).status, 'processing'); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0); - assert.equal(deps.queueProcessor.isCatBusy.mock.calls.length, 0, 'admission must not consult queued residue'); - }); - - it('AC-B4: broadcast @mention to busy cat → queued', async () => { - // opus is busy. Message @mentions opus explicitly. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - hasMentions: true, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '@opus 停一下', - threadId: 'thread-1', - }, - }); - - assert.equal(res.statusCode, 202, `Expected 202 (queued), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued', '@mention to busy cat should queue'); - }); - - it('AC-B4: broadcast without @mention (fallback routing) → thread-level queue', async () => { - // opus is busy. No @mention → fallback routing resolves to opus. - // hasMentions: false → thread-level check → queued. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - hasMentions: false, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '帮我看看这个函数', - threadId: 'thread-1', - }, - }); - - assert.equal(res.statusCode, 202, `Expected 202 (queued), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued', 'no @mention with busy thread should queue'); - }); - - it('P1: multi @mention with mixed busy/idle → queued (any busy = queue)', async () => { - // @codex(idle) + @opus(busy). hasMentions: true, targetCats: ['codex', 'opus']. - // Even though codex is idle, opus is busy → entire message should queue. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['codex', 'opus'], - intent: { intent: 'execute' }, - hasMentions: true, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '@codex @opus 帮我看看', - threadId: 'thread-1', - }, - }); - - assert.equal(res.statusCode, 202, `Expected 202 (queued), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued', 'multi-mention with any busy target should queue'); - }); - - it('P1: multi @mention with reversed order (busy first) → queued', async () => { - // @opus(busy) + @codex(idle). Order reversed — should still queue. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'execute' }, - hasMentions: true, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '@opus @codex 帮我看看', - threadId: 'thread-1', - }, - }); - - assert.equal(res.statusCode, 202, `Expected 202 (queued), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued', 'multi-mention with busy first should also queue'); - }); - - it('P1: multi @mention all idle → immediate', async () => { - // @codex(idle) + @gemini(idle). Both idle → immediate. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['codex', 'gemini'], - intent: { intent: 'execute' }, - hasMentions: true, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '@codex @gemini 帮我看看', - threadId: 'thread-1', - }, - }); - - assert.equal(res.statusCode, 200, `Expected 200 (immediate), got ${res.statusCode}: ${res.body}`); - const body = JSON.parse(res.body); - assert.equal(body.status, 'processing', 'multi-mention all idle should dispatch immediately'); - }); - - it('explicit deliveryMode=force on whisper → cancels target slot and executes', async () => { - // opus is busy. Whisper to opus with force → should cancel and execute immediately. - deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '布偶猫停下来', - threadId: 'thread-1', - visibility: 'whisper', - whisperTo: ['opus'], - deliveryMode: 'force', - }, - }); - - // F-parallel-cancel (cloud #6): force = scoped preempt of the TARGET invocation → cancelInvocation - // (not cancelAll, which would also abort an unrelated side-dispatch in the same thread). - assert.ok( - deps.invocationTracker.cancelInvocation.mock.calls.length > 0, - 'force mode should cancelInvocation (scoped preempt) the active invocation', - ); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0, 'force mode should not enqueue'); - }); -}); diff --git a/packages/api/test/messages-intent-mode.test.js b/packages/api/test/messages-intent-mode.test.js deleted file mode 100644 index 5093d3f706..0000000000 --- a/packages/api/test/messages-intent-mode.test.js +++ /dev/null @@ -1,392 +0,0 @@ -/** - * #768 regression: intent_mode deferred broadcast in POST /api/messages - * - * Tests both the main path (routeExecution via invocationRecordStore) and - * the legacy path (router.route fallback). - */ - -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import Fastify from 'fastify'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; - -// Shared minimal mocks ────────────────────────────────────────────── - -function makeMockRouter(routeFn, routeExecutionFn) { - return { - resolveTargetsAndIntent: async () => ({ - targetCats: ['codex'], - intent: { intent: 'execute', explicit: false, promptTags: [] }, - }), - route: routeFn ?? async function* () {}, - routeExecution: - routeExecutionFn ?? - async function* () { - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - ackCollectedCursors: async () => {}, - }; -} - -function makeMockSocketManager() { - const events = []; - return { - events, - broadcastToRoom(room, event, payload) { - events.push({ room, event, payload }); - }, - broadcastAgentMessage() {}, - emitToUser(userId, event, payload) { - events.push({ room: `user:${userId}`, event, payload }); - }, - }; -} - -function makeMockRegistry() { - return { - active() { - return new Set(); - }, - }; -} - -function makeMockMessageStore() { - return { - append: async (msg) => ({ id: `msg-${Date.now()}`, ...msg }), - updateStatus: async () => {}, - }; -} - -function makeMockInvocationTracker() { - return { - has: () => false, - isDeleting: () => false, - start: () => new AbortController(), - startAll: () => new AbortController(), - tryStartThread: () => new AbortController(), - tryStartThreadAll: () => new AbortController(), - complete: () => {}, - completeAll: () => {}, - completeSlot: () => {}, - trackExternalSlot: () => true, - }; -} - -function makeMockInvocationRecordStore() { - return { - create: async () => ({ outcome: 'created', invocationId: `inv-${Date.now()}` }), - update: async () => {}, - }; -} - -async function buildApp(overrides = {}) { - const { messagesRoutes } = await import('../dist/routes/messages.js'); - const socketManager = overrides.socketManager ?? makeMockSocketManager(); - const app = Fastify(); - await app.register(messagesRoutes, { - registry: makeMockRegistry(), - messageStore: makeMockMessageStore(), - socketManager, - router: overrides.router ?? makeMockRouter(), - invocationTracker: overrides.invocationTracker ?? makeMockInvocationTracker(), - invocationRecordStore: overrides.invocationRecordStore, - ...(overrides.extra ?? {}), - }); - await app.ready(); - return { app, socketManager }; -} - -// ── Legacy path (no invocationRecordStore) ────────────────────────── - -describe('#768 messages.ts legacy path', () => { - it('publishes persisted participants to the user room before the first CLI event', async () => { - const sm = makeMockSocketManager(); - let releaseFirstEvent; - const firstEventGate = new Promise((resolve) => { - releaseFirstEvent = resolve; - }); - const participants = ['opus']; - const threadStore = { - get: async (threadId) => ({ - id: threadId, - title: 'Sidebar presence regression', - participants: [...participants], - }), - addParticipants: async (_threadId, catIds) => { - for (const catId of catIds) { - if (!participants.includes(catId)) participants.push(catId); - } - }, - }; - const { app } = await buildApp({ - socketManager: sm, - router: makeMockRouter(async function* () { - await firstEventGate; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, undefined), - invocationRecordStore: undefined, - extra: { threadStore }, - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@codex test', threadId: 'thread-sidebar-presence' }, - }); - - const participantsBeforeFirstEvent = [...participants]; - const eventsBeforeFirstEvent = [...sm.events]; - - releaseFirstEvent(); - await new Promise((resolve) => setTimeout(resolve, 20)); - await app.close(); - - const participantEvents = eventsBeforeFirstEvent.filter((event) => event.event === 'thread_updated'); - assert.deepEqual(participantsBeforeFirstEvent, ['opus', 'codex']); - assert.equal(participantEvents.length, 1); - assert.equal(participantEvents[0].room, 'user:default-user'); - assert.deepEqual(participantEvents[0].payload, { - threadId: 'thread-sidebar-presence', - participants: ['opus', 'codex'], - }); - assert.equal( - eventsBeforeFirstEvent.some((event) => event.event === 'intent_mode'), - false, - ); - assert.equal(sm.events.filter((event) => event.event === 'thread_updated').length, 1); - }); - - it('intent_mode is NOT broadcast when router.route throws before yielding', async () => { - const sm = makeMockSocketManager(); - const { app } = await buildApp({ - socketManager: sm, - router: makeMockRouter(async function* () { - throw new Error('CLI spawn failed'); - }, undefined), - // No invocationRecordStore → legacy path - invocationRecordStore: undefined, - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@codex test', threadId: 'thread-768-legacy-throw' }, - }); - - // Background fire-and-forget: wait for it to flush - await new Promise((r) => setTimeout(r, 50)); - - const intentEvents = sm.events.filter((e) => e.event === 'intent_mode'); - assert.equal( - intentEvents.length, - 0, - '#768: legacy path must NOT broadcast intent_mode when route() throws before yielding', - ); - await app.close(); - }); - - it('intent_mode IS broadcast once router.route yields first event', async () => { - const sm = makeMockSocketManager(); - const { app } = await buildApp({ - socketManager: sm, - router: makeMockRouter(async function* () { - yield { type: 'text', catId: 'codex', content: 'hello', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, undefined), - invocationRecordStore: undefined, - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@codex test', threadId: 'thread-768-legacy-ok' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - const intentEvents = sm.events.filter((e) => e.event === 'intent_mode'); - assert.equal(intentEvents.length, 1, '#768: legacy path must broadcast intent_mode exactly once'); - assert.equal(intentEvents[0].payload.threadId, 'thread-768-legacy-ok'); - await app.close(); - }); - - it('passes authoritative A2A admission and durable deferral into the legacy route', async () => { - const queue = new InvocationQueue(); - const tracker = makeMockInvocationTracker(); - tracker.trackExternalSlot = () => false; - let observedClaim; - let observedEnqueue; - const { app } = await buildApp({ - router: makeMockRouter(async function* (_userId, _content, threadId, _blocks, _uploadDir, _signal, options) { - observedClaim = options.trackA2ASlot(threadId, 'codex', 'user-1', options.invocationController); - observedEnqueue = options.deferA2AEnqueue({ - threadId, - userId: 'user-1', - content: '@codex review complete', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - callerCatId: 'opus', - messageId: 'msg-legacy-review', - a2aTriggerMessageId: 'msg-legacy-review', - autoExecute: true, - priority: 'normal', - intent: 'execute', - }); - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }, undefined), - invocationTracker: tracker, - invocationRecordStore: undefined, - extra: { invocationQueue: queue }, - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@opus review', threadId: 'thread-legacy-a2a-admission' }, - }); - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(observedClaim, false); - assert.equal(observedEnqueue.outcome, 'enqueued'); - const [deferred] = queue.list('thread-legacy-a2a-admission', 'user-1'); - assert.equal(deferred.messageId, 'msg-legacy-review'); - assert.equal(deferred.ownerAuthProvenance, 'compatibility_fallback'); - await app.close(); - }); - - it('releases a completed dynamic A2A child outside the original target set exactly once', async () => { - const tracker = new InvocationTracker(); - const completeSlotCalls = []; - const completeSlot = tracker.completeSlot.bind(tracker); - let routeController; - tracker.completeSlot = (threadId, catId, controller) => { - completeSlotCalls.push({ threadId, catId, controller }); - completeSlot(threadId, catId, controller); - }; - - const { app } = await buildApp({ - router: makeMockRouter(async function* (_userId, _content, threadId, _blocks, _uploadDir, _signal, options) { - routeController = options.invocationController; - assert.equal(options.trackA2ASlot(threadId, 'opus', 'user-1', routeController), true); - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }, undefined), - invocationTracker: tracker, - invocationRecordStore: undefined, - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@codex route to opus', threadId: 'thread-legacy-dynamic-child' }, - }); - await new Promise((r) => setTimeout(r, 50)); - - const opusCompletions = completeSlotCalls.filter((call) => call.catId === 'opus'); - assert.equal(opusCompletions.length, 1); - assert.equal(opusCompletions[0].threadId, 'thread-legacy-dynamic-child'); - assert.equal(opusCompletions[0].controller, routeController); - assert.equal(tracker.has('thread-legacy-dynamic-child', 'opus'), false); - await app.close(); - }); - - it('cannot release a replacement dynamic child with the completed route generation', async () => { - const tracker = new InvocationTracker(); - const completeSlot = tracker.completeSlot.bind(tracker); - let routeController; - let replacementController; - let opusCompletionCount = 0; - tracker.completeSlot = (threadId, catId, controller) => { - if (catId === 'opus') { - opusCompletionCount += 1; - replacementController = tracker.startAll(threadId, ['opus'], 'user-2', 'replacement-opus'); - } - completeSlot(threadId, catId, controller); - }; - - const { app } = await buildApp({ - router: makeMockRouter(async function* (_userId, _content, threadId, _blocks, _uploadDir, _signal, options) { - routeController = options.invocationController; - assert.equal(options.trackA2ASlot(threadId, 'opus', 'user-1', routeController), true); - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }, undefined), - invocationTracker: tracker, - invocationRecordStore: undefined, - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@codex replace opus', threadId: 'thread-legacy-replacement-child' }, - }); - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(opusCompletionCount, 1); - assert.ok(replacementController); - assert.notEqual(replacementController, routeController); - assert.equal( - tracker.classifyExecutionId('thread-legacy-replacement-child', 'opus', 'replacement-opus'), - 'matching', - ); - assert.equal(tracker.has('thread-legacy-replacement-child', 'opus'), true); - tracker.completeAll('thread-legacy-replacement-child', ['opus'], replacementController); - await app.close(); - }); -}); - -// ── Main path (with invocationRecordStore) ────────────────────────── - -describe('#768 messages.ts main path', () => { - it('intent_mode is NOT broadcast when routeExecution throws before yielding', async () => { - const sm = makeMockSocketManager(); - const { app } = await buildApp({ - socketManager: sm, - router: makeMockRouter(undefined, async function* () { - throw new Error('CLI spawn failed'); - }), - invocationRecordStore: makeMockInvocationRecordStore(), - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@codex test', threadId: 'thread-768-main-throw' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - const intentEvents = sm.events.filter((e) => e.event === 'intent_mode'); - assert.equal( - intentEvents.length, - 0, - '#768: main path must NOT broadcast intent_mode when routeExecution throws before yielding', - ); - await app.close(); - }); - - it('intent_mode IS broadcast once routeExecution yields first event', async () => { - const sm = makeMockSocketManager(); - const { app } = await buildApp({ - socketManager: sm, - router: makeMockRouter(undefined, async function* () { - yield { type: 'text', catId: 'codex', content: 'hello', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }), - invocationRecordStore: makeMockInvocationRecordStore(), - }); - - await app.inject({ - method: 'POST', - url: '/api/messages', - payload: { content: '@codex test', threadId: 'thread-768-main-ok' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - const intentEvents = sm.events.filter((e) => e.event === 'intent_mode'); - assert.equal(intentEvents.length, 1, '#768: main path must broadcast intent_mode exactly once'); - assert.equal(intentEvents[0].payload.threadId, 'thread-768-main-ok'); - await app.close(); - }); -}); diff --git a/packages/api/test/messages-parallel-slot-release.test.js b/packages/api/test/messages-parallel-slot-release.test.js deleted file mode 100644 index 888e6e31ba..0000000000 --- a/packages/api/test/messages-parallel-slot-release.test.js +++ /dev/null @@ -1,338 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import Fastify from 'fastify'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; -import { messagesRoutes } from '../dist/routes/messages.js'; -import { queueRoutes } from '../dist/routes/queue.js'; - -function deferred() { - /** @type {(value?: void | PromiseLike) => void} */ - let resolve; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} - -function makeSocketManager(events) { - return { - broadcastAgentMessage(message, threadId) { - events.push({ threadId, message }); - }, - broadcastToRoom() {}, - emitToUser() {}, - }; -} - -function makeRegistry() { - return { - active() { - return new Set(); - }, - }; -} - -function makeMessageStore() { - return { - append: async (msg) => ({ id: `msg-${Date.now()}`, ...msg }), - updateStatus: async () => {}, - }; -} - -function makeInvocationRecordStore() { - const record = { - invocationId: 'inv-parallel-release', - status: 'running', - }; - return { - create: async () => ({ outcome: 'created', invocationId: 'inv-parallel-release' }), - get: async () => record, - update: async (_invocationId, patch) => Object.assign(record, patch), - }; -} - -function makeThreadStore() { - return { - get: async (id) => ({ - id, - title: 'Parallel Slot Release', - createdBy: 'system', - }), - }; -} - -function makeQueueProcessor() { - return { - processNext: async () => ({ started: false }), - isPaused: () => false, - getPauseReason: () => undefined, - clearPause: () => {}, - releaseSlot: () => {}, - releaseThread: () => {}, - }; -} - -describe('POST /api/messages parallel slot release', () => { - it('drops finished cats from InvocationTracker before the whole batch completes', async (t) => { - const gate = deferred(); - const tracker = new InvocationTracker(); - const invocationQueue = new InvocationQueue(); - const broadcastEvents = []; - const threadId = 'thread-parallel-slot-release'; - - const router = { - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'ideate', explicit: true, promptTags: [] }, - }), - routeExecution: async function* () { - yield { type: 'text', catId: 'opus', content: 'first', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', isFinal: false, timestamp: Date.now() }; - await gate.promise; - yield { type: 'text', catId: 'codex', content: 'second', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - ackCollectedCursors: async () => {}, - }; - - const app = Fastify(); - await app.register(messagesRoutes, { - registry: makeRegistry(), - messageStore: makeMessageStore(), - socketManager: makeSocketManager(broadcastEvents), - router, - invocationTracker: tracker, - invocationRecordStore: makeInvocationRecordStore(), - }); - await app.register(queueRoutes, { - threadStore: makeThreadStore(), - invocationQueue, - queueProcessor: makeQueueProcessor(), - invocationTracker: tracker, - socketManager: makeSocketManager(broadcastEvents), - }); - await app.ready(); - t.after(async () => { - gate.resolve(); - await app.close(); - }); - - const sendRes = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { - content: 'parallel status repro', - threadId, - }, - }); - assert.equal(sendRes.statusCode, 200, sendRes.body); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const queueRes = await app.inject({ - method: 'GET', - url: `/api/threads/${threadId}/queue`, - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - assert.equal(queueRes.statusCode, 200); - assert.deepEqual( - JSON.parse(queueRes.body) - .activeInvocations.map((slot) => slot.catId) - .sort(), - ['codex'], - 'queue endpoint should already reflect only the still-running cat', - ); - assert.ok( - broadcastEvents.some( - (event) => event.threadId === threadId && event.message.type === 'done' && event.message.catId === 'opus', - ), - 'socket broadcast should emit the finished cat before the batch ends', - ); - assert.deepEqual( - tracker - .getActiveSlots(threadId) - .map((slot) => slot.catId) - .sort(), - ['codex'], - 'after opus done(non-final), only the still-running cat should remain active', - ); - - gate.resolve(); - await new Promise((resolve) => setTimeout(resolve, 50)); - assert.equal(tracker.has(threadId), false, 'all slots should clear once the last cat finishes'); - }); - - it('drops errored cats from InvocationTracker before the whole batch completes', async (t) => { - const gate = deferred(); - const tracker = new InvocationTracker(); - const invocationQueue = new InvocationQueue(); - const broadcastEvents = []; - const threadId = 'thread-parallel-slot-error-release'; - - const router = { - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus', 'codex'], - intent: { intent: 'ideate', explicit: true, promptTags: [] }, - }), - routeExecution: async function* () { - yield { type: 'text', catId: 'opus', content: 'first', timestamp: Date.now() }; - yield { - type: 'error', - catId: 'opus', - error: 'tool failed', - errorDisposition: 'terminal', - timestamp: Date.now(), - }; - await gate.promise; - yield { type: 'text', catId: 'codex', content: 'second', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - ackCollectedCursors: async () => {}, - }; - - const app = Fastify(); - await app.register(messagesRoutes, { - registry: makeRegistry(), - messageStore: makeMessageStore(), - socketManager: makeSocketManager(broadcastEvents), - router, - invocationTracker: tracker, - invocationRecordStore: makeInvocationRecordStore(), - }); - await app.register(queueRoutes, { - threadStore: makeThreadStore(), - invocationQueue, - queueProcessor: makeQueueProcessor(), - invocationTracker: tracker, - socketManager: makeSocketManager(broadcastEvents), - }); - await app.ready(); - t.after(async () => { - gate.resolve(); - await app.close(); - }); - - const sendRes = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { - content: 'parallel error status repro', - threadId, - }, - }); - assert.equal(sendRes.statusCode, 200, sendRes.body); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const queueRes = await app.inject({ - method: 'GET', - url: `/api/threads/${threadId}/queue`, - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - assert.equal(queueRes.statusCode, 200); - assert.deepEqual( - JSON.parse(queueRes.body) - .activeInvocations.map((slot) => slot.catId) - .sort(), - ['codex'], - 'queue endpoint should drop errored cats immediately too', - ); - assert.ok( - broadcastEvents.some( - (event) => event.threadId === threadId && event.message.type === 'error' && event.message.catId === 'opus', - ), - 'socket broadcast should emit the erroring cat before the batch ends', - ); - assert.deepEqual( - tracker - .getActiveSlots(threadId) - .map((slot) => slot.catId) - .sort(), - ['codex'], - 'after opus error, only the still-running cat should remain active', - ); - - gate.resolve(); - await new Promise((resolve) => setTimeout(resolve, 50)); - assert.equal(tracker.has(threadId), false, 'all slots should clear once the last cat finishes'); - }); - - it('keeps a cat cancelable after a transient diagnostic until its done frame', async (t) => { - const gate = deferred(); - const tracker = new InvocationTracker(); - const invocationQueue = new InvocationQueue(); - const broadcastEvents = []; - const threadId = 'thread-transient-error-stays-cancelable'; - - const router = { - resolveTargetsAndIntent: async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute', explicit: true, promptTags: [] }, - }), - routeExecution: async function* () { - yield { - type: 'error', - catId: 'opus', - error: 'recoverable provider diagnostic', - errorDisposition: 'transient', - timestamp: Date.now(), - }; - await gate.promise; - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }, - ackCollectedCursors: async () => {}, - }; - - const app = Fastify(); - await app.register(messagesRoutes, { - registry: makeRegistry(), - messageStore: makeMessageStore(), - socketManager: makeSocketManager(broadcastEvents), - router, - invocationTracker: tracker, - invocationRecordStore: makeInvocationRecordStore(), - }); - await app.register(queueRoutes, { - threadStore: makeThreadStore(), - invocationQueue, - queueProcessor: makeQueueProcessor(), - invocationTracker: tracker, - socketManager: makeSocketManager(broadcastEvents), - }); - await app.ready(); - t.after(async () => { - gate.resolve(); - await app.close(); - }); - - const sendRes = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { - content: 'transient error ownership repro', - threadId, - }, - }); - assert.equal(sendRes.statusCode, 200, sendRes.body); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const queueRes = await app.inject({ - method: 'GET', - url: `/api/threads/${threadId}/queue`, - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - assert.equal(queueRes.statusCode, 200); - assert.deepEqual( - JSON.parse(queueRes.body).activeInvocations.map((slot) => slot.catId), - ['opus'], - 'a transient diagnostic must not release the exact Stop controller', - ); - assert.equal(tracker.has(threadId, 'opus'), true); - - gate.resolve(); - await new Promise((resolve) => setTimeout(resolve, 50)); - assert.equal(tracker.has(threadId), false, 'the done frame releases the slot'); - }); -}); diff --git a/packages/api/test/multi-mention-b6-queue-dispatch.test.js b/packages/api/test/multi-mention-b6-queue-dispatch.test.js index 1cbe23a218..1b6a477c55 100644 --- a/packages/api/test/multi-mention-b6-queue-dispatch.test.js +++ b/packages/api/test/multi-mention-b6-queue-dispatch.test.js @@ -10,12 +10,22 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; import Fastify from 'fastify'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { + InvocationQueue, + queueEntryTargetCats, +} from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { registerCallbackAuthHook } from '../dist/routes/callback-auth-prehandler.js'; import { getMultiMentionOrchestrator, resetMultiMentionOrchestrator, } from '../dist/routes/callback-multi-mention-routes.js'; +import { + adaptInvocationQueue, + adaptMessageStore, + appendTestLifecycleResponseSource, + canonicalTestQueueInput, +} from './helpers/message-from-fixtures.js'; // ── Mocks ────────────────────────────────────────────────────────────── @@ -57,22 +67,23 @@ function createMockSocketManager() { broadcastToRoom(room, event, data) { roomEvents.push({ room, event, data }); }, + emitToUser() {}, getMessages: () => messages, getRoomEvents: () => roomEvents, }; } function createMockMessageStore() { + const store = adaptMessageStore(new MessageStore()); const messages = []; - return { - append(msg) { - const stored = { id: `msg-${messages.length}`, ...msg }; - messages.push(stored); - return stored; - }, - getById: (id) => messages.find((m) => m.id === id) ?? null, - getMessages: () => messages, + const append = store.append.bind(store); + store.append = (msg) => { + const stored = append(msg); + messages.push(stored); + return stored; }; + store.getMessages = () => messages; + return store; } function createMockInvocationRecordStore() { @@ -120,23 +131,27 @@ function createMockQueueProcessor() { const hooks = new Map(); const autoExecuteCalls = []; return { - registerEntryCompleteHook(entryId, hook) { - hooks.set(entryId, hook); + registerEntryCompleteHook(entryId, hook, targetCatId) { + const hookId = targetCatId ? `${entryId}:${targetCatId}` : entryId; + hooks.set(hookId, { entryId, hook, targetCatId }); }, unregisterEntryCompleteHook(entryId) { - hooks.delete(entryId); + if (hooks.delete(entryId)) return; + for (const [hookId, registration] of hooks) { + if (registration.entryId === entryId) hooks.delete(hookId); + } }, - tryAutoExecute(threadId) { + requestDrain(threadId) { autoExecuteCalls.push(threadId); return Promise.resolve(); }, getHooks: () => hooks, getAutoExecuteCalls: () => autoExecuteCalls, - simulateComplete(entryId, status, responseText) { - const hook = hooks.get(entryId); - if (hook) { - hook(entryId, status, responseText); - hooks.delete(entryId); + simulateComplete(hookId, status, responseText) { + const registration = hooks.get(hookId); + if (registration) { + registration.hook(registration.entryId, status, responseText); + hooks.delete(hookId); } }, }; @@ -161,7 +176,7 @@ describe('B6: multi_mention queue dispatch', () => { mockInvocationRecordStore = createMockInvocationRecordStore(); mockInvocationTracker = createMockInvocationTracker(); mockRouter = createMockRouter(); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); mockQueueProcessor = createMockQueueProcessor(); actionAdmissionCalls = []; actionUnavailableCalls = []; @@ -185,6 +200,7 @@ describe('B6: multi_mention queue dispatch', () => { }, }; creds = mockRegistry.register('opus', 'thread-1', 'user-1'); + appendTestLifecycleResponseSource(mockMessageStore, creds); app = Fastify({ logger: false }); registerCallbackAuthHook(app, mockRegistry); @@ -219,14 +235,14 @@ describe('B6: multi_mention queue dispatch', () => { }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 200, res.body); const body = res.json(); assert.ok(body.requestId); // Router should NOT have been called directly (queue path used) assert.equal(mockRouter.getExecutions().length, 0); - // tryAutoExecute should have been called + // The thread drain should have been signaled. assert.ok(mockQueueProcessor.getAutoExecuteCalls().length > 0); // Completion hook should have been registered for the enqueued entry @@ -234,7 +250,7 @@ describe('B6: multi_mention queue dispatch', () => { assert.equal(actionAdmissionCalls.length, 0, 'legacy unscoped request must remain backward compatible'); }); - test('preserves exact cloud source provenance and per-target lineage in every Queue carrier', async () => { + test('preserves exact cloud source provenance and target lineage in the source Queue carrier', async () => { const source = mockMessageStore.append({ userId: 'user-1', catId: null, @@ -242,11 +258,14 @@ describe('B6: multi_mention queue dispatch', () => { mentions: [], timestamp: 100, threadId: 'thread-1', - deliveryStatus: 'delivered', + // F117: append() delivery metadata is transition-owned; only 'queued' may be + // initialized at append time (MessageStore.assertValidAppendDeliveryMetadata). + // This fixture only needs the source message identity, so omit deliveryStatus. }); creds = mockRegistry.register('opus', 'thread-1', 'user-1', { originTriggerMessageId: source.id, }); + const callerResponse = appendTestLifecycleResponseSource(mockMessageStore, creds); const res = await app.inject({ method: 'POST', @@ -260,45 +279,27 @@ describe('B6: multi_mention queue dispatch', () => { }, }); - assert.equal(res.statusCode, 200); - const { requestId } = res.json(); + assert.equal(res.statusCode, 200, res.body); const entries = invocationQueue.list('thread-1', 'user-1'); - assert.equal(entries.length, 2); - assert.deepEqual( - entries.map((entry) => ({ - targetCatId: entry.targetCats[0], - parentInvocationId: entry.a2aParentInvocationId, - idempotencyKey: entry.idempotencyKey, - requiresExactProvenance: entry.requiresExactCloudDispatchProvenance, - provenance: entry.cloudDispatchProvenance, - })), - [ - { - targetCatId: 'codex', - parentInvocationId: creds.invocationId, - idempotencyKey: `multi-mention:${requestId}:codex`, - requiresExactProvenance: true, - provenance: { - sourceMessageId: source.id, - sourceSender: { kind: 'user', id: 'user-1' }, - calledByCatId: 'opus', - intent: 'Review the exact source\n\n---\n\nPreserve this original context', - }, - }, - { - targetCatId: 'gpt-pro', - parentInvocationId: creds.invocationId, - idempotencyKey: `multi-mention:${requestId}:gpt-pro`, - requiresExactProvenance: true, - provenance: { - sourceMessageId: source.id, - sourceSender: { kind: 'user', id: 'user-1' }, - calledByCatId: 'opus', - intent: 'Review the exact source\n\n---\n\nPreserve this original context', - }, - }, - ], + assert.equal(entries.length, 1); + const [entry] = entries; + assert.deepEqual(queueEntryTargetCats(entry), ['codex', 'gpt-pro']); + assert.equal(entry.execution.a2aParentInvocationId, creds.invocationId); + const dispatchSource = mockMessageStore.getById(entry.payload.sourceRecordId); + assert.ok(dispatchSource, 'Queue sourceRecordId must resolve to the public multi-mention source'); + assert.equal(dispatchSource.replyTo, callerResponse.id); + assert.equal(dispatchSource.extra?.causal?.triggerMessageId, callerResponse.id); + assert.equal( + dispatchSource.content, + '[Multi-Mention from opus]\n\nReview the exact source\n\n---\n\nPreserve this original context', ); + assert.equal(entry.execution.requiresExactCloudDispatchProvenance, true); + assert.deepEqual(entry.execution.cloudDispatchProvenance, { + sourceMessageId: source.id, + sourceSender: { kind: 'user', id: 'user-1' }, + calledByCatId: 'opus', + intent: 'Review the exact source\n\n---\n\nPreserve this original context', + }); }); test('rejects caller-visible but cloud-ineligible Queue provenance while local sibling stays independent', async () => { @@ -309,13 +310,17 @@ describe('B6: multi_mention queue dispatch', () => { mentions: [], timestamp: 101, threadId: 'thread-1', - deliveryStatus: 'delivered', + // F117: delivery metadata is transition-owned (see sibling fixture above); + // append() rejects any non-'queued' initialization, so omit deliveryStatus. visibility: 'whisper', whisperTo: ['opus'], }); creds = mockRegistry.register('opus', 'thread-1', 'user-1', { originTriggerMessageId: source.id, }); + // F117: seed the lifecycle response source for the re-registered invocation id + // (same contract as the sibling test above). + appendTestLifecycleResponseSource(mockMessageStore, creds); const res = await app.inject({ method: 'POST', @@ -328,20 +333,24 @@ describe('B6: multi_mention queue dispatch', () => { }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 200, res.body); const { requestId } = res.json(); const entries = invocationQueue.list('thread-1', 'user-1'); - const localEntry = entries.find((entry) => entry.targetCats[0] === 'codex'); - const cloudEntry = entries.find((entry) => entry.targetCats[0] === 'gpt-pro'); - assert.ok(localEntry); - assert.ok(cloudEntry); - assert.equal(cloudEntry.requiresExactCloudDispatchProvenance, true); - assert.equal(cloudEntry.cloudDispatchProvenance, undefined); + assert.equal(entries.length, 1); + const [entry] = entries; + assert.deepEqual(queueEntryTargetCats(entry), ['codex', 'gpt-pro']); + assert.equal(entry.execution.requiresExactCloudDispatchProvenance, true); + assert.equal(entry.execution.cloudDispatchProvenance, undefined); + assert.equal(entry.execution.a2aParentInvocationId, creds.invocationId); const orch = getMultiMentionOrchestrator(); - mockQueueProcessor.simulateComplete(cloudEntry.id, 'succeeded', '未发送给 @gpt-pro:精确来源不满足公开回程资格。'); + mockQueueProcessor.simulateComplete( + `${entry.id}:gpt-pro`, + 'succeeded', + '未发送给 @gpt-pro:精确来源不满足公开回程资格。', + ); assert.equal(orch.getStatus(requestId), 'partial'); - mockQueueProcessor.simulateComplete(localEntry.id, 'succeeded', 'Local sibling completed'); + mockQueueProcessor.simulateComplete(`${entry.id}:codex`, 'succeeded', 'Local sibling completed'); assert.equal(orch.getStatus(requestId), 'done'); const flushMsg = mockMessageStore.getMessages().find((message) => message.content?.includes('Multi-Mention')); @@ -379,8 +388,8 @@ describe('B6: multi_mention queue dispatch', () => { outcome: 'claimed', }); const [entry] = invocationQueue.list('thread-1', 'user-1'); - assert.deepEqual(entry.actionSuccessorFence, actionAdmissionResult.fence); - assert.equal(entry.idempotencyKey, 'action:lease-action-1:1:codex'); + assert.deepEqual(entry.execution.actionSuccessorFence, actionAdmissionResult.fence); + assert.equal(entry.payload.sourceRecordId, entry.payload.messageId); }); test('confirms a returned generation only after its predecessor is enqueued', async () => { @@ -691,6 +700,7 @@ describe('B6: multi_mention queue dispatch', () => { // After completion, orchestrator should be done (all 1 target responded) assert.equal(orch.getStatus(requestId), 'done'); + await new Promise((resolve) => setImmediate(resolve)); // Result message should have been flushed to message store const stored = mockMessageStore.getMessages(); @@ -729,6 +739,7 @@ describe('B6: multi_mention queue dispatch', () => { // Complete second target mockQueueProcessor.simulateComplete(entryIds[1], 'succeeded', 'Gemini response'); assert.equal(orch.getStatus(requestId), 'done'); + await new Promise((resolve) => setImmediate(resolve)); // Both responses should be in the flush message const stored = mockMessageStore.getMessages(); @@ -788,7 +799,7 @@ describe('B6: multi_mention queue dispatch', () => { assert.ok(result.responses[0].content.includes('[dispatch error]')); }); - test('enqueued entries have source=agent and autoExecute=true', async () => { + test('enqueued entries have canonical agent From and autoExecute=true', async () => { await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', @@ -804,26 +815,29 @@ describe('B6: multi_mention queue dispatch', () => { const entries = invocationQueue.listAutoExecute('thread-1'); assert.ok(entries.length > 0); const entry = entries[0]; - assert.equal(entry.source, 'agent'); - assert.equal(entry.autoExecute, true); - assert.equal(entry.ownerAuthProvenance, 'strict'); - assert.deepEqual(entry.targetCats, ['codex']); - assert.ok(entry.content.includes('[Multi-Mention from opus]')); - assert.ok(entry.content.includes('Test queue entry fields')); + assert.deepEqual(entry.from, { kind: 'agent', catId: 'opus' }); + assert.equal(entry.execution.autoExecute, true); + assert.equal(entry.execution.ownerAuthProvenance, 'strict'); + assert.deepEqual(entry.targets, ['codex']); + assert.ok(entry.payload.content.includes('[Multi-Mention from opus]')); + assert.ok(entry.payload.content.includes('Test queue entry fields')); }); test('depth limit prevents excessive enqueue', async () => { // Fill the queue with 10 agent entries (MAX_MM_DEPTH) for (let i = 0; i < 10; i++) { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: `fill-${i}`, - source: 'agent', - targetCats: [`cat-${i}`], - intent: 'execute', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: `fill-${i}`, + source: 'agent', + targetCats: [`cat-${i}`], + intent: 'execute', + }), + ); } const res = await app.inject({ @@ -844,15 +858,18 @@ describe('B6: multi_mention queue dispatch', () => { test('action lease becomes replaceable when queue depth prevents dispatch', async () => { for (let i = 0; i < 10; i++) { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: `fill-${i}`, - source: 'agent', - targetCats: [`cat-${i}`], - intent: 'execute', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: `fill-${i}`, + source: 'agent', + targetCats: [`cat-${i}`], + intent: 'execute', + }), + ); } const res = await app.inject({ @@ -886,15 +903,18 @@ describe('B6: multi_mention queue dispatch', () => { test('keeps a failed return delivery pending for recovery instead of changing custody', async () => { for (let i = 0; i < 10; i++) { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: `fill-return-${i}`, - source: 'agent', - targetCats: [`cat-return-${i}`], - intent: 'execute', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: `fill-return-${i}`, + source: 'agent', + targetCats: [`cat-return-${i}`], + intent: 'execute', + }), + ); } actionAdmissionResult = { admit: true, @@ -934,15 +954,18 @@ describe('B6: multi_mention queue dispatch', () => { test('keeps a failed replayed return pending for recovery instead of changing custody', async () => { for (let i = 0; i < 10; i++) { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: `fill-return-replay-${i}`, - source: 'agent', - targetCats: [`cat-return-replay-${i}`], - intent: 'execute', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: `fill-return-replay-${i}`, + source: 'agent', + targetCats: [`cat-return-replay-${i}`], + intent: 'execute', + }), + ); } actionAdmissionResult = { admit: false, @@ -980,15 +1003,18 @@ describe('B6: multi_mention queue dispatch', () => { }); test('action-scoped dispatch queues behind unrelated work for the same cat', async () => { - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'existing unrelated work', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: 'existing unrelated work', + source: 'agent', + targetCats: ['codex'], + intent: 'execute', + }), + ); const res = await app.inject({ method: 'POST', @@ -1019,8 +1045,8 @@ describe('B6: multi_mention queue dispatch', () => { const realSetTimeout = globalThis.setTimeout; const realClearTimeout = globalThis.clearTimeout; let fireTimeout; - globalThis.setTimeout = (callback) => { - fireTimeout = callback; + globalThis.setTimeout = (callback, delay) => { + if (delay >= 3 * 60_000) fireTimeout = callback; return { unref() {} }; }; globalThis.clearTimeout = () => {}; @@ -1050,6 +1076,7 @@ describe('B6: multi_mention queue dispatch', () => { fireTimeout(); await Promise.resolve(); await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(actionUnavailableCalls.length, 1); assert.deepEqual(actionUnavailableCalls[0], { @@ -1117,17 +1144,20 @@ describe('B6: multi_mention queue dispatch', () => { } }); - test('duplicate cat detection skips already-queued cats', async () => { + test('a new multi-mention source is not deduped by another pending message for the same cat', async () => { // Pre-enqueue codex as agent - invocationQueue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'existing', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - }); + invocationQueue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: 'existing', + source: 'agent', + targetCats: ['codex'], + intent: 'execute', + }), + ); const res = await app.inject({ method: 'POST', @@ -1135,219 +1165,57 @@ describe('B6: multi_mention queue dispatch', () => { headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { targets: ['codex'], - question: 'Should be skipped', + question: 'Independent new question', callbackTo: 'opus', }, }); assert.equal(res.statusCode, 200); - // No new hooks (codex already queued) - assert.equal(mockQueueProcessor.getHooks().size, 0); + assert.equal(mockQueueProcessor.getHooks().size, 1); + assert.equal( + invocationQueue.list('thread-1', 'user-1').filter((entry) => entry.targets.includes('codex')).length, + 2, + 'dedupe is source×target, never merely target cat identity', + ); }); - test('falls back to direct dispatch when queue deps are absent', async () => { - // Create a new app WITHOUT queue deps - const fallbackApp = Fastify({ logger: false }); - registerCallbackAuthHook(fallbackApp, mockRegistry); + test('fails closed when canonical Queue deps are absent', async () => { + const incompleteApp = Fastify({ logger: false }); + registerCallbackAuthHook(incompleteApp, mockRegistry); resetMultiMentionOrchestrator(); - const fallbackRouter = createMockRouter(); + const incompleteRouter = createMockRouter(); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - const source = mockMessageStore.append({ - userId: 'user-2', - catId: null, - content: 'Exact direct source', - mentions: [], - timestamp: 200, - threadId: 'thread-2', - deliveryStatus: 'delivered', - }); - const fallbackCreds = mockRegistry.register('opus', 'thread-2', 'user-2', { - originTriggerMessageId: source.id, - }); + const incompleteCreds = mockRegistry.register('opus', 'thread-2', 'user-2'); - registerMultiMentionRoutes(fallbackApp, { + registerMultiMentionRoutes(incompleteApp, { registry: mockRegistry, messageStore: mockMessageStore, socketManager: mockSocket, - router: fallbackRouter, + router: incompleteRouter, invocationRecordStore: mockInvocationRecordStore, invocationTracker: mockInvocationTracker, - // No invocationQueue or queueProcessor }); - await fallbackApp.ready(); + await incompleteApp.ready(); - const res = await fallbackApp.inject({ + const res = await incompleteApp.inject({ method: 'POST', url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': fallbackCreds.invocationId, 'x-callback-token': fallbackCreds.callbackToken }, - payload: { - targets: ['codex'], - question: 'Fallback test', - callbackTo: 'opus', + headers: { + 'x-invocation-id': incompleteCreds.invocationId, + 'x-callback-token': incompleteCreds.callbackToken, }, - }); - - assert.equal(res.statusCode, 200); - - // Wait for async dispatch to complete - await new Promise((r) => setTimeout(r, 100)); - - // Direct dispatch should have been used (router called) - assert.ok(fallbackRouter.getExecutions().length > 0); - const [execution] = fallbackRouter.getExecutions(); - assert.equal(execution.userMessageId, source.id); - assert.equal(execution.options.requiresExactCloudDispatchProvenance, true); - assert.deepEqual(execution.options.cloudDispatchProvenance, { - sourceMessageId: source.id, - sourceSender: { kind: 'user', id: 'user-2' }, - calledByCatId: 'opus', - intent: 'Fallback test', - }); - - await fallbackApp.close(); - }); - - test('keeps a typed direct cloud provenance failure visible instead of recording empty success', async () => { - const fallbackApp = Fastify({ logger: false }); - registerCallbackAuthHook(fallbackApp, mockRegistry); - resetMultiMentionOrchestrator(); - const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - const routeCalls = []; - const fallbackRouter = { - async *routeExecution(_userId, _message, _threadId, _sourceMessageId, targetCats, _intent, options) { - routeCalls.push(options); - yield { - type: 'system_info', - catId: targetCats[0], - content: JSON.stringify({ - type: 'cloud_bridge_status', - status: 'unavailable', - reason: 'incomplete-dispatch-provenance', - message: '未发送给 @gpt-pro:投递来源或回程绑定不完整。', - }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; - }, - }; - const fallbackCreds = mockRegistry.register('opus', 'thread-2', 'user-2'); - - registerMultiMentionRoutes(fallbackApp, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: fallbackRouter, - invocationRecordStore: mockInvocationRecordStore, - invocationTracker: mockInvocationTracker, - }); - await fallbackApp.ready(); - - const res = await fallbackApp.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': fallbackCreds.invocationId, 'x-callback-token': fallbackCreds.callbackToken }, payload: { - targets: ['gpt-pro'], - question: 'Fail visibly', + targets: ['codex'], + question: 'Canonical Queue required', callbackTo: 'opus', }, }); - assert.equal(res.statusCode, 200); - const { requestId } = res.json(); - await new Promise((resolve) => setTimeout(resolve, 100)); - const result = getMultiMentionOrchestrator().getResult(requestId); - assert.equal(routeCalls[0].requiresExactCloudDispatchProvenance, true); - assert.equal(routeCalls[0].cloudDispatchProvenance, undefined); - assert.equal(result.responses[0].status, 'received'); - assert.match(result.responses[0].content, /未发送给 @gpt-pro/); - - await fallbackApp.close(); - }); - - test('does not direct-dispatch a caller-visible source that is ineligible for the cloud return boundary', async () => { - const fallbackApp = Fastify({ logger: false }); - registerCallbackAuthHook(fallbackApp, mockRegistry); - resetMultiMentionOrchestrator(); - const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - const routeCalls = []; - let bridgeDispatches = 0; - const fallbackRouter = { - async *routeExecution(_userId, _message, _threadId, _sourceMessageId, targetCats, _intent, options) { - const targetCatId = targetCats[0]; - routeCalls.push({ targetCatId, options }); - if (targetCatId === 'gpt-pro') { - if (options.cloudDispatchProvenance) { - bridgeDispatches += 1; - yield { type: 'text', catId: targetCatId, content: 'Cloud bridge dispatched', timestamp: Date.now() }; - } else { - yield { - type: 'system_info', - catId: targetCatId, - content: JSON.stringify({ - type: 'cloud_bridge_status', - status: 'unavailable', - reason: 'missing-source-message-id', - message: '未发送给 @gpt-pro:精确来源不满足公开回程资格。', - }), - timestamp: Date.now(), - }; - } - } else { - yield { type: 'text', catId: targetCatId, content: 'Local sibling completed', timestamp: Date.now() }; - } - yield { type: 'done', catId: targetCatId, isFinal: true, timestamp: Date.now() }; - }, - }; - const source = mockMessageStore.append({ - userId: 'user-2', - catId: null, - content: 'Private source for opus only', - mentions: [], - timestamp: 201, - threadId: 'thread-2', - deliveryStatus: 'delivered', - visibility: 'whisper', - whisperTo: ['opus'], - }); - const fallbackCreds = mockRegistry.register('opus', 'thread-2', 'user-2', { - originTriggerMessageId: source.id, - }); - - registerMultiMentionRoutes(fallbackApp, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: fallbackRouter, - invocationRecordStore: mockInvocationRecordStore, - invocationTracker: mockInvocationTracker, - }); - await fallbackApp.ready(); - - const res = await fallbackApp.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': fallbackCreds.invocationId, 'x-callback-token': fallbackCreds.callbackToken }, - payload: { - targets: ['codex', 'gpt-pro'], - question: 'Do not leak the private source', - callbackTo: 'opus', - }, - }); + assert.equal(res.statusCode, 503); + assert.match(res.json().error, /InvocationQueue and QueueProcessor/); + assert.equal(incompleteRouter.getExecutions().length, 0); - assert.equal(res.statusCode, 200); - const { requestId } = res.json(); - await new Promise((resolve) => setTimeout(resolve, 100)); - const cloudCall = routeCalls.find((call) => call.targetCatId === 'gpt-pro'); - assert.equal(bridgeDispatches, 0); - assert.equal(cloudCall.options.requiresExactCloudDispatchProvenance, true); - assert.equal(cloudCall.options.cloudDispatchProvenance, undefined); - const result = getMultiMentionOrchestrator().getResult(requestId); - assert.equal(result.request.status, 'done'); - assert.match(result.responses.find((response) => response.catId === 'codex').content, /Local sibling completed/); - assert.match(result.responses.find((response) => response.catId === 'gpt-pro').content, /未发送给 @gpt-pro/); - - await fallbackApp.close(); + await incompleteApp.close(); }); }); @@ -1356,7 +1224,7 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { const { InvocationQueue: IQ } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor: QP } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const queue = new IQ(); + const queue = adaptInvocationQueue(new IQ()); let hookResult = null; const stubDeps = { @@ -1374,7 +1242,17 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { update: () => {}, }, router: { - async *routeExecution(_u, _c, _t, _m, targetCats) { + resolveExplicitTargets: async (requestedCatIds) => [...requestedCatIds], + resolveConversationTargetsAtAdmission: async (requestedCatIds) => [...requestedCatIds], + async *routeExecution(userId, _c, threadId, _m, targetCats, _intent, options) { + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: 'turn-hook-single', + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); yield { type: 'text', catId: targetCats[0], content: 'Hello from hook', timestamp: Date.now() }; yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; }, @@ -1385,32 +1263,31 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { broadcastToRoom: () => {}, emitToUser: () => {}, }, - messageStore: { - markDelivered: () => null, - getById: () => null, - }, + messageStore: adaptMessageStore(new MessageStore()), log: { info: () => {}, warn: () => {}, error: () => {} }, }; const qp = new QP(stubDeps); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'test', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - }); + const result = queue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: 'test', + source: 'agent', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + }), + ); qp.registerEntryCompleteHook(result.entry.id, (entryId, status, responseText) => { hookResult = { entryId, status, responseText }; }); - // Trigger execution via tryAutoExecute - await qp.tryAutoExecute('thread-1'); + await qp.requestDrain('thread-1'); // Wait for async execution to complete await new Promise((r) => setTimeout(r, 200)); @@ -1420,11 +1297,100 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { assert.equal(hookResult.responseText, 'Hello from hook'); }); - test('dispatches one exact cloud child on Queue replay and returns its typed failure notice', async () => { + test('executeEntry partitions one source row into target-specific completion hooks', async () => { + const { InvocationQueue: IQ } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const { QueueProcessor: QP } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); + + const queue = adaptInvocationQueue(new IQ()); + const hookResults = new Map(); + const invocationKeys = []; + const stubDeps = { + queue, + invocationTracker: { + start: () => new AbortController(), + startAll: () => new AbortController(), + tryStartThreadAll: () => new AbortController(), + complete: () => {}, + completeAll: () => {}, + has: () => false, + }, + invocationRecordStore: { + create: (input) => { + invocationKeys.push(input.idempotencyKey); + return { outcome: 'created', invocationId: `inv-${input.targetCats[0]}` }; + }, + update: () => {}, + }, + router: { + resolveExplicitTargets: async (requestedCatIds) => [...requestedCatIds], + resolveConversationTargetsAtAdmission: async (requestedCatIds) => [...requestedCatIds], + async *routeExecution(userId, _content, threadId, _messageId, targetCats, _intent, options) { + for (const catId of targetCats) { + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId, + invocationId: `turn-hook-${catId}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); + yield { type: 'text', catId, content: `${catId} completed`, timestamp: Date.now() }; + yield { type: 'done', catId, isFinal: true, timestamp: Date.now() }; + } + }, + ackCollectedCursors: () => Promise.resolve(), + }, + socketManager: { + broadcastAgentMessage: () => {}, + broadcastToRoom: () => {}, + emitToUser: () => {}, + }, + messageStore: adaptMessageStore(new MessageStore()), + log: { info: () => {}, warn: () => {}, error: () => {} }, + }; + const qp = new QP(stubDeps); + const result = queue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: 'test both targets', + source: 'agent', + targetCats: ['codex', 'gemini'], + intent: 'execute', + autoExecute: true, + }), + ); + + for (const catId of ['codex', 'gemini']) { + qp.registerEntryCompleteHook( + result.entry.id, + (_entryId, status, responseText) => hookResults.set(catId, { status, responseText }), + catId, + ); + } + + await qp.requestDrain('thread-1'); + await new Promise((resolve) => setTimeout(resolve, 500)); + + assert.deepEqual(hookResults.get('codex'), { status: 'succeeded', responseText: 'codex completed' }); + assert.deepEqual(hookResults.get('gemini'), { status: 'succeeded', responseText: 'gemini completed' }); + assert.deepEqual(invocationKeys, [`queue-${result.entry.id}:codex`]); + }); + + test('dispatches one exact cloud child, returns its typed failure notice, and retires the Queue carrier', async () => { const { InvocationQueue: IQ } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor: QP } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const queue = new IQ(); + // F117: InvocationQueue dropped the legacy in-memory `enqueue`; use the + // fixture adapter (canonical MessageFrom + durable ledger admission) like the + // sibling tests below. + const queue = adaptInvocationQueue(new IQ()); + // F117: message_wake rows must reference a real persisted History message — + // production admits message+queue atomically (appendAndEnqueueDurable), and + // the QueueProcessor fails a wake closed when the referenced message is absent. + const messageStore = adaptMessageStore(new MessageStore()); const recordsByIdempotencyKey = new Map(); const recordsById = new Map(); let invocationCounter = 0; @@ -1459,8 +1425,18 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { }, }, router: { + resolveExplicitTargets: async (requestedCatIds) => [...requestedCatIds], + resolveConversationTargetsAtAdmission: async (requestedCatIds) => [...requestedCatIds], async *routeExecution(userId, message, threadId, sourceMessageId, targetCats, intent, options) { routeCalls.push({ userId, message, threadId, sourceMessageId, targetCats, intent, options }); + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: 'turn-cloud-child', + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); yield { type: 'system_info', catId: targetCats[0], @@ -1481,42 +1457,55 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { broadcastToRoom: () => {}, emitToUser: () => {}, }, - messageStore: { - markDelivered: () => null, - getById: () => null, - }, + messageStore, log: { info: () => {}, warn: () => {}, error: () => {} }, }; const qp = new QP(stubDeps); + // Persist the exact source message the wake references (atomic-admission contract). + const sourceMessage = messageStore.append({ + userId: 'user-1', + from: { kind: 'agent', catId: 'opus' }, + content: '[Multi-Mention from opus]\n\nOriginal raw intent', + mentions: [], + timestamp: 100, + threadId: 'thread-1', + }); const carrier = { - sourceMessageId: 'msg-exact-source', + sourceMessageId: sourceMessage.id, sourceSender: { kind: 'user', id: 'user-1' }, calledByCatId: 'opus', intent: 'Original raw intent', }; const enqueue = () => - queue.enqueue({ + queue.enqueueExistingMessageDurable(messageStore, sourceMessage.id, { + // F117: durable Queue admission requires an explicit ledger kind + // (same contract as production A2A fan-out: message_wake). + kind: 'message_wake', ownerAuthProvenance: 'strict', threadId: 'thread-1', userId: 'user-1', content: '[Multi-Mention from opus]\n\nOriginal raw intent', - source: 'agent', + from: { kind: 'agent', catId: 'opus' }, targetCats: ['gpt-pro'], intent: 'execute', autoExecute: true, callerCatId: 'opus', a2aParentInvocationId: 'inv-parent', + // F117: message_wake admission requires a durable History message reference; + // production A2A fan-out threads the source message id as payload.messageId. + messageId: sourceMessage.id, + a2aTriggerMessageId: carrier.sourceMessageId, idempotencyKey: 'multi-mention:req-1:gpt-pro', cloudDispatchProvenance: carrier, requiresExactCloudDispatchProvenance: true, }); - const first = enqueue(); + const first = await enqueue(); qp.registerEntryCompleteHook(first.entry.id, (_entryId, status, responseText) => { hookResults.push({ status, responseText }); }); - await qp.tryAutoExecute('thread-1'); + await qp.requestDrain('thread-1'); await new Promise((resolve) => setTimeout(resolve, 200)); assert.equal(routeCalls.length, 1); @@ -1530,23 +1519,14 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { }, ]); - const replay = enqueue(); - qp.registerEntryCompleteHook(replay.entry.id, (_entryId, status, responseText) => { - hookResults.push({ status, responseText }); - }); - await qp.tryAutoExecute('thread-1'); - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.equal(routeCalls.length, 1, 'stable replay must not dispatch the exact child twice'); - assert.equal(hookResults.length, 2); - assert.equal(hookResults[1].status, 'succeeded'); + assert.deepEqual(queue.list('thread-1', 'user-1'), [], 'delivered work must leave no Queue tombstone'); }); test('hook is auto-removed after firing (one-shot)', async () => { const { InvocationQueue: IQ } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor: QP } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const queue = new IQ(); + const queue = adaptInvocationQueue(new IQ()); let hookCallCount = 0; const stubDeps = { @@ -1564,6 +1544,8 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { update: () => {}, }, router: { + resolveExplicitTargets: async (requestedCatIds) => [...requestedCatIds], + resolveConversationTargetsAtAdmission: async (requestedCatIds) => [...requestedCatIds], async *routeExecution(_u, _c, _t, _m, targetCats) { yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; }, @@ -1583,22 +1565,25 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { const qp = new QP(stubDeps); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'test', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - }); + const result = queue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: 'test', + source: 'agent', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + }), + ); qp.registerEntryCompleteHook(result.entry.id, () => { hookCallCount++; }); - await qp.tryAutoExecute('thread-1'); + await qp.requestDrain('thread-1'); await new Promise((r) => setTimeout(r, 200)); assert.equal(hookCallCount, 1, 'Hook should fire exactly once'); @@ -1608,7 +1593,7 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { const { InvocationQueue: IQ } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor: QP } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const queue = new IQ(); + const queue = adaptInvocationQueue(new IQ()); let hookResult = null; const abortController = new AbortController(); @@ -1627,6 +1612,8 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { update: () => {}, }, router: { + resolveExplicitTargets: async (requestedCatIds) => [...requestedCatIds], + resolveConversationTargetsAtAdmission: async (requestedCatIds) => [...requestedCatIds], async *routeExecution(_u, _c, _t, _m, targetCats) { yield { type: 'text', catId: targetCats[0], content: 'partial', timestamp: Date.now() }; abortController.abort(); @@ -1648,22 +1635,25 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { const qp = new QP(stubDeps); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'test', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - }); + const result = queue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: 'test', + source: 'agent', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + }), + ); qp.registerEntryCompleteHook(result.entry.id, (entryId, status, responseText) => { hookResult = { entryId, status, responseText }; }); - await qp.tryAutoExecute('thread-1'); + await qp.requestDrain('thread-1'); await new Promise((r) => setTimeout(r, 200)); assert.ok(hookResult, 'Hook should have been called'); @@ -1674,7 +1664,7 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { const { InvocationQueue: IQ } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor: QP } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - const queue = new IQ(); + const queue = adaptInvocationQueue(new IQ()); let hookResult = null; const stubDeps = { @@ -1692,6 +1682,8 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { update: () => {}, }, router: { + resolveExplicitTargets: async (requestedCatIds) => [...requestedCatIds], + resolveConversationTargetsAtAdmission: async (requestedCatIds) => [...requestedCatIds], async *routeExecution() { throw new Error('Should not be called for duplicate'); }, @@ -1711,22 +1703,25 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { const qp = new QP(stubDeps); - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'test-dup', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - }); + const result = queue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'private_input', + content: 'test-dup', + source: 'agent', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + }), + ); qp.registerEntryCompleteHook(result.entry.id, (entryId, status, responseText) => { hookResult = { entryId, status, responseText }; }); - await qp.tryAutoExecute('thread-1'); + await qp.requestDrain('thread-1'); await new Promise((r) => setTimeout(r, 200)); assert.ok(hookResult, 'Hook should have been called for duplicate'); @@ -1749,9 +1744,10 @@ describe('B6: canceled hook skips recordResponse in dispatchViaQueue', () => { mockInvocationRecordStore = createMockInvocationRecordStore(); mockInvocationTracker = createMockInvocationTracker(); mockRouter = createMockRouter(); - invocationQueue = new InvocationQueue(); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); mockQueueProcessor = createMockQueueProcessor(); creds = mockRegistry.register('opus', 'thread-1', 'user-1'); + appendTestLifecycleResponseSource(mockMessageStore, creds); app = Fastify({ logger: false }); registerCallbackAuthHook(app, mockRegistry); diff --git a/packages/api/test/multi-mention-routes.test.js b/packages/api/test/multi-mention-routes.test.js index c583b2f649..3afda7cdf3 100644 --- a/packages/api/test/multi-mention-routes.test.js +++ b/packages/api/test/multi-mention-routes.test.js @@ -8,8 +8,16 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; import Fastify from 'fastify'; +import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { registerCallbackAuthHook } from '../dist/routes/callback-auth-prehandler.js'; import { resetMultiMentionOrchestrator } from '../dist/routes/callback-multi-mention-routes.js'; +import { + adaptInvocationQueue, + adaptMessageStore, + appendTestLifecycleResponseSource, + canonicalTestQueueInput, +} from './helpers/message-from-fixtures.js'; // ── Mocks ────────────────────────────────────────────────────────────── @@ -25,6 +33,7 @@ function createMockRegistry() { userId, invocationId: id, callbackToken: token, + ownerAuthProvenance: 'strict', ...(parentInvocationId ? { parentInvocationId } : {}), }); return { invocationId: id, callbackToken: token }; @@ -54,6 +63,7 @@ function createMockSocketManager() { broadcastToRoom(room, event, data) { roomEvents.push({ room, event, data }); }, + emitToUser() {}, getMessages() { return messages; }, @@ -64,18 +74,16 @@ function createMockSocketManager() { } function createMockMessageStore() { + const store = adaptMessageStore(new MessageStore()); const messages = []; - return { - append(msg) { - const stored = { id: `msg-${messages.length}`, ...msg }; - messages.push(stored); - return stored; - }, - getById: (id) => messages.find((m) => m.id === id) ?? null, - getMessages() { - return messages; - }, + const append = store.append.bind(store); + store.append = (msg) => { + const stored = append(msg); + messages.push(stored); + return stored; }; + store.getMessages = () => messages; + return store; } function createMockInvocationRecordStore() { @@ -167,6 +175,21 @@ function createMockRouter(responses = {}) { }; } +function createMockQueueProcessor() { + const hooks = new Map(); + return { + registerEntryCompleteHook(entryId, hook) { + hooks.set(entryId, hook); + }, + unregisterEntryCompleteHook(entryId) { + hooks.delete(entryId); + }, + requestDrain() { + return Promise.resolve(); + }, + }; +} + // ── Test setup ───────────────────────────────────────────────────────── describe('Multi-Mention Routes', () => { @@ -178,6 +201,8 @@ describe('Multi-Mention Routes', () => { let mockInvocationRecordStore; let mockInvocationTracker; let mockRouter; + let invocationQueue; + let mockQueueProcessor; let creds; beforeEach(async () => { @@ -189,9 +214,12 @@ describe('Multi-Mention Routes', () => { mockInvocationRecordStore = createMockInvocationRecordStore(); mockInvocationTracker = createMockInvocationTracker(); mockRouter = createMockRouter({ codex: 'Codex says hello', gemini: 'Gemini says hi' }); + invocationQueue = adaptInvocationQueue(new InvocationQueue()); + mockQueueProcessor = createMockQueueProcessor(); // Register a caller invocation (opus calling) creds = mockRegistry.register('opus', 'thread-1', 'user-1'); + appendTestLifecycleResponseSource(mockMessageStore, creds); app = Fastify({ logger: false }); registerCallbackAuthHook(app, mockRegistry); @@ -205,6 +233,8 @@ describe('Multi-Mention Routes', () => { router: mockRouter, invocationRecordStore: mockInvocationRecordStore, invocationTracker: mockInvocationTracker, + invocationQueue, + queueProcessor: mockQueueProcessor, }); await app.ready(); @@ -234,46 +264,11 @@ describe('Multi-Mention Routes', () => { assert.equal(body.status, 'running'); }); - test('releases a terminal dynamic A2A child with the multi-mention route controller', async () => { - let routeController; - mockRouter.routeExecution = async function* ( - _userId, - _message, - threadId, - _invocationId, - _targetCats, - _intent, - options, - ) { - routeController = options.invocationController; - assert.equal(options.trackA2ASlot(threadId, 'gemini', 'user-1', routeController), true); - yield { type: 'done', catId: 'gemini', isFinal: true, timestamp: Date.now() }; - options.completeA2ASlots(threadId, ['gemini'], routeController); - }; - - const res = await app.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, - payload: { - targets: ['codex'], - question: 'Route to a dynamic child', - callbackTo: 'opus', - }, - }); - assert.equal(res.statusCode, 200); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const dynamicCompletions = mockInvocationTracker.getAllCompletes().filter((call) => call.catIds.includes('gemini')); - assert.equal(dynamicCompletions.length, 1); - assert.equal(dynamicCompletions[0].threadId, 'thread-1'); - assert.equal(dynamicCompletions[0].controller, routeController); - }); - test('uses durable prompt coverage so a same-wave sibling reply does not hold multi-mention', async () => { const freshnessApp = Fastify({ logger: false }); registerCallbackAuthHook(freshnessApp, mockRegistry); const causalMessageStore = createMockMessageStore(); + appendTestLifecycleResponseSource(causalMessageStore, creds); const trigger = causalMessageStore.append({ userId: 'user-1', catId: null, @@ -303,6 +298,8 @@ describe('Multi-Mention Routes', () => { router: mockRouter, invocationRecordStore: mockInvocationRecordStore, invocationTracker: mockInvocationTracker, + invocationQueue, + queueProcessor: mockQueueProcessor, deliveryCursorStore: { async getSeenCursor() { return trigger.id; @@ -342,10 +339,11 @@ describe('Multi-Mention Routes', () => { await freshnessApp.close(); }); - test('holds multi-mention on unread visible other-cat stream-origin speech in play mode', async () => { + test('multi-mention does not hide a dispatch behind unread-message state', async () => { const freshnessApp = Fastify({ logger: false }); registerCallbackAuthHook(freshnessApp, mockRegistry); const causalMessageStore = createMockMessageStore(); + appendTestLifecycleResponseSource(causalMessageStore, creds); const baseline = causalMessageStore.append({ userId: 'user-1', catId: null, @@ -375,6 +373,8 @@ describe('Multi-Mention Routes', () => { router: mockRouter, invocationRecordStore: mockInvocationRecordStore, invocationTracker: mockInvocationTracker, + invocationQueue: adaptInvocationQueue(new InvocationQueue()), + queueProcessor: createMockQueueProcessor(), deliveryCursorStore: { async getSeenCursor() { return baseline.id; @@ -392,36 +392,44 @@ describe('Multi-Mention Routes', () => { method: 'POST', url: '/api/callbacks/multi-mention', headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, - payload: { targets: ['codex'], question: 'must read first', callbackTo: 'opus' }, + payload: { targets: ['codex'], question: 'dispatch without inbox side effects', callbackTo: 'opus' }, }); assert.equal(res.statusCode, 200); - assert.equal(JSON.parse(res.body).status, 'held'); - assert.equal(JSON.parse(res.body).reason, 'newer_messages_available'); + assert.equal(JSON.parse(res.body).status, 'running'); await freshnessApp.close(); }); - test('checks queued continue-current work against the callback outer parent', async () => { + test('multi-mention does not let queued continue-current work veto dispatch', async () => { const freshnessApp = Fastify({ logger: false }); registerCallbackAuthHook(freshnessApp, mockRegistry); const parentInvocationId = 'parent-multi-freshness'; const parentCreds = mockRegistry.register('opus', 'thread-multi-parent', 'user-1', parentInvocationId); const queueMessageStore = createMockMessageStore(); - queueMessageStore.getByThreadAfter = async () => []; - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); - invocationQueue.enqueue({ - ownerAuthProvenance: 'strict', + appendTestLifecycleResponseSource(queueMessageStore, { + ...parentCreds, threadId: 'thread-multi-parent', userId: 'user-1', - content: 'read before starting a multi-mention', - source: 'user', - targetCats: ['opus'], - authorIntentByCatId: { - opus: { requested: 'continue_current', boundParentInvocationId: parentInvocationId }, - }, - intent: 'execute', + catId: 'opus', }); + queueMessageStore.getByThreadAfter = async () => []; + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const invocationQueue = adaptInvocationQueue(new InvocationQueue()); + invocationQueue.enqueue( + canonicalTestQueueInput({ + ownerAuthProvenance: 'strict', + threadId: 'thread-multi-parent', + userId: 'user-1', + kind: 'conversation_input', + content: 'read before starting a multi-mention', + source: 'user', + targetCats: ['opus'], + authorIntentByCatId: { + opus: { requested: 'continue_current', boundParentInvocationId: parentInvocationId }, + }, + intent: 'execute', + }), + ); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(freshnessApp, { messageStore: queueMessageStore, @@ -430,6 +438,7 @@ describe('Multi-Mention Routes', () => { invocationRecordStore: mockInvocationRecordStore, invocationTracker: mockInvocationTracker, invocationQueue, + queueProcessor: createMockQueueProcessor(), deliveryCursorStore: { getSeenCursor: async () => 'seen-cursor' }, turnExecutionStore: { async get(invocationId) { @@ -457,14 +466,13 @@ describe('Multi-Mention Routes', () => { }, payload: { targets: ['codex'], - question: 'Do not start before reading current work', + question: 'Start without making this tool an inbox reader', callbackTo: 'opus', }, }); assert.equal(res.statusCode, 200); - assert.equal(JSON.parse(res.body).status, 'held'); - assert.equal(JSON.parse(res.body).reason, 'newer_messages_available'); + assert.equal(JSON.parse(res.body).status, 'running'); await freshnessApp.close(); }); @@ -551,30 +559,8 @@ describe('Multi-Mention Routes', () => { assert.ok(body.error.includes('callbackTo')); }); - test('dispatches to all targets', async () => { - await app.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, - payload: { - targets: ['codex', 'gemini'], - question: 'Review this design', - callbackTo: 'opus', - }, - }); - - // Wait for async dispatch to complete - await new Promise((r) => setTimeout(r, 100)); - - // Should have dispatched to both targets - const executions = mockRouter.getExecutions(); - assert.equal(executions.length, 2); - assert.ok(executions.some((e) => e.targetCats[0] === 'codex')); - assert.ok(executions.some((e) => e.targetCats[0] === 'gemini')); - }); - - test('broadcasts intent_mode and tracks active slots for dispatched targets', async () => { - await app.inject({ + test('queues all targets without direct provider dispatch', async () => { + const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, @@ -585,33 +571,14 @@ describe('Multi-Mention Routes', () => { }, }); - await new Promise((r) => setTimeout(r, 100)); - - const starts = mockInvocationTracker.getStarts(); - assert.equal(starts.length, 2); - assert.deepEqual(starts.map((entry) => entry.catId).sort(), ['codex', 'gemini']); - - const roomEvents = mockSocket.getRoomEvents().filter((event) => event.event === 'intent_mode'); - assert.equal(roomEvents.length, 2); - assert.deepEqual(roomEvents.map((event) => event.data.targetCats[0]).sort(), ['codex', 'gemini']); - for (const event of roomEvents) { - assert.equal(event.data.threadId, 'thread-1'); - assert.ok(event.data.invocationId, 'intent_mode should include invocationId'); - } - - const agentMessages = mockSocket - .getMessages() - .filter((message) => ['text', 'done'].includes(message.type) && ['codex', 'gemini'].includes(message.catId)); - assert.ok(agentMessages.length >= 4, 'expected streamed text+done messages for both targets'); - for (const message of agentMessages) { - assert.ok(message.invocationId, 'streamed multi-mention events should carry invocationId'); - } - - const completes = mockInvocationTracker.getCompletes(); - assert.equal(completes.length, 2); + assert.equal(res.statusCode, 200); + assert.equal(mockRouter.getExecutions().length, 0); + const entries = invocationQueue.list('thread-1', 'user-1'); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0].targets.toSorted(), ['codex', 'gemini']); }); - test('includes multi-mention prefix in dispatched message', async () => { + test('includes multi-mention prefix in queued content', async () => { await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', @@ -623,12 +590,24 @@ describe('Multi-Mention Routes', () => { }, }); - await new Promise((r) => setTimeout(r, 100)); - - const executions = mockRouter.getExecutions(); - assert.equal(executions.length, 1); - assert.ok(executions[0].message.includes('[Multi-Mention from opus]')); - assert.ok(executions[0].message.includes('What is your opinion?')); + const [entry] = invocationQueue.list('thread-1', 'user-1'); + assert.ok(entry.payload.content.includes('[Multi-Mention from opus]')); + assert.ok(entry.payload.content.includes('What is your opinion?')); + const source = await mockMessageStore.getById(entry.payload.sourceRecordId); + assert.ok(source, 'Queue source must be a real History message'); + assert.equal(entry.payload.messageId, source.id); + assert.equal(entry.payload.content, source.content); + const callerResponse = await mockMessageStore.getByIdempotencyKey( + 'user-1', + 'thread-1', + `message-lifecycle-response:${creds.invocationId}`, + ); + assert.ok(callerResponse); + assert.equal(source.replyTo, callerResponse.id); + assert.deepEqual(source.extra?.causal, { + kind: 'invocation_reply', + triggerMessageId: callerResponse.id, + }); }); test('uses default timeout when not specified', async () => { @@ -720,37 +699,6 @@ describe('Multi-Mention Routes', () => { assert.equal(res.statusCode, 401); }); - // ── Result aggregation ──────────────────────────────────────────── - - test('flushes aggregated result when all targets respond', async () => { - await app.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, - payload: { - targets: ['codex'], - question: 'Quick question', - callbackTo: 'opus', - }, - }); - - // Wait for dispatch + flush - await new Promise((r) => setTimeout(r, 200)); - - // Should have stored the aggregated result - const stored = mockMessageStore.getMessages(); - assert.ok(stored.length > 0); - - const resultMsg = stored.find((m) => m.content.includes('Multi-Mention 结果汇总')); - assert.ok(resultMsg, 'Should have stored aggregated result message'); - assert.ok(resultMsg.content.includes('Quick question')); - - // Should have broadcast via connector_message - const roomEvents = mockSocket.getRoomEvents(); - const connectorEvent = roomEvents.find((e) => e.event === 'connector_message'); - assert.ok(connectorEvent, 'Should have broadcast connector_message'); - }); - // ── Anti-cascade ────────────────────────────────────────────────── test('rejects multi-mention from active target cat (anti-cascade)', async () => { @@ -788,71 +736,6 @@ describe('Multi-Mention Routes', () => { assert.ok(body.error.includes('Anti-cascade')); }); - // ── InvocationTracker concurrent abort bug ────────────────────── - - test('concurrent dispatches are NOT aborted by InvocationTracker (per-thread singleton)', async () => { - // Reproduce the bug: InvocationTracker.start() aborts prior invocation - // for the same threadId, causing all but the last dispatch to lose their response. - resetMultiMentionOrchestrator(); - - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const tracker = new InvocationTracker(); - - // Slow mock router: each target yields text after a delay, giving tracker - // time to abort earlier dispatches - const slowRouter = { - async *routeExecution(_userId, _message, _threadId, _invId, targetCats, _intent, opts) { - const catId = targetCats[0]; - // Small delay to let concurrent starts happen - await new Promise((r) => setTimeout(r, 30)); - // Check if we've been aborted - if (opts?.signal?.aborted) return; - yield { type: 'text', catId, content: `Reply from ${catId}`, timestamp: Date.now() }; - yield { type: 'done', catId, isFinal: true, timestamp: Date.now() }; - }, - }; - - // Re-create app with invocationTracker - const trackerApp = Fastify({ logger: false }); - registerCallbackAuthHook(trackerApp, mockRegistry); - const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - registerMultiMentionRoutes(trackerApp, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: slowRouter, - invocationRecordStore: mockInvocationRecordStore, - invocationTracker: tracker, - }); - await trackerApp.ready(); - - // Use a separate creds so initiator (codex) is different from all targets - const callerCreds = mockRegistry.register('codex', 'thread-1', 'user-1'); - - await trackerApp.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': callerCreds.invocationId, 'x-callback-token': callerCreds.callbackToken }, - payload: { - targets: ['opus', 'gemini'], - question: 'Test concurrent dispatch', - callbackTo: 'codex', - }, - }); - - // Wait for all dispatches to complete - await new Promise((r) => setTimeout(r, 500)); - - // The aggregated result should contain replies from BOTH cats - const stored = mockMessageStore.getMessages(); - const resultMsg = stored.find((m) => m.content.includes('Multi-Mention 结果汇总')); - assert.ok(resultMsg, 'Should have aggregated result'); - assert.ok(resultMsg.content.includes('Reply from opus'), `Opus response missing. Got:\n${resultMsg?.content}`); - assert.ok(resultMsg.content.includes('Reply from gemini'), `Gemini response missing. Got:\n${resultMsg?.content}`); - - await trackerApp.close(); - }); - // ── Idempotency ─────────────────────────────────────────────────── test('idempotency key returns same requestId', async () => { @@ -872,180 +755,4 @@ describe('Multi-Mention Routes', () => { const body2 = JSON.parse(res2.body); assert.equal(body1.requestId, body2.requestId); }); - - // ── F122: target crash releases target slot (AC-A7) ──────────── - - test('F122 AC-A7: target execution failure releases target tracker slot', async () => { - resetMultiMentionOrchestrator(); - - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const tracker = new InvocationTracker(); - - // Router that throws (simulating target crash / context limit exceeded) - const crashRouter = { - async *routeExecution() { - throw new Error('prompt token count of 158302 exceeds the limit of 128000'); - }, - }; - - const crashApp = Fastify({ logger: false }); - registerCallbackAuthHook(crashApp, mockRegistry); - const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - registerMultiMentionRoutes(crashApp, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: crashRouter, - invocationRecordStore: mockInvocationRecordStore, - invocationTracker: tracker, - }); - await crashApp.ready(); - - const crashCreds = mockRegistry.register('opus', 'thread-crash', 'user-1'); - - const res = await crashApp.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': crashCreds.invocationId, 'x-callback-token': crashCreds.callbackToken }, - payload: { - targets: ['codex'], - question: 'This will crash', - callbackTo: 'opus', - }, - }); - - assert.equal(res.statusCode, 200); - - // Wait for background dispatch to complete (with error) - await new Promise((r) => setTimeout(r, 200)); - - // The key assertion: target slot must be released after crash - assert.equal( - tracker.has('thread-crash', 'codex'), - false, - 'Target cat slot must be released after execution failure', - ); - // Thread-level check - assert.equal(tracker.has('thread-crash'), false, 'Thread must have no active slots after target crash'); - const failedUpdate = mockInvocationRecordStore - .getUpdates() - .find((entry) => entry.data.status === 'failed' && entry.data.error === 'dispatch_error'); - assert.ok(failedUpdate, 'crashed multi-mention target must converge InvocationRecord to failed'); - - await crashApp.close(); - }); - - test('F122 AC-A7: pre-aborted controller still releases target tracker slot', async () => { - resetMultiMentionOrchestrator(); - - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const tracker = new InvocationTracker(); - - // Pre-abort the slot: simulate another invocation aborting this one - // by starting a slot for codex, then starting again (which aborts the first) - const controller1 = tracker.start('thread-preabort', 'codex', 'user-1', ['codex']); - // The slot is now active and not aborted - assert.equal(tracker.has('thread-preabort', 'codex'), true); - // Abort it to simulate preemption - controller1.abort(); - tracker.complete('thread-preabort', 'codex', controller1); - - // Now the slot should be free for the next dispatch - // The router doesn't matter here — what matters is the aborted-before-start path - const normalRouter = { - async *routeExecution(_u, _m, _t, _i, targetCats) { - const catId = targetCats[0]; - yield { type: 'text', catId, content: 'ok', timestamp: Date.now() }; - yield { type: 'done', catId, isFinal: true, timestamp: Date.now() }; - }, - }; - - const preAbortApp = Fastify({ logger: false }); - registerCallbackAuthHook(preAbortApp, mockRegistry); - const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - registerMultiMentionRoutes(preAbortApp, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: normalRouter, - invocationRecordStore: mockInvocationRecordStore, - invocationTracker: tracker, - }); - await preAbortApp.ready(); - - const preCreds = mockRegistry.register('opus', 'thread-preabort', 'user-1'); - - const res = await preAbortApp.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': preCreds.invocationId, 'x-callback-token': preCreds.callbackToken }, - payload: { - targets: ['codex'], - question: 'After preempt', - callbackTo: 'opus', - }, - }); - - assert.equal(res.statusCode, 200); - await new Promise((r) => setTimeout(r, 200)); - - // Slot must be released regardless of whether dispatch ran or was pre-aborted - assert.equal(tracker.has('thread-preabort', 'codex'), false, 'Slot must be released after pre-aborted dispatch'); - - await preAbortApp.close(); - }); - - // ── F122: parentInvocationId passthrough ─────────────────────── - - test('F122 AC-A1: dispatchToTarget passes parentInvocationId to routeExecution', async () => { - resetMultiMentionOrchestrator(); - - const capturedOpts = []; - const capturingRouter = { - async *routeExecution(_userId, _message, _threadId, _invId, targetCats, _intent, opts) { - capturedOpts.push(opts); - const catId = targetCats[0]; - yield { type: 'text', catId, content: `Response from ${catId}`, timestamp: Date.now() }; - yield { type: 'done', catId, isFinal: true, timestamp: Date.now() }; - }, - }; - - const capApp = Fastify({ logger: false }); - registerCallbackAuthHook(capApp, mockRegistry); - const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); - registerMultiMentionRoutes(capApp, { - registry: mockRegistry, - messageStore: mockMessageStore, - socketManager: mockSocket, - router: capturingRouter, - invocationRecordStore: mockInvocationRecordStore, - invocationTracker: mockInvocationTracker, - }); - await capApp.ready(); - - const capCreds = mockRegistry.register('opus', 'thread-pid', 'user-1'); - - const res = await capApp.inject({ - method: 'POST', - url: '/api/callbacks/multi-mention', - headers: { 'x-invocation-id': capCreds.invocationId, 'x-callback-token': capCreds.callbackToken }, - payload: { - targets: ['codex'], - question: 'F122 parentInvocationId test', - callbackTo: 'opus', - }, - }); - - assert.equal(res.statusCode, 200); - - // Wait for background dispatch - await new Promise((r) => setTimeout(r, 100)); - - assert.equal(capturedOpts.length, 1, 'routeExecution should be called once'); - assert.ok(capturedOpts[0].parentInvocationId, 'opts must include parentInvocationId'); - assert.ok(typeof capturedOpts[0].parentInvocationId === 'string', 'parentInvocationId must be a string'); - assert.ok(capturedOpts[0].signal, 'opts must still include signal'); - - await capApp.close(); - }); }); diff --git a/packages/api/test/parallel-cancel-signal-isolation.test.js b/packages/api/test/parallel-cancel-signal-isolation.test.js index 0b67d5cf62..4ceaf1a05a 100644 --- a/packages/api/test/parallel-cancel-signal-isolation.test.js +++ b/packages/api/test/parallel-cancel-signal-isolation.test.js @@ -61,6 +61,7 @@ function createMockDeps(services) { get: async () => null, getParticipantsWithActivity: async () => [], updateParticipantActivity: async () => {}, + consumeMentionRoutingFeedback: async () => null, }, apiUrl: 'http://127.0.0.1:3004', }, @@ -381,7 +382,7 @@ describe('cloud-#4: route-parallel suppresses abort-induced error (cancel AFTER 'codex (not cancelled) streamed normally', ); assert.ok( - deps.storedMessages.some((m) => m.catId === 'opus' && m.content === 'partial'), + deps.storedMessages.some((m) => m.from?.kind === 'agent' && m.from.catId === 'opus' && m.content === 'partial'), 'partial pre-abort output must pass through the normal done finalizer and persist', ); }); @@ -410,7 +411,11 @@ describe('cloud-#4: route-parallel suppresses abort-induced error (cancel AFTER Symbol.asyncIterator ](); - while (!deps.storedMessages.some((m) => m.catId === 'opus' && m.content === 'partial-before-cancel')) { + while ( + !deps.storedMessages.some( + (m) => m.from?.kind === 'agent' && m.from.catId === 'opus' && m.content === 'partial-before-cancel', + ) + ) { const result = await Promise.race([ iterator.next(), new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)), diff --git a/packages/api/test/parse-multipart-files.test.js b/packages/api/test/parse-multipart-files.test.js index 0d7cba58db..4532263aa2 100644 --- a/packages/api/test/parse-multipart-files.test.js +++ b/packages/api/test/parse-multipart-files.test.js @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; +import './helpers/setup-cat-registry.js'; describe('parseMultipart with mixed image + file attachments', () => { let uploadDir; @@ -118,6 +119,20 @@ describe('parseMultipart with mixed image + file attachments', () => { assert.equal(result.contentBlocks[1].type, 'file'); assert.equal(result.contentBlocks[1].fileName, 'data.json'); }); + + it('preserves repeated explicit member targets for attachment sends', async () => { + const { parseMultipart } = await import('../dist/routes/parse-multipart.js'); + const parts = [ + { type: 'field', fieldname: 'content', value: 'targeted attachment context' }, + { type: 'field', fieldname: 'mentions', value: 'opus' }, + { type: 'field', fieldname: 'mentions', value: 'codex' }, + ]; + + const result = await parseMultipart({ parts: () => createAsyncIterator(parts) }, uploadDir); + + assert.ok(!('error' in result)); + assert.deepEqual(result.mentions, ['opus', 'codex']); + }); }); async function* createAsyncIterator(items) { diff --git a/packages/api/test/per-cat-terminal-disposition-collector.test.js b/packages/api/test/per-cat-terminal-disposition-collector.test.js index f23fd93518..0fb1e8e31c 100644 --- a/packages/api/test/per-cat-terminal-disposition-collector.test.js +++ b/packages/api/test/per-cat-terminal-disposition-collector.test.js @@ -64,48 +64,19 @@ describe('PerCatTerminalDispositionCollector', () => { assert.deepEqual(collector.getSuccessfulCatIds(), ['opus']); }); - it('retains all adopted sources under their exact children across repeated done frames', () => { - const collector = new PerCatTerminalDispositionCollector({ targetCatIds: ['codex'] }); - const first = { - kind: 'managed_hold_continued', - sourceMessageId: 'source-1', - taskId: 'task-1', - transition: 'reheld', - }; - const second = { ...first, sourceMessageId: 'source-2', taskId: 'task-2' }; - const next = { ...first, sourceMessageId: 'source-3', taskId: 'task-3' }; - collector.observe({ type: 'done', catId: 'codex', invocationId: 'child-1', turnCustodyTerminalWitness: first }); - collector.observe({ - type: 'done', - catId: 'codex', - invocationId: 'child-1', - turnCustodyTerminalWitness: first, - turnCustodyTerminalWitnesses: [first, second], - }); - collector.observe({ type: 'done', catId: 'codex', invocationId: 'child-2', turnCustodyTerminalWitnesses: [next] }); - assert.deepEqual(collector.getTerminalConsumptionByInvocationId(), { - 'child-1': [first, second], - 'child-2': [next], + it('projects independent terminal status for every target', () => { + const collector = new PerCatTerminalDispositionCollector({ + targetCatIds: ['opus', 'codex', 'gemini'], + isCanceled: (catId) => catId === 'gemini', }); - assert.deepEqual(collector.getTerminalInvocationIdByCatId(), { codex: 'child-2' }); - }); - it('does not turn malformed, unbound or nonterminal event payloads into custody evidence', () => { - const collector = new PerCatTerminalDispositionCollector({ targetCatIds: ['codex'] }); - const witness = { kind: 'managed_hold_continued', sourceMessageId: 'source', taskId: 'task', transition: 'reheld' }; - collector.observe({ type: 'text', catId: 'codex', invocationId: 'child', turnCustodyTerminalWitness: witness }); - collector.observe({ type: 'done', catId: 'codex', turnCustodyTerminalWitness: witness }); - collector.observe({ type: 'done', catId: 'codex', invocationId: 42, turnCustodyTerminalWitness: witness }); - collector.observe({ - type: 'done', - catId: 'codex', - invocationId: 'child', - turnCustodyTerminalWitnesses: [ - { ...witness, sourceMessageId: '' }, - { ...witness, taskId: '' }, - { ...witness, transition: 'guessed' }, - ], - }); - assert.deepEqual(collector.getTerminalConsumptionByInvocationId(), {}); + collector.observe({ type: 'done', catId: 'opus' }); + collector.observe({ type: 'error', catId: 'codex', error: 'provider failed' }); + collector.observe({ type: 'done', catId: 'gemini' }); + + assert.equal(collector.getTerminalStatus('opus'), 'succeeded'); + assert.equal(collector.getTerminalStatus('codex'), 'failed'); + assert.equal(collector.getTerminalStatus('gemini'), 'canceled'); + assert.equal(collector.getTerminalStatus('unknown'), undefined); }); }); diff --git a/packages/api/test/persisted-queue-delivery.test.ts b/packages/api/test/persisted-queue-delivery.test.ts index ad5b711175..1414d4eb54 100644 --- a/packages/api/test/persisted-queue-delivery.test.ts +++ b/packages/api/test/persisted-queue-delivery.test.ts @@ -1,13 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { createCatId } from '@cat-cafe/shared'; +import { InvocationQueue } from '../src/domains/cats/services/agents/invocation/InvocationQueue.js'; import { ensurePersistedCarrierOwnedAndScheduled } from '../src/domains/cats/services/agents/invocation/PersistedQueueCarrier.js'; import { PersistedQueueDelivery } from '../src/domains/cats/services/agents/invocation/PersistedQueueDelivery.js'; -import { - createInitialCrossThreadQueuedMessageCustody, - createInitialQueuedMessageCustody, -} from '../src/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { createPersistedQueueFixture } from './helpers/persisted-queue-fixture.js'; +import { MessageStore } from '../src/domains/cats/services/stores/ports/MessageStore.js'; import './helpers/setup-cat-registry.js'; const input = { @@ -19,323 +15,99 @@ const input = { source: { connector: 'content-review', label: 'Review', icon: 'cat-cafe', meta: { reviewReceiptRef: 'receipt:1' } }, }; -function fixture(t: { after: (close: () => Promise) => void }) { - const f = createPersistedQueueFixture(); - t.after(f.close); - return f; -} - -function admitGroup(f: ReturnType, contents = [input.content]) { - const queued = f.queue.enqueue({ - threadId: input.threadId, - userId: input.ownerUserId, - ownerAuthProvenance: 'strict', - content: contents.join('\n'), - source: 'connector', - targetCats: [input.targetCatId], - intent: 'execute', - idempotencyKey: input.idempotencyKey, - }); - assert.ok(queued.entry); - const entry = queued.entry; - const messages = contents.map((content, index) => { - const message = f.messages.append({ - userId: input.ownerUserId, - threadId: input.threadId, - catId: null, - content, - mentions: [createCatId(input.targetCatId)], - timestamp: entry.createdAt + index, - deliveryStatus: 'queued', - source: input.source, - queueCustody: createInitialQueuedMessageCustody(entry), - ...(index === 0 ? { idempotencyKey: input.idempotencyKey } : {}), - }); - f.queue.backfillMessageId(input.threadId, input.ownerUserId, entry.id, message.id); - return message; - }); - const ensure = () => - ensurePersistedCarrierOwnedAndScheduled( - { - messages: f.messages, - queue: f.queue, - progress: (carrier, target) => f.processor.progressOwnedCarrier(carrier, target), - }, - { ...input, sourceMessageId: messages[0]!.id, expectedEntryId: entry.id }, - ); - return { entry, messages, ensure }; -} - -test('append survives failed backfill/rollback: retry restores the original carrier and one durable child', async (t) => { - const f = fixture(t); - const backfill = f.queue.backfillMessageId.bind(f.queue); - f.queue.backfillMessageId = () => { - throw new Error('backfill unavailable'); - }; - await assert.rejects(f.delivery.deliver(input), /backfill unavailable/); - const saved = f.messages.getByIdempotencyKey(input.ownerUserId, input.threadId, input.idempotencyKey); - assert.ok(saved?.queueCustody); - assert.equal(f.queue.list(input.threadId, input.ownerUserId).length, 0); - f.queue.backfillMessageId = backfill; - const recovered = await f.delivery.deliver(input); - assert.equal(recovered.state, 'started'); - assert.equal(recovered.message?.id, saved.id); - assert.equal('entryId' in recovered && recovered.entryId, saved.queueCustody.entryId); - const invocationId = await f.waitForAwakening(saved.id); - assert.equal((await f.delivery.deliver(input)).state, 'already_processing'); - assert.equal(await f.waitForAwakening(saved.id), invocationId); - assert.equal(f.records.size, 1); -}); - -test('append idempotency winning an admission race cannot leave a second process-local queue owner', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - f.queue.rollbackEnqueue(input.threadId, input.ownerUserId, group.entry.id); - // Another admission committed after this caller's lookup: append still returns the canonical old carrier. - const find = f.messages.getByIdempotencyKey.bind(f.messages); - let first = true; - f.messages.getByIdempotencyKey = (...args) => { - if (first) { - first = false; - return null; - } - return find(...args); - }; - await f.delivery.deliver(input); - await f.waitForAwakening(group.messages[0]!.id); - assert.deepEqual( - f.queue.list(input.threadId, input.ownerUserId).map((entry) => entry.id), - [group.entry.id], - ); - assert.equal(f.records.size, 1); -}); - -test('an unrelated queue completion cannot start a producer reservation before durable message admission', async (t) => { - const f = fixture(t); - let entered!: () => void, release!: () => void; - const appending = new Promise((resolve) => { - entered = resolve; - }); - const barrier = new Promise((resolve) => { - release = resolve; - }); +function fixture(progress: 'started' | 'owned_deferred_busy' = 'started') { + const messages = new MessageStore(); + const queue = new InvocationQueue(); + const progressed: string[] = []; const delivery = new PersistedQueueDelivery({ - queue: f.queue, - progress: (entry, catId) => f.processor.progressOwnedCarrier(entry, catId), - messages: { - getById: f.messages.getById.bind(f.messages), - getByIdempotencyKey: f.messages.getByIdempotencyKey.bind(f.messages), - getByThreadAfter: f.messages.getByThreadAfter.bind(f.messages), - async append(message) { - entered(); - await barrier; - return f.messages.append(message); - }, + messages, + queue, + progress: async (entry) => { + progressed.push(entry.id); + return progress; }, }); - const delivering = delivery.deliver(input); - await appending; - await f.processor.onInvocationComplete(input.threadId, input.targetCatId, 'succeeded'); - await new Promise((resolve) => setImmediate(resolve)); - const earlyStarts = f.records.size; - release(); - const result = await delivering; - assert.equal(earlyStarts, 0, 'Dispatch must not create an invocation before durable admission'); - assert.ok(result.message); - await f.waitForAwakening(result.message.id); - assert.equal(f.records.size, 1); -}); - -test('recovery uses the complete ordered merged group and keeps the exact entry identity', async (t) => { - const f = fixture(t); - const group = admitGroup(f, ['first source', 'second source']); - f.queue.rollbackEnqueue(input.threadId, input.ownerUserId, group.entry.id); - assert.equal((await group.ensure()).state, 'started'); - const entry = f.queue.getEntrySnapshot(input.threadId, input.ownerUserId, group.entry.id); - assert.equal(entry?.content, 'first source\nsecond source'); - assert.equal(entry?.messageId, group.messages[0]!.id); - assert.deepEqual(entry?.mergedMessageIds, [group.messages[1]!.id]); - const first = await f.waitForAwakening(group.messages[0]!.id); - assert.equal(await f.waitForAwakening(group.messages[1]!.id), first); - assert.equal(f.records.size, 1); -}); - -for (const failure of ['missing sibling', 'timeline unavailable']) - test(`${failure} cannot restore or start a partial carrier`, async (t) => { - const f = fixture(t); - const group = admitGroup(f, ['first', 'second']); - f.queue.rollbackEnqueue(input.threadId, input.ownerUserId, group.entry.id); - if (failure === 'missing sibling') { - const get = f.messages.getById.bind(f.messages); - f.messages.getById = (id) => (id === group.messages[1]!.id ? null : get(id)); - } else - f.messages.getByThreadAfter = () => { - throw new Error('store unavailable'); - }; - assert.equal((await group.ensure()).state, 'unavailable'); - assert.equal(f.queue.list(input.threadId, input.ownerUserId).length, 0); - assert.equal(f.records.size, 0); - }); - -test('an existing live row must match its complete durable scheduling projection', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - f.queue.setPosition(input.threadId, input.ownerUserId, group.entry.id, -10); - assert.equal((await group.ensure()).state, 'conflict'); - assert.equal(f.records.size, 0); - assert.equal(f.queue.getEntrySnapshot(input.threadId, input.ownerUserId, group.entry.id)?.position, -10); - f.queue.rollbackEnqueue(input.threadId, input.ownerUserId, group.entry.id); - f.queue.restoreDurableEntry({ - ...group.entry, - messageId: group.messages[0]!.id, - queuedAttemptIdByCatId: { [input.targetCatId]: 'a-different-retry-attempt' }, - }); - assert.equal((await group.ensure()).state, 'conflict', 'a different attempt id cannot inherit durable custody'); - assert.equal(f.records.size, 0); -}); - -test('manual pause and its epoch survive producer recovery; explicit resume runs the same carrier', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - await f.processor.onInvocationComplete(input.threadId, input.targetCatId, 'canceled_by_user', undefined, [], true); - assert.equal(f.processor.isPaused(input.threadId, input.targetCatId), true); - const epoch = new Map(Reflect.get(f.processor, 'pauseEpoch') as Map); - assert.equal((await group.ensure()).state, 'owned_deferred_paused'); - assert.deepEqual(Reflect.get(f.processor, 'pauseEpoch'), epoch); - assert.equal(f.records.size, 0); - const resumed = await f.processor.processNext(input.threadId, input.ownerUserId); - assert.equal(resumed.entry?.id, group.entry.id); - await f.waitForAwakening(group.messages[0]!.id); - assert.equal(f.records.size, 1); -}); + return { messages, queue, delivery, progressed }; +} -test('force-reset suppression survives producer recovery until explicit canonical resume', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - f.processor.suppressAutoResume(input.threadId, input.targetCatId, ['reset-owner']); - assert.equal((await group.ensure()).state, 'owned_deferred_suppressed'); - assert.equal(f.processor.isAutoResumeSuppressed(input.threadId, input.targetCatId), true); - assert.equal(f.records.size, 0); - assert.equal((await f.processor.processNext(input.threadId, input.ownerUserId)).entry?.id, group.entry.id); - await f.waitForAwakening(group.messages[0]!.id); +test('producer delivery atomically persists one Message and canonical Queue row', async () => { + const f = fixture(); + const delivered = await f.delivery.deliver(input); + assert.equal(delivered.state, 'started'); + assert.ok(delivered.message); + assert.equal(delivered.message.deliveryStatus, 'queued'); + assert.equal(delivered.message.queueCustody, undefined, 'History must not mirror Queue ledger state'); + assert.ok('entryId' in delivered); + const entry = await f.queue.getDurableEntry(input.threadId, delivered.entryId); + assert.equal(entry?.payload.messageId, delivered.message.id); + assert.deepEqual(entry?.targets, [input.targetCatId]); + assert.deepEqual(f.progressed, [delivered.entryId]); }); -test('busy ownership defers without forking, and normal slot completion progresses the original carrier', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - const controller = f.tracker.start(input.threadId, input.targetCatId); - assert.equal((await group.ensure()).state, 'owned_deferred_busy'); - assert.equal(f.records.size, 0); - f.tracker.complete(input.threadId, input.targetCatId, controller); - await f.processor.onInvocationComplete(input.threadId, input.targetCatId, 'succeeded'); - await f.waitForAwakening(group.messages[0]!.id); - assert.equal(f.records.size, 1); +test('idempotent producer replay reuses the same Message and Queue identity', async () => { + const f = fixture('owned_deferred_busy'); + const first = await f.delivery.deliver(input); + const replay = await f.delivery.deliver(input); + assert.equal(first.message?.id, replay.message?.id); + assert.equal('entryId' in first && 'entryId' in replay && first.entryId, replay.entryId); + assert.equal(f.queue.list(input.threadId, input.ownerUserId).length, 1); }); -test('withdrawn custody is accepted as terminal ownership without a substitute carrier', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - await f.coordinator.withdrawEntry(f.queue.getEntrySnapshot(input.threadId, input.ownerUserId, group.entry.id)!); - f.queue.rollbackEnqueue(input.threadId, input.ownerUserId, group.entry.id); - assert.equal((await group.ensure()).state, 'terminal_owned'); - assert.equal(f.queue.list(input.threadId, input.ownerUserId).length, 0); - assert.equal(f.records.size, 0); +test('an idempotency collision with a different immutable envelope fails closed', async () => { + const f = fixture(); + await f.delivery.deliver(input); + const conflict = await f.delivery.deliver({ ...input, content: 'different content' }); + assert.equal(conflict.state, 'conflict'); + assert.equal(f.queue.list(input.threadId, input.ownerUserId).length, 1); }); -test('same-id live custody in another user scope is a conflict, never an existing-owner success', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - f.queue.rollbackEnqueue(input.threadId, input.ownerUserId, group.entry.id); - f.queue.restoreDurableEntry({ ...group.entry, userId: 'another-owner' }); - assert.equal((await group.ensure()).state, 'conflict'); - assert.equal(f.queue.list(input.threadId, input.ownerUserId).length, 0); - assert.equal(f.records.size, 0); -}); +test('recovery schedules only exact persisted source and Queue coordinates', async () => { + const f = fixture(); + const admitted = await f.delivery.deliver(input); + assert.ok(admitted.message && 'entryId' in admitted); + const progress = async () => 'owned_deferred_busy' as const; + const exact = await ensurePersistedCarrierOwnedAndScheduled( + { messages: f.messages, queue: f.queue, progress }, + { ...input, sourceMessageId: admitted.message.id, expectedEntryId: admitted.entryId }, + ); + assert.deepEqual(exact, { state: 'owned_deferred_busy', entryId: admitted.entryId }); -test('failed custody keeps its existing retry authority without automatic producer replay', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - const entry = f.queue.getEntrySnapshot(input.threadId, input.ownerUserId, group.entry.id)!; - await f.coordinator.persistEntry({ ...entry, queuedFailedByCatIds: [input.targetCatId] }); - assert.equal((await group.ensure()).state, 'terminal_owned'); - assert.equal(f.records.size, 0); -}); + const missingIdentity = await ensurePersistedCarrierOwnedAndScheduled( + { messages: f.messages, queue: f.queue, progress }, + { ...input, sourceMessageId: admitted.message.id }, + ); + assert.equal(missingIdentity.state, 'conflict'); -test('producer recovery keeps F175 priority across users instead of jumping to its own return', async (t) => { - const f = fixture(t); - const group = admitGroup(f); - const older = f.queue.enqueue({ - threadId: input.threadId, - userId: 'another-owner', - ownerAuthProvenance: 'strict', - content: 'earlier urgent work', - source: 'connector', - targetCats: [input.targetCatId], - intent: 'execute', - priority: 'urgent', - }); - assert.ok(older.entry); - const message = f.messages.append({ - userId: 'another-owner', - threadId: input.threadId, - catId: null, - content: older.entry.content, - mentions: [createCatId(input.targetCatId)], - timestamp: older.entry.createdAt, - deliveryStatus: 'queued', - source: input.source, - queueCustody: createInitialQueuedMessageCustody(older.entry), - }); - f.queue.backfillMessageId(input.threadId, 'another-owner', older.entry.id, message.id); - assert.equal((await group.ensure()).state, 'owned_deferred_busy'); - await f.waitForAwakening(message.id); - assert.equal(f.messages.getById(group.messages[0]!.id)?.queueCustody?.awakenedInvocationIdByCatId, undefined); - assert.equal(f.records.size, 1); + const wrongTarget = await ensurePersistedCarrierOwnedAndScheduled( + { messages: f.messages, queue: f.queue, progress }, + { ...input, targetCatId: 'opus', sourceMessageId: admitted.message.id, expectedEntryId: admitted.entryId }, + ); + assert.equal(wrongTarget.state, 'conflict'); }); -test('fanout recovery derives the target carrier from custody and retains the other target ownership', async (t) => { - const f = fixture(t); - const source = f.messages.append({ - userId: input.ownerUserId, - threadId: input.threadId, - catId: createCatId('opus'), - content: 'shared fanout source', - mentions: [createCatId('codex'), createCatId('opus')], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); - const entries = ['codex', 'opus'].map((catId) => { - const result = f.queue.enqueue({ - threadId: input.threadId, - userId: input.ownerUserId, - ownerAuthProvenance: 'strict', - content: source.content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - a2aTriggerMessageId: source.id, - }); - assert.ok(result.entry); - f.queue.backfillMessageId(input.threadId, input.ownerUserId, result.entry.id, source.id); - return f.queue.getEntrySnapshot(input.threadId, input.ownerUserId, result.entry.id)!; - }); - f.messages.initializeQueueCustody(source.id, createInitialCrossThreadQueuedMessageCustody(source.id, entries)); - f.queue.rollbackEnqueue(input.threadId, input.ownerUserId, entries[0]!.id); - f.tracker.start(input.threadId, 'codex'); +test('claimed carrier is recognized as already processing without another progress request', async () => { + const f = fixture(); + const admitted = await f.delivery.deliver(input); + assert.ok(admitted.message && 'entryId' in admitted); + const claimed = await f.queue.claimExactSteerEntryDurable( + input.threadId, + input.ownerUserId, + admitted.entryId, + input.targetCatId, + ); + assert.equal(claimed.outcome, 'claimed'); + let progressed = false; const result = await ensurePersistedCarrierOwnedAndScheduled( { messages: f.messages, queue: f.queue, - progress: (entry, catId) => f.processor.progressOwnedCarrier(entry, catId), + progress: async () => { + progressed = true; + return 'started'; + }, }, - { ...input, sourceMessageId: source.id }, + { ...input, sourceMessageId: admitted.message.id, expectedEntryId: admitted.entryId }, ); - assert.equal(result.state, 'owned_deferred_busy'); - assert.equal('entryId' in result && result.entryId, entries[0]!.id); - assert.equal(f.queue.getEntrySnapshot(input.threadId, input.ownerUserId, entries[0]!.id)?.sourceCategory, 'a2a'); - assert.deepEqual(f.queue.getEntrySnapshot(input.threadId, input.ownerUserId, entries[1]!.id), entries[1]); - assert.equal(f.records.size, 0); + assert.deepEqual(result, { state: 'already_processing', entryId: admitted.entryId }); + assert.equal(progressed, false); }); diff --git a/packages/api/test/persistence-fault-drill.test.js b/packages/api/test/persistence-fault-drill.test.js index c6ff685e67..8632626b98 100644 --- a/packages/api/test/persistence-fault-drill.test.js +++ b/packages/api/test/persistence-fault-drill.test.js @@ -4,18 +4,19 @@ * Goal: * - Simulate persistence outage during message processing. * - Verify invocation is marked failed with explicit user-facing signal. - * - Verify recovery path succeeds after retry. + * - Verify the failed attempt remains terminal when persistence fails. */ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import Fastify from 'fastify'; +import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; +import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; import { InvocationRecordStore } from '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; -import { invocationsRoutes } from '../dist/routes/invocations.js'; import { messagesRoutes } from '../dist/routes/messages.js'; function createMockSocketManager() { @@ -55,7 +56,14 @@ function createFaultDrillRouter(modeRef) { intent: { intent: 'execute', explicit: false, promptTags: [] }, }; }, + async resolveExplicitTargets(targetCats) { + return [...targetCats]; + }, + async resolveConversationTargetsAtAdmission(targetCats) { + return targetCats.length > 0 ? [...targetCats] : ['opus']; + }, async *routeExecution(_userId, _content, _threadId, _userMessageId, _targets, _intent, opts = {}) { + modeRef.invocationId = opts.parentInvocationId; if (modeRef.failPersistence) { if (opts.persistenceContext) { opts.persistenceContext.failed = true; @@ -86,9 +94,19 @@ async function setupScenario() { const threadStore = new ThreadStore(); const invocationRecordStore = new InvocationRecordStore(); const invocationTracker = new InvocationTracker(); + const invocationQueue = new InvocationQueue(); const socketManager = createMockSocketManager(); const modeRef = { failPersistence: true }; const router = createFaultDrillRouter(modeRef); + const queueProcessor = new QueueProcessor({ + queue: invocationQueue, + invocationTracker, + invocationRecordStore, + socketManager, + messageStore, + router, + log: { info() {}, warn() {}, error() {} }, + }); const threadId = threadStore.create('user-1', 'fault drill thread').id; const app = Fastify(); @@ -100,13 +118,8 @@ async function setupScenario() { threadStore, invocationTracker, invocationRecordStore, - }); - await app.register(invocationsRoutes, { - invocationRecordStore, - messageStore, - socketManager, - router, - invocationTracker, + invocationQueue, + queueProcessor, }); await app.ready(); @@ -122,7 +135,7 @@ async function setupScenario() { describe('Persistence fault drills', () => { it('marks invocation failed and emits explicit error when persistence fails mid-flight', async () => { - const { app, threadId, socketManager, invocationRecordStore, router } = await setupScenario(); + const { app, modeRef, threadId, socketManager, invocationRecordStore, router } = await setupScenario(); const res = await app.inject({ method: 'POST', @@ -134,15 +147,18 @@ describe('Persistence fault drills', () => { }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 202); const body = res.json(); - assert.equal(body.status, 'processing'); - assert.ok(body.invocationId); + assert.equal(body.status, 'queued'); + assert.ok(body.entryId); - const failedReady = await waitFor(() => invocationRecordStore.get(body.invocationId)?.status === 'failed'); + const failedReady = await waitFor(() => + Boolean(modeRef.invocationId && invocationRecordStore.get(modeRef.invocationId)?.status === 'failed'), + ); assert.equal(failedReady, true, 'invocation should become failed within wait window'); - const record = invocationRecordStore.get(body.invocationId); + const record = invocationRecordStore.get(modeRef.invocationId); + assert.ok(record); assert.equal(record.status, 'failed'); assert.ok( String(record.error).includes('Message delivered but persistence failed'), @@ -157,39 +173,4 @@ describe('Persistence fault drills', () => { await app.close(); }); - - it('supports recovery: failed invocation can retry to succeeded after persistence recovers', async () => { - const { app, threadId, modeRef, router, invocationRecordStore } = await setupScenario(); - - const createRes = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1' }, - payload: { - content: '@布偶猫 retry drill', - threadId, - }, - }); - - const { invocationId } = createRes.json(); - const firstFailedReady = await waitFor(() => invocationRecordStore.get(invocationId)?.status === 'failed'); - assert.equal(firstFailedReady, true, 'initial invocation should fail before retry'); - assert.equal(invocationRecordStore.get(invocationId).status, 'failed'); - - // Simulate Redis/API recovery before retry. - modeRef.failPersistence = false; - - const retryRes = await app.inject({ - method: 'POST', - url: `/api/invocations/${invocationId}/retry`, - }); - assert.equal(retryRes.statusCode, 202); - - const retrySucceeded = await waitFor(() => invocationRecordStore.get(invocationId)?.status === 'succeeded'); - assert.equal(retrySucceeded, true, 'retry should eventually become succeeded'); - assert.equal(invocationRecordStore.get(invocationId).status, 'succeeded'); - assert.equal(router.getAckCalls().length, 1, 'cursor ack should occur after recovered retry'); - - await app.close(); - }); }); diff --git a/packages/api/test/person-memory-proposal-status-context.test.js b/packages/api/test/person-memory-proposal-status-context.test.js index 36d7c93dca..ad9d294eaf 100644 --- a/packages/api/test/person-memory-proposal-status-context.test.js +++ b/packages/api/test/person-memory-proposal-status-context.test.js @@ -365,11 +365,10 @@ describe('F276 live proposal status invocation context', () => { }); } - it('routeSerial prioritizes the active A2A trigger over the original route message', async () => { - const [{ PersonMemoryProposalStatusContextResolver }, { routeSerial }] = await Promise.all([ - import('../dist/domains/memory/people/PersonMemoryProposalStatusContextResolver.js'), - import('../dist/domains/cats/services/agents/routing/route-serial.js'), - ]); + it('prioritizes the active A2A trigger over the original route message', async () => { + const { PersonMemoryProposalStatusContextResolver } = await import( + '../dist/domains/memory/people/PersonMemoryProposalStatusContextResolver.js' + ); const requestedCandidateId = 'person_candidate_a2a_oldest'; const candidateIds = [ requestedCandidateId, @@ -388,23 +387,10 @@ describe('F276 live proposal status invocation context', () => { }, messageStore, ); - const opusService = createCapturingService('opus', '@缅因猫\n请核对黄挺现在的真实状态。'); - const codexService = createCapturingService('codex'); - const deps = createRouteDeps(opusService, messageStore, resolver); - deps.services.codex = codexService; - - for await (const _message of routeSerial(deps, ['opus'], '先查看这些人物记忆卡。', 'owner-1', 'thread-f276', { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - contextHistory: '[对话历史]\n黄挺历史卡片状态:pending_approval(旧快照)', - })) { - // drain - } - - assert.equal(codexService.calls.length, 1); - assert.match(codexService.calls[0], /黄挺历史卡片状态:pending_approval(旧快照)/); - assert.match(codexService.calls[0], /person_candidate_a2a_oldest.*liveStatus=withdrawn/); - assert.match(codexService.calls[0], /历史卡片状态不得作为当前状态/); + const context = await resolver.resolve('owner-1', 'thread-f276', '@缅因猫\n请核对黄挺现在的真实状态。'); + + assert.match(context, /person_candidate_a2a_oldest.*liveStatus=withdrawn/); + assert.doesNotMatch(context, /person_candidate_a2a_recent_1.*liveStatus=/); + assert.match(context, /历史卡片状态不得作为当前状态/); }); }); diff --git a/packages/api/test/personal-chrome-title-refresh-routes.test.js b/packages/api/test/personal-chrome-title-refresh-routes.test.js index c99431955a..b76c9549ee 100644 --- a/packages/api/test/personal-chrome-title-refresh-routes.test.js +++ b/packages/api/test/personal-chrome-title-refresh-routes.test.js @@ -5,8 +5,14 @@ import { registerPersonalChromePluginRoutes } from '../dist/routes/personal-chro test('only an explicit local-owner POST refreshes names; state reads and rejected callers cannot trigger it', async (t) => { const app = Fastify(); - t.after(() => app.close()); - const owner = process.env.DEFAULT_OWNER_USER_ID ?? 'owner-user'; + const previousOwner = process.env.DEFAULT_OWNER_USER_ID; + process.env.DEFAULT_OWNER_USER_ID = 'owner-user'; + t.after(async () => { + if (previousOwner === undefined) delete process.env.DEFAULT_OWNER_USER_ID; + else process.env.DEFAULT_OWNER_USER_ID = previousOwner; + await app.close(); + }); + const owner = 'owner-user'; const state = { titleSync: { status: 'synced', updatedCount: 1, requestedCount: 2 } }; let refreshes = 0; app.addHook('preHandler', async (request) => { diff --git a/packages/api/test/pingpong-reset.test.js b/packages/api/test/pingpong-reset.test.js deleted file mode 100644 index db86e992c2..0000000000 --- a/packages/api/test/pingpong-reset.test.js +++ /dev/null @@ -1,181 +0,0 @@ -/** - * F167 L1 AC-A3: streak reset triggers. - * - * (A) user message POST → resetStreak hook clears streakPair on active worklist. - * (B) third-cat injection → pushToWorklist internal samePair check auto-resets - * (覆盖在 worklist-registry-streak.test.js,此处只做 sanity check). - */ - -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); -const { registerWorklist, unregisterWorklist, pushToWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' -); - -function buildDeps(overrides = {}) { - const invocationQueue = new InvocationQueue(); - return { - registry: new InvocationRegistry(), - messageStore: { - append: mock.fn(async (msg) => ({ id: `msg-${Date.now()}`, ...msg })), - getByThread: mock.fn(async () => []), - getByThreadBefore: mock.fn(async () => []), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - router: { - resolveTargetsAndIntent: mock.fn(async () => ({ targetCats: ['opus'], intent: { intent: 'execute' } })), - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - route: mock.fn(async function* () { - yield { type: 'done' }; - }), - }, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - tryStartThread: mock.fn(() => new AbortController()), - tryStartThreadAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - cancel: mock.fn(() => ({ cancelled: true, catIds: ['opus'] })), - isDeleting: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ outcome: 'created', invocationId: 'inv-stub' })), - update: mock.fn(async () => {}), - }, - invocationQueue, - threadStore: { - get: mock.fn(async () => ({ id: 'thread-pp-reset', title: 'Reset Thread', createdBy: 'user-1' })), - updateTitle: mock.fn(async () => {}), - }, - ...overrides, - }; -} - -describe('F167 L1 AC-A3: ping-pong reset triggers', () => { - let app; - let deps; - - beforeEach(async () => { - deps = buildDeps(); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('POST /api/messages → resetStreak fires on the active worklist', async () => { - const threadId = 'thread-pp-reset'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - // Preload streak to warn level (2) - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); - assert.equal(entry.streakPair?.count, 2, 'precondition: streak=2 before user msg'); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '新的一轮,重新开始', - threadId, - }, - }); - assert.ok(res.statusCode < 500, `expected non-500, got ${res.statusCode}: ${res.body}`); - - const current = getWorklist(threadId); - assert.ok(current, 'worklist must still exist after user msg'); - assert.ok( - !current.streakPair || current.streakPair.count === 0, - `streakPair must be cleared after user msg; got ${JSON.stringify(current.streakPair)}`, - ); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - it('POST /api/messages → resetStreak clears parentInvocationId-keyed worklist (P1-2)', async () => { - const threadId = 'thread-pp-reset-inv'; - const parentInvocationId = 'inv-reset-1'; - const entry = registerWorklist(threadId, ['opus'], 10, parentInvocationId); - try { - // Preload streak to warn level via parentInvocationId-keyed push - pushToWorklist(threadId, ['codex'], 'opus', parentInvocationId); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex', parentInvocationId); - assert.equal(entry.streakPair?.count, 2, 'precondition: streak=2 before user msg'); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '新的一轮(parentInvocationId 场景)', - threadId, - }, - }); - assert.ok(res.statusCode < 500, `expected non-500, got ${res.statusCode}: ${res.body}`); - - const current = getWorklist(threadId, parentInvocationId); - assert.ok(current, 'parent-keyed worklist must still exist after user msg'); - assert.ok( - !current.streakPair || current.streakPair.count === 0, - `streakPair on parent-keyed worklist must be cleared; got ${JSON.stringify(current.streakPair)}`, - ); - } finally { - unregisterWorklist(threadId, entry, parentInvocationId); - } - }); - - it('POST /api/messages to a thread without active worklist → no-op (no crash)', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { - content: '没有 worklist 时发消息', - threadId: 'thread-pp-reset', - }, - }); - assert.ok(res.statusCode < 500, `expected non-500, got ${res.statusCode}: ${res.body}`); - }); - - it('third-cat injection via pushToWorklist auto-resets streak (sanity: no route-layer hook required)', () => { - const threadId = 'thread-pp-third-cat'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - // Build up opus↔codex streak - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); - assert.equal(entry.streakPair?.count, 2); - - // Now gemini (third cat) pushes opus — different pair → streak resets to 1 - entry.list.push('gemini'); - entry.executedIndex = 3; - const result = pushToWorklist(threadId, ['opus'], 'gemini'); - assert.ok(!result.warnPingPong, 'third-cat push must NOT carry warn'); - assert.equal(entry.streakPair?.count, 1, 'streak reset to 1 for new pair'); - } finally { - unregisterWorklist(threadId, entry); - } - }); -}); diff --git a/packages/api/test/plugin-m0d-fixture-setup.js b/packages/api/test/plugin-m0d-fixture-setup.js index debde50e1c..252ca71f4d 100644 --- a/packages/api/test/plugin-m0d-fixture-setup.js +++ b/packages/api/test/plugin-m0d-fixture-setup.js @@ -67,8 +67,8 @@ async function seedMessage(owner, behaviorCase, fixtureMessage, fixtureOwnerId, const revision = fixtureMessage.revision ?? 1; const { elements, appendOps } = fixtureElements(fixtureMessage, revision); const stored = owner.messageStore.append({ + from: { kind: 'plugin', instanceId: pluginInstanceId }, userId: 'fixture-user', - catId: null, content: 'fixture plugin message', mentions: [], timestamp: Date.now(), @@ -255,8 +255,8 @@ export async function prepareFixture(owner, behaviorCase, retentionCount) { threadIds.add(message.threadId); if (!messageIdMap.has(message.messageId)) { const stored = owner.messageStore.append({ + from: { kind: 'user', userId: 'fixture-user' }, userId: 'fixture-user', - catId: null, content: message.text ?? 'fixture host message', mentions: [], timestamp: Date.now(), diff --git a/packages/api/test/plugin-messaging-append.test.js b/packages/api/test/plugin-messaging-append.test.js index 6843dc0bee..44eb086da2 100644 --- a/packages/api/test/plugin-messaging-append.test.js +++ b/packages/api/test/plugin-messaging-append.test.js @@ -5,6 +5,7 @@ */ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; let memory; let handlesMod; @@ -297,14 +298,16 @@ describe('AppendService — validation and lock guards (§4d)', () => { }); test('a raw non-plugin message id is not an issued message handle', async () => { - const user = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'human words', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + const user = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'human words', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); await expectCode(service.appendElements(CTX, appendInput(user.id)), 'NOT_FOUND'); }); diff --git a/packages/api/test/plugin-messaging-event-stream-snapshot.test.js b/packages/api/test/plugin-messaging-event-stream-snapshot.test.js index d5c2fc825e..b8ac86cd6e 100644 --- a/packages/api/test/plugin-messaging-event-stream-snapshot.test.js +++ b/packages/api/test/plugin-messaging-event-stream-snapshot.test.js @@ -6,6 +6,7 @@ */ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; let memory; let handlesMod; @@ -95,28 +96,30 @@ describe('EventStreamService — stale + snapshot (INV-9)', () => { test('invalid historical payload fails before snapshot entitlement or cursor movement', async () => { const handleId = await issueHandle(); const { subscriptionId } = await stream.subscribe(CTX, handleId); - messageStore.append({ - userId: 'user-1', - catId: null, - content: '[rich_block:el-invalid]', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - extra: { - pluginMessage: { - instanceId: CTX.pluginInstanceId, - revision: 1, - provenance: { - origin: { kind: 'plugin', instanceId: CTX.pluginInstanceId }, - epistemicStatus: 'inference', + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '[rich_block:el-invalid]', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + extra: { + pluginMessage: { + instanceId: CTX.pluginInstanceId, + revision: 1, + provenance: { + origin: { kind: 'plugin', instanceId: CTX.pluginInstanceId }, + epistemicStatus: 'inference', + }, + elements: [{ elementId: 'el-invalid', kind: 'rich_block', payload: { value: Number.NaN } }], + outputRevision: 1, + outputSequence: 1, + appendOps: [], }, - elements: [{ elementId: 'el-invalid', kind: 'rich_block', payload: { value: Number.NaN } }], - outputRevision: 1, - outputSequence: 1, - appendOps: [], }, - }, - }); + }), + ); const before = await cursors.get(CTX.pluginInstanceId, subscriptionId); await expectCode(stream.snapshotPage(CTX, { subscriptionId, maxItems: 1 }), 'VALIDATION'); @@ -180,14 +183,16 @@ describe('EventStreamService — stale + snapshot (INV-9)', () => { const { subscriptionId } = await stream.subscribe(CTX, handleId); // Insert host-only messages that have no corresponding plugin event. for (let index = 0; index < 205; index += 1) { - messageStore.append({ - userId: 'user-1', - catId: null, - content: `host message ${index}`, - mentions: [], - timestamp: Date.now() + index, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: `host message ${index}`, + mentions: [], + timestamp: Date.now() + index, + threadId: 'thread-1', + }), + ); } // Send one plugin message (tracked by the event log). await sendService.send(CTX, { @@ -320,16 +325,20 @@ describe('EventStreamService — stale + snapshot (INV-9)', () => { const base = { catId: null, mentions: [], timestamp: Date.now(), threadId: 'thread-1' }; // All of these are host messages (no pluginMessage) — none belong in the // plugin snapshot regardless of their visibility category. - messageStore.append({ ...base, userId: 'user-1', content: 'public user content' }); - messageStore.append({ ...base, userId: 'system', content: 'system prompt' }); - messageStore.append({ ...base, userId: 'scheduler', content: 'scheduler prompt' }); - messageStore.append({ ...base, userId: 'user-1', content: 'briefing prompt', origin: 'briefing' }); - messageStore.append({ - ...base, - userId: 'user-1', - content: 'hidden trigger', - extra: { scheduler: { hiddenTrigger: true } }, - }); + messageStore.append(canonicalTestMessageInput({ ...base, userId: 'user-1', content: 'public user content' })); + messageStore.append(canonicalTestMessageInput({ ...base, userId: 'system', content: 'system prompt' })); + messageStore.append(canonicalTestMessageInput({ ...base, userId: 'scheduler', content: 'scheduler prompt' })); + messageStore.append( + canonicalTestMessageInput({ ...base, userId: 'user-1', content: 'briefing prompt', origin: 'briefing' }), + ); + messageStore.append( + canonicalTestMessageInput({ + ...base, + userId: 'user-1', + content: 'hidden trigger', + extra: { scheduler: { hiddenTrigger: true } }, + }), + ); // Send one plugin message to verify plugin content IS included. await sendService.send(CTX, { address: { kind: 'thread_handle', handle: handleId }, @@ -372,14 +381,16 @@ describe('EventStreamService — stale + snapshot (INV-9)', () => { if (scanCount === 1) { // Inject a host message after the scan completes but before // the fence check. This host message has no plugin event. - target.append({ - userId: 'user-1', - catId: null, - content: 'host message injected during scan', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + target.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'host message injected during scan', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); } return result; }; diff --git a/packages/api/test/plugin-messaging-redis-stores.test.js b/packages/api/test/plugin-messaging-redis-stores.test.js index c863529df9..5802f70af1 100644 --- a/packages/api/test/plugin-messaging-redis-stores.test.js +++ b/packages/api/test/plugin-messaging-redis-stores.test.js @@ -7,6 +7,7 @@ */ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, redisIsolationSkipReason } from './helpers/redis-test-helpers.js'; const REDIS_URL = process.env.REDIS_URL; @@ -353,24 +354,26 @@ describe('Plugin messaging Redis stores', { skip: redisIsolationSkipReason(REDIS describe('RedisMessageStore plugin payload isolation', () => { it('concurrent host and plugin updates preserve payload arrays and host metadata', async () => { const store = new RedisMessageStore(redis); - const message = await store.append({ - userId: nextId('user'), - catId: null, - content: 'plugin message', - mentions: [], - timestamp: Date.now(), - threadId: nextId('thread'), - extra: { - rich: { v: 1, blocks: [] }, - pluginMessage: { - instanceId: 'inst-a', - revision: 1, - provenance: { origin: { kind: 'plugin', instanceId: 'inst-a' }, epistemicStatus: 'inference' }, - elements: [{ elementId: 'el-1', kind: 'text', payload: { text: 'x' } }], - appendOps: [], + const message = await store.append( + canonicalTestMessageInput({ + userId: nextId('user'), + catId: null, + content: 'plugin message', + mentions: [], + timestamp: Date.now(), + threadId: nextId('thread'), + extra: { + rich: { v: 1, blocks: [] }, + pluginMessage: { + instanceId: 'inst-a', + revision: 1, + provenance: { origin: { kind: 'plugin', instanceId: 'inst-a' }, epistemicStatus: 'inference' }, + elements: [{ elementId: 'el-1', kind: 'text', payload: { text: 'x' } }], + appendOps: [], + }, }, - }, - }); + }), + ); const pluginMessage = { ...message.extra.pluginMessage, revision: 2, @@ -396,23 +399,25 @@ describe('Plugin messaging Redis stores', { skip: redisIsolationSkipReason(REDIS it('hard delete wipes the independent plugin payload', async () => { const store = new RedisMessageStore(redis); - const message = await store.append({ - userId: nextId('user'), - catId: null, - content: 'sensitive plugin message', - mentions: [], - timestamp: Date.now(), - threadId: nextId('thread'), - extra: { - pluginMessage: { - instanceId: 'inst-a', - revision: 1, - provenance: { origin: { kind: 'plugin', instanceId: 'inst-a' }, epistemicStatus: 'inference' }, - elements: [{ elementId: 'el-1', kind: 'text', payload: { text: 'secret' } }], - appendOps: [], + const message = await store.append( + canonicalTestMessageInput({ + userId: nextId('user'), + catId: null, + content: 'sensitive plugin message', + mentions: [], + timestamp: Date.now(), + threadId: nextId('thread'), + extra: { + pluginMessage: { + instanceId: 'inst-a', + revision: 1, + provenance: { origin: { kind: 'plugin', instanceId: 'inst-a' }, epistemicStatus: 'inference' }, + elements: [{ elementId: 'el-1', kind: 'text', payload: { text: 'secret' } }], + appendOps: [], + }, }, - }, - }); + }), + ); const tombstone = await store.hardDelete(message.id, 'user-1'); assert.equal(tombstone._tombstone, true); diff --git a/packages/api/test/plugin-messaging-replay-retention.test.js b/packages/api/test/plugin-messaging-replay-retention.test.js index cf86fa3d8a..b32fe631ac 100644 --- a/packages/api/test/plugin-messaging-replay-retention.test.js +++ b/packages/api/test/plugin-messaging-replay-retention.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; - +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { createMessagingOwner } from './plugin-m0d-messaging-owner.js'; const THREAD_ID = 'thread-replay-retention'; @@ -54,14 +54,17 @@ async function seededOwner() { const owner = await createMessagingOwner(500); await seedSubscription(owner, 'plugin-a', 'subscription-a', 'handle-a'); await seedSubscription(owner, 'plugin-b', 'subscription-b', 'handle-b'); - owner.messageStore.append({ - userId: 'fixture-user', - catId: null, - content: 'canonical message must survive replay cleanup', - mentions: [], - timestamp: Date.now(), - threadId: THREAD_ID, - }); + // F117: append requires MessageFrom sender identity + owner.messageStore.append( + canonicalTestMessageInput({ + userId: 'fixture-user', + catId: null, + content: 'canonical message must survive replay cleanup', + mentions: [], + timestamp: Date.now(), + threadId: THREAD_ID, + }), + ); await owner.events.append(THREAD_ID, 'event-key-1', eventInput(), 500); return owner; } diff --git a/packages/api/test/plugin-messaging-rollback-evidence.test.js b/packages/api/test/plugin-messaging-rollback-evidence.test.js index a062b40ee0..79646586d4 100644 --- a/packages/api/test/plugin-messaging-rollback-evidence.test.js +++ b/packages/api/test/plugin-messaging-rollback-evidence.test.js @@ -7,6 +7,7 @@ */ import assert from 'node:assert/strict'; import { before, beforeEach, describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; let MessageStore; let envelope; @@ -39,36 +40,40 @@ describe('Rollback evidence — additive plugin fields are inert under old-binar * through host-only paths (old binary without K-1 awareness). */ function seedPluginMessage(overrides = {}) { - return messageStore.append({ - userId: 'user-1', - catId: null, - content: 'host-visible text content', - mentions: ['opus'], - timestamp: Date.now(), - threadId: 'thread-1', - extra: { - pluginMessage: { - instanceId: 'inst-a', - revision: 1, - provenance: { origin: { kind: 'plugin', instanceId: 'inst-a' }, epistemicStatus: 'inference' }, - elements: [{ elementId: 'el-1', kind: 'text', payload: { text: 'plugin text' } }], - appendOps: [], + return messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'host-visible text content', + mentions: ['opus'], + timestamp: Date.now(), + threadId: 'thread-1', + extra: { + pluginMessage: { + instanceId: 'inst-a', + revision: 1, + provenance: { origin: { kind: 'plugin', instanceId: 'inst-a' }, epistemicStatus: 'inference' }, + elements: [{ elementId: 'el-1', kind: 'text', payload: { text: 'plugin text' } }], + appendOps: [], + }, }, - }, - ...overrides, - }); + ...overrides, + }), + ); } test('host pagination (getByThreadAfter) returns messages with additive plugin fields', () => { seedPluginMessage(); - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'normal host message', - mentions: [], - timestamp: Date.now() + 1, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'normal host message', + mentions: [], + timestamp: Date.now() + 1, + threadId: 'thread-1', + }), + ); const all = messageStore.getByThreadAfter('thread-1'); assert.equal(all.length, 2, 'both plugin-bearing and host messages returned'); assert.ok(all[0].extra?.pluginMessage, 'plugin field preserved in store'); @@ -77,14 +82,16 @@ describe('Rollback evidence — additive plugin fields are inert under old-binar test('getBefore pagination works with plugin-bearing messages', () => { seedPluginMessage({ timestamp: 1_800_000_000_000 }); - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'later message', - mentions: [], - timestamp: 1_800_000_000_001, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'later message', + mentions: [], + timestamp: 1_800_000_000_001, + threadId: 'thread-1', + }), + ); const page = messageStore.getBefore(1_800_000_000_002, 10); assert.equal(page.length, 2); }); @@ -124,7 +131,7 @@ describe('Rollback evidence — additive plugin fields are inert under old-binar assert.equal(env.payload.elements[0].payload.text, 'plugin text'); }); - test('rollback scenario: stripping pluginMessage awareness falls back to host content', () => { + test('rollback scenario: stripping pluginMessage awareness keeps canonical sender and falls back to host content', () => { const msg = seedPluginMessage(); // Simulate old-binary: delete the pluginMessage from extra before projection. // This is what happens when old code reads the Redis extra JSON and doesn't @@ -133,7 +140,7 @@ describe('Rollback evidence — additive plugin fields are inert under old-binar delete rollbackMsg.extra.pluginMessage; const env = envelope.projectEnvelope(rollbackMsg); assert.ok(env, 'still projects via host-message fallback'); - assert.deepEqual(env.actor, { kind: 'user', id: 'user-1' }); + assert.deepEqual(env.actor, { kind: 'plugin', id: 'inst-a' }); assert.equal(env.payload.elements[0].payload.text, 'host-visible text content'); assert.deepEqual(env.payload.provenance.origin, { kind: 'host' }); }); @@ -170,14 +177,16 @@ describe('No-plugin dormancy — K-1 services with zero plugin handles retain ho assert.ok(sendService); assert.ok(stream); // Host message operations remain unaffected. - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'host message', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'host message', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); const messages = messageStore.getByThreadAfter('thread-1'); assert.equal(messages.length, 1); assert.equal(messages[0].content, 'host message'); @@ -187,14 +196,16 @@ describe('No-plugin dormancy — K-1 services with zero plugin handles retain ho test('host message pagination is independent of plugin event log state', async () => { const events = new memory.MemoryEventLogStore(); // Even with events in the plugin log, host pagination is unaffected. - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'cat reply', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'cat reply', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-1', + }), + ); const [msg] = messageStore.getByThreadAfter('thread-1'); assert.equal(msg.content, 'cat reply'); // Plugin event head is 0 — no interaction with host message. @@ -203,22 +214,26 @@ describe('No-plugin dormancy — K-1 services with zero plugin handles retain ho }); test('host projectEnvelope for user/cat messages is unaffected by K-1 code presence', () => { - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'user says hi', - mentions: [], - timestamp: 1_800_000_000_000, - threadId: 'thread-1', - }); - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'cat replies', - mentions: [], - timestamp: 1_800_000_000_001, - threadId: 'thread-1', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'user says hi', + mentions: [], + timestamp: 1_800_000_000_000, + threadId: 'thread-1', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'cat replies', + mentions: [], + timestamp: 1_800_000_000_001, + threadId: 'thread-1', + }), + ); const msgs = messageStore.getByThreadAfter('thread-1'); const envs = msgs.map((m) => envelope.projectEnvelope(m)).filter(Boolean); assert.equal(envs.length, 2); diff --git a/packages/api/test/plugin-messaging-send-adversarial.test.js b/packages/api/test/plugin-messaging-send-adversarial.test.js index 8da325ccb2..a5277344c2 100644 --- a/packages/api/test/plugin-messaging-send-adversarial.test.js +++ b/packages/api/test/plugin-messaging-send-adversarial.test.js @@ -6,6 +6,7 @@ */ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; let memory; let handlesMod; @@ -167,14 +168,16 @@ describe('SendService — §4a adversarial paths', () => { }); test('replyTo referencing another thread → VALIDATION (no cross-thread preview leak)', async () => { - const foreign = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'secret in another thread', - mentions: [], - timestamp: Date.now(), - threadId: 'thread-OTHER', - }); + const foreign = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'secret in another thread', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-OTHER', + }), + ); const handleId = await issueHandle(); await expectCode(service.send(CTX, draftFor(handleId, { replyTo: foreign.id })), 'VALIDATION'); }); @@ -191,15 +194,35 @@ describe('SendService — §4a adversarial paths', () => { const handleId = await issueHandle(); const base = { userId: 'user-1', catId: null, mentions: [], timestamp: Date.now(), threadId: 'thread-1' }; const parents = [ - messageStore.append({ - ...base, - content: 'secret', - visibility: 'whisper', - whisperTo: ['opus'], - }), - messageStore.append({ ...base, content: 'queued', deliveryStatus: 'queued' }), - messageStore.append({ ...base, userId: 'system', content: 'system prompt' }), - messageStore.append({ ...base, content: 'briefing', origin: 'briefing' }), + messageStore.append( + canonicalTestMessageInput({ + ...base, + content: 'secret', + visibility: 'whisper', + whisperTo: ['opus'], + }), + ), + messageStore.append( + canonicalTestMessageInput({ + ...base, + content: 'queued', + deliveryStatus: 'queued', + }), + ), + messageStore.append( + canonicalTestMessageInput({ + ...base, + userId: 'system', + content: 'system prompt', + }), + ), + messageStore.append( + canonicalTestMessageInput({ + ...base, + content: 'briefing', + origin: 'briefing', + }), + ), ]; for (const [index, parent] of parents.entries()) { // eslint-disable-next-line no-await-in-loop diff --git a/packages/api/test/podcast-context-injection.test.js b/packages/api/test/podcast-context-injection.test.js index f7c10849dd..9dd317aa18 100644 --- a/packages/api/test/podcast-context-injection.test.js +++ b/packages/api/test/podcast-context-injection.test.js @@ -18,82 +18,32 @@ const VALID_PODCAST_JSON = JSON.stringify({ totalDuration: 20, }); -/** Build instrumented fake deps that capture the prompt sent to routeExecution. */ +/** Build instrumented fake deps that capture the private Queue prompt. */ function buildCapturingDeps(callLog) { + let completionHook; return { - messageStore: { - append(msg) { - callLog.push({ op: 'append', content: msg.content, threadId: msg.threadId }); - return { id: 'msg-001', ...msg }; + invocationQueue: { + async enqueueDurable(input) { + callLog.push({ op: 'enqueue', content: input.content, input }); + return { outcome: 'enqueued', entry: { id: 'entry-context', status: 'queued' } }; }, - getByThread(threadId, limit) { - callLog.push({ op: 'getByThread', threadId, limit }); - // Return simulated study thread messages - return [ - { - id: 'msg-study-1', - threadId, - userId: 'user-1', - catId: null, - content: '我觉得这篇文章的核心观点是分布式系统中的 CAP 定理权衡', - mentions: [], - timestamp: 1000, - }, - { - id: 'msg-study-2', - threadId, - userId: 'user-1', - catId: 'opus', - content: '确实,作者特别强调了在实际生产中,可用性通常比一致性更重要', - mentions: [], - timestamp: 2000, - }, - { - id: 'msg-study-3', - threadId, - userId: 'user-1', - catId: null, - content: '但是金融场景下一致性不能妥协', - mentions: [], - timestamp: 3000, - }, - ]; + getEntrySnapshot() { + return null; }, - }, - router: { - async *routeExecution(_userId, message, _threadId, _userMessageId, _targetCats, _intent, _options) { - callLog.push({ - op: 'routeExecution', - promptReceived: message, - }); - yield { type: 'text', content: VALID_PODCAST_JSON }; + removeEntrySnapshotIfUnchanged() { + return false; }, }, - invocationRecordStore: { - create(_input) { - callLog.push({ op: 'create' }); - return { outcome: 'created', invocationId: 'inv-001' }; - }, - update(_id, patch) { - callLog.push({ op: 'update', ...patch }); - return {}; - }, - }, - invocationTracker: { - acquireExecutionAdmission() { - return { release() {} }; - }, - start() { - return new AbortController(); + queueProcessor: { + registerEntryCompleteHook(_entryId, hook) { + completionHook = hook; }, - startAll() { - return new AbortController(); + unregisterEntryCompleteHook() { + completionHook = undefined; }, - tryStartThreadAll() { - return new AbortController(); + async requestDrain() { + completionHook?.('entry-context', 'succeeded', VALID_PODCAST_JSON); }, - complete() {}, - completeAll() {}, }, }; } @@ -116,9 +66,11 @@ describe('F091 Phase 8: threadContext injection into podcast prompt', () => { await generateScriptViaThread(request, 'thread-ctx', deps); - const appendCall = callLog.find((c) => c.op === 'append'); - assert.ok(appendCall, 'must append prompt message'); - const prompt = appendCall.content; + const enqueueCall = callLog.find((c) => c.op === 'enqueue'); + assert.ok(enqueueCall, 'must enqueue the private prompt'); + assert.equal(enqueueCall.input.kind, 'private_input'); + assert.deepEqual(enqueueCall.input.from, { kind: 'system', service: 'podcast-generator' }); + const prompt = enqueueCall.content; // threadContext must appear in the prompt assert.ok(prompt.includes('CAP 定理在金融场景下的特殊需求'), 'prompt must include threadContext content'); diff --git a/packages/api/test/podcast-thread-generation.test.js b/packages/api/test/podcast-thread-generation.test.js index 5faac8bc11..828edd4ead 100644 --- a/packages/api/test/podcast-thread-generation.test.js +++ b/packages/api/test/podcast-thread-generation.test.js @@ -1,58 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -/** - * F091 Phase 6: Thread-based podcast generation tests. - * - * Tests the REAL production function `generateScriptViaThread` with - * instrumented deps to verify invocation lifecycle correctness. - */ - -describe('F091 Phase 6: PodcastRequest + ThreadInvokeDeps contract', () => { - it('ThreadInvokeDeps type is exported from podcast-generator', async () => { - const mod = await import('../dist/domains/signals/services/podcast-generator.js'); - assert.ok(mod.generatePodcastScript, 'generatePodcastScript should be exported'); - assert.ok(mod.generateScriptViaThread, 'generateScriptViaThread should be exported'); - }); - - it('PodcastRouteOptions interface requires DI deps', async () => { - const mod = await import('../dist/routes/signal-podcast-routes.js'); - assert.ok(mod.signalPodcastRoutes, 'signalPodcastRoutes should be exported'); - }); -}); - -describe('F091 Phase 6: resolveStudyThread logic', () => { - it('AC-P6-1: reuses existing thread from study meta', async () => { - const { StudyMetaService } = await import('../dist/domains/signals/services/study-meta-service.js'); - const svc = new StudyMetaService(); - - const testId = `test-resolve-${Date.now()}`; - const testPath = '/tmp/test-resolve.md'; - - await svc.linkThread(testId, testPath, { - threadId: 'thread-existing', - linkedBy: 'test-user', - }); - - const meta = await svc.readMeta(testId, testPath); - assert.ok(meta.threads.length > 0, 'should have linked thread'); - assert.equal(meta.threads[0].threadId, 'thread-existing'); - }); - - it('AC-P6-2: creates new thread when none exists', async () => { - const { StudyMetaService } = await import('../dist/domains/signals/services/study-meta-service.js'); - const svc = new StudyMetaService(); - - const testId = `test-no-thread-${Date.now()}`; - const testPath = '/tmp/test-no-thread.md'; - - const meta = await svc.readMeta(testId, testPath); - assert.equal(meta.threads.length, 0, 'no threads for new article'); - }); -}); - -// ── Helpers ── - const VALID_PODCAST_JSON = JSON.stringify({ segments: [ { speaker: '宪宪', text: '大家好', durationEstimate: 3 }, @@ -61,65 +9,6 @@ const VALID_PODCAST_JSON = JSON.stringify({ totalDuration: 5, }); -/** Build instrumented fake deps that record every call. */ -function buildFakeDeps(callLog, responseText = VALID_PODCAST_JSON) { - return { - messageStore: { - append(msg) { - callLog.push({ op: 'append', threadId: msg.threadId, catId: msg.catId }); - return { id: 'msg-001', ...msg }; - }, - }, - router: { - async *routeExecution(_userId, _message, threadId, userMessageId, _targetCats, _intent, options) { - callLog.push({ - op: 'routeExecution', - threadId, - userMessageId, - hasSignal: !!options?.signal, - signalAborted: !!options?.signal?.aborted, - }); - yield { type: 'text', content: responseText }; - }, - }, - invocationRecordStore: { - create(input) { - callLog.push({ op: 'create', threadId: input.threadId }); - return { outcome: 'created', invocationId: 'inv-001' }; - }, - update(id, patch) { - callLog.push({ op: 'update', id, ...patch }); - return {}; - }, - }, - invocationTracker: { - acquireExecutionAdmission() { - return { release() {} }; - }, - start(_threadId, _userId, _cats) { - callLog.push({ op: 'tracker.start' }); - return new AbortController(); - }, - startAll() { - return new AbortController(); - }, - tryStartThreadAll() { - return new AbortController(); - }, - complete(_threadId, _controller) { - callLog.push({ op: 'tracker.complete' }); - }, - completeSlot(threadId, catId, controller) { - callLog.push({ op: 'tracker.completeSlot', threadId, catId, controller }); - }, - trackExternalSlot() { - return true; - }, - completeAll() {}, - }, - }; -} - function makeRequest(overrides = {}) { return { articleId: 'art-001', @@ -128,162 +17,128 @@ function makeRequest(overrides = {}) { articleContent: 'Some content.', mode: 'essence', requestedBy: 'test-user', + threadId: 'thread-test', ...overrides, }; } -describe('F091 Phase 6: generateScriptViaThread — real production function', () => { - it('parks direct podcast admission until a manual session seal releases', async () => { - const { generateScriptViaThread } = await import('../dist/domains/signals/services/podcast-generator.js'); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const callLog = []; - const deps = buildFakeDeps(callLog); - const tracker = new InvocationTracker(); - deps.invocationTracker = tracker; - - const sealGuard = tracker.guardSessionSeal('thread-seal-race', 'opus'); - assert.equal(sealGuard.acquired, true); - - const generation = generateScriptViaThread(makeRequest(), 'thread-seal-race', deps); - await new Promise((resolve) => setImmediate(resolve)); - - try { - assert.equal( - callLog.some((call) => call.op === 'append'), - false, - 'podcast message must be written into the replacement session after seal release', - ); - assert.equal( - callLog.some((call) => call.op === 'routeExecution'), - false, - 'podcast execution must wait instead of routing with the seal-rejected controller', - ); - } finally { - sealGuard.release(); - } - - await generation; - const routeCall = callLog.find((call) => call.op === 'routeExecution'); - assert.ok(routeCall, 'podcast execution should resume after the seal releases'); - assert.equal(routeCall.signalAborted, false); - const nextSealGuard = tracker.guardSessionSeal('thread-seal-race', 'opus'); - assert.equal(nextSealGuard.acquired, true, 'podcast completion must release tracker and admission ownership'); - nextSealGuard.release(); - }); - - it('P1-1: backfills userMessageId into invocation record', async () => { - const { generateScriptViaThread } = await import('../dist/domains/signals/services/podcast-generator.js'); - const callLog = []; - const deps = buildFakeDeps(callLog); - - await generateScriptViaThread(makeRequest(), 'thread-test', deps); - - const backfillCall = callLog.find((c) => c.op === 'update' && c.userMessageId !== undefined); - assert.ok(backfillCall, 'must backfill userMessageId into invocation record'); - assert.equal(backfillCall.userMessageId, 'msg-001'); +function buildQueueDeps(callLog, outcome = { status: 'succeeded', responseText: VALID_PODCAST_JSON }) { + let completionHook; + return { + invocationQueue: { + async enqueueDurable(input) { + callLog.push({ op: 'enqueue', input }); + return { + outcome: 'enqueued', + entry: { + version: 1, + id: 'podcast-entry-1', + threadId: input.threadId, + owner: { kind: 'user', userId: input.userId }, + kind: input.kind, + from: input.from, + target: { kind: 'cat', catId: input.targetCats[0] }, + payload: { sourceId: input.idempotencyKey, content: input.content }, + execution: { + intent: input.intent, + ownerAuthProvenance: input.ownerAuthProvenance, + autoExecute: input.autoExecute, + }, + delivery: {}, + status: 'queued', + enqueuedAt: 1, + priority: input.priority, + }, + }; + }, + getEntrySnapshot() { + return null; + }, + removeEntrySnapshotIfUnchanged() { + return false; + }, + }, + queueProcessor: { + registerEntryCompleteHook(entryId, hook) { + callLog.push({ op: 'register', entryId }); + completionHook = hook; + }, + unregisterEntryCompleteHook(entryId) { + callLog.push({ op: 'unregister', entryId }); + completionHook = undefined; + }, + async requestDrain(threadId) { + callLog.push({ op: 'drain', threadId }); + completionHook?.('podcast-entry-1', outcome.status, outcome.responseText); + }, + }, + }; +} - // backfill must happen BEFORE 'running' status - const backfillIdx = callLog.indexOf(backfillCall); - const runningIdx = callLog.findIndex((c) => c.op === 'update' && c.status === 'running'); - assert.ok(runningIdx > backfillIdx, 'backfill must precede running status update'); +describe('F091 / RFC #1356: podcast Queue admission', () => { + it('exports the Queue-backed generator and route', async () => { + const generator = await import('../dist/domains/signals/services/podcast-generator.js'); + const routes = await import('../dist/routes/signal-podcast-routes.js'); + assert.ok(generator.generatePodcastScript); + assert.ok(generator.generateScriptViaThread); + assert.ok(routes.signalPodcastRoutes); }); - it('P1-2: passes controller.signal into routeExecution options', async () => { + it('enqueues one private system input with no History source record', async () => { const { generateScriptViaThread } = await import('../dist/domains/signals/services/podcast-generator.js'); const callLog = []; - const deps = buildFakeDeps(callLog); - - await generateScriptViaThread(makeRequest(), 'thread-signal', deps); - - const routeCall = callLog.find((c) => c.op === 'routeExecution'); - assert.ok(routeCall, 'routeExecution must be called'); - assert.equal(routeCall.hasSignal, true, 'must pass signal to routeExecution'); - assert.equal(routeCall.signalAborted, false, 'signal should not be pre-aborted'); + const deps = buildQueueDeps(callLog); + + await generateScriptViaThread(makeRequest(), 'thread-podcast', deps); + + const admission = callLog.find((call) => call.op === 'enqueue').input; + assert.equal(admission.threadId, 'thread-podcast'); + assert.equal(admission.userId, 'test-user'); + assert.equal(admission.kind, 'private_input'); + assert.deepEqual(admission.from, { kind: 'system', service: 'podcast-generator' }); + assert.equal(admission.messageId, undefined); + assert.deepEqual(admission.targetCats, ['opus']); + assert.equal(admission.autoExecute, true); + assert.deepEqual( + callLog.map((call) => call.op), + ['enqueue', 'register', 'drain'], + ); }); - it('releases a terminal dynamic A2A child with the podcast route controller', async () => { + it('registers completion before draining so a fast provider cannot lose the result', async () => { const { generateScriptViaThread } = await import('../dist/domains/signals/services/podcast-generator.js'); const callLog = []; - const deps = buildFakeDeps(callLog); - let routeController; - deps.router = { - async *routeExecution(_userId, _message, threadId, _messageId, _targetCats, _intent, options) { - routeController = options.invocationController; - assert.equal(options.trackA2ASlot(threadId, 'codex', 'test-user', routeController), true); - yield { type: 'text', catId: 'opus', content: VALID_PODCAST_JSON, timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }, - }; - - await generateScriptViaThread(makeRequest(), 'thread-podcast-child', deps); - - const dynamicCompletions = callLog.filter((call) => call.op === 'tracker.completeSlot' && call.catId === 'codex'); - assert.equal(dynamicCompletions.length, 1); - assert.equal(dynamicCompletions[0].threadId, 'thread-podcast-child'); - assert.equal(dynamicCompletions[0].controller, routeController); + await generateScriptViaThread(makeRequest(), 'thread-order', buildQueueDeps(callLog)); + assert.ok(callLog.findIndex((call) => call.op === 'register') < callLog.findIndex((call) => call.op === 'drain')); }); - it('full lifecycle call sequence is correct', async () => { + it('returns the parsed completion-hook response', async () => { const { generateScriptViaThread } = await import('../dist/domains/signals/services/podcast-generator.js'); - const callLog = []; - const deps = buildFakeDeps(callLog); - - await generateScriptViaThread(makeRequest(), 'thread-seq', deps); - - const ops = callLog.map((c) => c.op); - assert.deepEqual(ops, [ - 'append', // ① post message - 'create', // ② create invocation record - 'update', // ②b backfill userMessageId - 'tracker.start', // ③ start tracker - 'update', // ④a status → running - 'routeExecution', // ④b invoke cat - 'update', // ④c status → succeeded - 'tracker.complete', // ⑤ cleanup - ]); + const result = await generateScriptViaThread(makeRequest(), 'thread-result', buildQueueDeps([])); + assert.equal(result.segments.length, 2); + assert.equal(result.segments[0].speaker, '宪宪'); + assert.equal(result.totalDuration, 5); }); - it('failure path: tracker cleanup + failed status even when routeExecution throws', async () => { + it('propagates terminal Queue failure and releases the one-shot hook', async () => { const { generateScriptViaThread } = await import('../dist/domains/signals/services/podcast-generator.js'); const callLog = []; - const failDeps = buildFakeDeps(callLog); - - // Override router to throw - failDeps.router = { - async *routeExecution() { - callLog.push({ op: 'routeExecution' }); - throw new Error('LLM unavailable'); - }, - }; - - await assert.rejects(() => generateScriptViaThread(makeRequest(), 'thread-fail', failDeps), { - message: 'LLM unavailable', + const deps = buildQueueDeps(callLog, { status: 'failed', responseText: '' }); + await assert.rejects(() => generateScriptViaThread(makeRequest(), 'thread-fail', deps), { + message: 'Podcast Queue execution failed', }); - - // Verify failed status was recorded - const failUpdate = callLog.find((c) => c.op === 'update' && c.status === 'failed'); - assert.ok(failUpdate, 'must update record to failed'); - assert.equal(failUpdate.error, 'LLM unavailable'); - - // Verify tracker was cleaned up - const completeCall = callLog.find((c) => c.op === 'tracker.complete'); - assert.ok(completeCall, 'tracker.complete must be called in finally block'); - - // No succeeded - const succeedUpdate = callLog.find((c) => c.op === 'update' && c.status === 'succeeded'); - assert.equal(succeedUpdate, undefined, 'must NOT mark as succeeded on failure'); + assert.ok(callLog.some((call) => call.op === 'unregister')); }); +}); - it('returns parsed PodcastScript on success', async () => { - const { generateScriptViaThread } = await import('../dist/domains/signals/services/podcast-generator.js'); - const callLog = []; - const deps = buildFakeDeps(callLog, VALID_PODCAST_JSON); - - const result = await generateScriptViaThread(makeRequest(), 'thread-result', deps); - - assert.ok(result, 'should return a PodcastScript'); - assert.ok(Array.isArray(result.segments), 'should have segments array'); - assert.equal(result.segments.length, 2); - assert.equal(result.segments[0].speaker, '宪宪'); +describe('F091 Phase 6: study thread metadata', () => { + it('reuses an existing thread link', async () => { + const { StudyMetaService } = await import('../dist/domains/signals/services/study-meta-service.js'); + const svc = new StudyMetaService(); + const testId = `test-resolve-${Date.now()}`; + const testPath = '/tmp/test-resolve.md'; + await svc.linkThread(testId, testPath, { threadId: 'thread-existing', linkedBy: 'test-user' }); + const meta = await svc.readMeta(testId, testPath); + assert.equal(meta.threads[0].threadId, 'thread-existing'); }); }); diff --git a/packages/api/test/post-message-disposition-settlement.test.js b/packages/api/test/post-message-disposition-settlement.test.js deleted file mode 100644 index 22fa8b64f7..0000000000 --- a/packages/api/test/post-message-disposition-settlement.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialCrossThreadQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { - createA2ADispositionAuth as auth, - createA2ADispositionHarness as harness, -} from './helpers/a2a-dispatch-disposition-harness.js'; - -describe('post/read → A2A disposition → Queue settlement integration', () => { - test('same-cat cross-thread completion settles one exact exposed carrier without requeue', async () => { - const h = await harness({ - sourceCatId: 'codex-sol', - crossPostSourceThreadId: 'thread-source', - deliveryStatus: 'queued', - }); - const queue = new InvocationQueue(); - const enqueued = queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - ownerAuthProvenance: 'unknown', - content: h.source.content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex-sol'], - intent: 'execute', - autoExecute: true, - callerCatId: 'codex-sol', - a2aParentInvocationId: 'source-invocation', - a2aTriggerMessageId: h.source.id, - }); - assert.equal(enqueued.outcome, 'enqueued'); - queue.backfillMessageId('thread-1', 'user-1', enqueued.entry.id, h.source.id); - const queued = queue.getEntrySnapshot('thread-1', 'user-1', enqueued.entry.id); - assert.equal( - h.messageStore.initializeQueueCustody( - h.source.id, - createInitialCrossThreadQueuedMessageCustody(h.source.id, [queued]), - ).kind, - 'initialized', - ); - - const seenAt = queued.createdAt + 10; - assert.equal(queue.markProcessingById('thread-1', queued.id), true); - assert.equal(queue.markQueuedAwakened('thread-1', 'user-1', queued.id, 'codex-sol', 'inv-1', seenAt - 1), true); - queue.markProcessingSeen('thread-1', 'user-1', queued.id, ['codex-sol'], 'inv-1', seenAt); - const processing = queue.getEntrySnapshot('thread-1', 'user-1', queued.id); - const custody = new QueuedMessageCustodyCoordinator({ messageStore: h.messageStore, now: () => seenAt + 1 }); - await custody.persistEntry(processing); - assert.deepEqual(h.messageStore.getById(h.source.id).queueCustody.bodyExposures, [ - { targetCatId: 'codex-sol', invocationId: 'inv-1', seenAt }, - ]); - - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'applied'); - const dispositionEvent = (await h.eventLog.read('ball:thread:thread-1')).find( - (event) => event.kind === 'ball.dispatch_dispositioned', - ); - assert.ok(dispositionEvent, 'the exact persisted handoff must produce one disposition event'); - - const settled = await custody.commitSuccessfulTargets(processing, ['codex-sol'], 'inv-1', seenAt + 20, { - 'codex-sol': { - invocationId: 'inv-1', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-1' }, - handledAt: seenAt + 20, - }, - }); - assert.equal(settled.perMessage[0].fullyConsumed, true); - assert.equal(h.messageStore.getById(h.source.id).deliveryStatus, 'delivered'); - assert.equal(h.messageStore.getById(h.source.id).queueCustody.status, 'terminal'); - - assert.equal(queue.removeProcessedAcrossUsers('thread-1', queued.id)?.id, queued.id); - assert.deepEqual(queue.list('thread-1', 'user-1'), []); - assert.equal(queue.rollbackProcessing('thread-1', queued.id), false, 'a settled carrier cannot be requeued'); - assert.equal((await h.service.complete(auth(h), 'completed')).outcome, 'replayed'); - assert.deepEqual(queue.list('thread-1', 'user-1'), []); - }); -}); diff --git a/packages/api/test/post-message-successor-e2e.test.js b/packages/api/test/post-message-successor-e2e.test.js index 28e80ae363..f46eb5ca5b 100644 --- a/packages/api/test/post-message-successor-e2e.test.js +++ b/packages/api/test/post-message-successor-e2e.test.js @@ -35,14 +35,14 @@ async function createHarness({ failEnqueueAttempts = 0 } = {}) { const registry = new InvocationRegistry(); const invocationQueue = new InvocationQueue(); - const originalEnqueue = invocationQueue.enqueue.bind(invocationQueue); + const originalEnqueue = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); let remainingEnqueueFailures = failEnqueueAttempts; - invocationQueue.enqueue = (input) => { + invocationQueue.appendAndEnqueueDurable = async (...args) => { if (remainingEnqueueFailures > 0) { remainingEnqueueFailures -= 1; - throw new Error('simulated Queue admission failure'); + return { outcome: 'full' }; } - return originalEnqueue(input); + return originalEnqueue(...args); }; const messageStore = new MessageStore(); const threadStore = new ThreadStore(); @@ -74,9 +74,11 @@ async function createHarness({ failEnqueueAttempts = 0 } = {}) { }, }, queueProcessor: { - async tryAutoExecute(...args) { + async tryAutoAppendExactEntry(...args) { autoExecuteCalls.push(args); + return { outcome: 'rejected' }; }, + async requestDrain() {}, async onInvocationComplete() {}, }, actionSuccessorAdmissionService: { @@ -130,8 +132,12 @@ test('typed local review fact needs no action lease or inherited coordination to ...REVIEW_ANCHOR, }); assert.deepEqual(visible[0].mentions, ['codex']); - assert.equal(visible[0].deliveryStatus, 'queued'); - assert.deepEqual(harness.invocationQueue.list(harness.thread.id, 'user-1')[0].targetCats, ['codex']); + assert.equal( + visible[0].deliveryStatus, + undefined, + 'published Agent review remains visible while Queue owns wake admission in lifecycle metadata', + ); + assert.deepEqual(harness.invocationQueue.list(harness.thread.id, 'user-1')[0].targets, ['codex']); assert.equal(harness.autoExecuteCalls.length, 1); const replay = toolJson(await harness.handlePostMessage(input)); @@ -243,14 +249,12 @@ test('a durable review fact recovers one author wake after transient Queue failu assert.equal(first.statusCode, 503); assert.equal(first.json().kind, 'review_delivery_pending'); const durable = harness.messageStore.getByThreadIncludingQueued(harness.thread.id, 20, 'user-1'); - assert.equal(durable.length, 1); - assert.equal(durable[0].deliveryStatus, 'queued'); + assert.equal(durable.length, 0, 'atomic Queue rejection must not publish a source without its wake'); assert.equal(harness.invocationQueue.list(harness.thread.id, 'user-1').length, 0); const replay = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload }); assert.equal(replay.statusCode, 200); - assert.equal(replay.json().status, 'duplicate'); - assert.equal(replay.json().messageId, durable[0].id); + assert.equal(replay.json().status, 'ok'); assert.equal(harness.messageStore.getByThreadIncludingQueued(harness.thread.id, 20, 'user-1').length, 1); assert.equal(harness.invocationQueue.list(harness.thread.id, 'user-1').length, 1); assert.equal(harness.autoExecuteCalls.length, 1); diff --git a/packages/api/test/proposal-approve-auth-provenance.test.js b/packages/api/test/proposal-approve-auth-provenance.test.js index 1d2fd870fa..1ef6ac9e5e 100644 --- a/packages/api/test/proposal-approve-auth-provenance.test.js +++ b/packages/api/test/proposal-approve-auth-provenance.test.js @@ -11,7 +11,11 @@ describe('F275 proposal approval owner-auth provenance', () => { const ctx = await createProposalTestContext({ routerOverride: { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; + return { + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }, invocationQueueOverride: invocationQueue, @@ -42,6 +46,6 @@ describe('F275 proposal approval owner-auth provenance', () => { assert.equal(response.statusCode, 200); const { threadId } = JSON.parse(response.body); const [entry] = invocationQueue.list(threadId, 'default-user'); - assert.equal(entry.ownerAuthProvenance, 'compatibility_fallback'); + assert.equal(entry.execution.ownerAuthProvenance, 'compatibility_fallback'); }); }); diff --git a/packages/api/test/proposal-approve-dispatch.test.js b/packages/api/test/proposal-approve-dispatch.test.js index 1891ab2613..252427fdb0 100644 --- a/packages/api/test/proposal-approve-dispatch.test.js +++ b/packages/api/test/proposal-approve-dispatch.test.js @@ -89,12 +89,18 @@ describe('F128 approve dispatch — initialMessage routing', () => { const entries = invocationQueue.list(body.threadId, 'alice'); assert.equal(entries.length, 1); - assert.ok(entries[0].content.startsWith('Kick this off'), 'enqueued content should start with user-typed content'); - assert.deepEqual(entries[0].targetCats, ['opus']); - assert.equal(entries[0].intent, 'execute'); - assert.equal(entries[0].ownerAuthProvenance, 'strict'); - assert.ok(entries[0].messageId); - const stored = await ctx.messageStore.getById(entries[0].messageId); + assert.ok( + entries[0].payload.content.startsWith('Kick this off'), + 'enqueued content should start with user-typed content', + ); + assert.deepEqual( + entries.flatMap((entry) => entry.targets), + ['opus'], + ); + assert.equal(entries[0].execution.intent, 'execute'); + assert.equal(entries[0].execution.ownerAuthProvenance, 'strict'); + assert.ok(entries[0].payload.messageId); + const stored = await ctx.messageStore.getById(entries[0].payload.messageId); assert.equal(stored.deliveryStatus, 'queued'); assert.deepEqual(stored.mentions, ['opus']); const timeline = await ctx.messageStore.getByThread(body.threadId, 20, 'alice', { @@ -151,12 +157,12 @@ describe('F128 approve dispatch — initialMessage routing', () => { const entries = invocationQueue.list(body.threadId, 'alice'); assert.equal(entries.length, 1); assert.deepEqual( - entries[0].targetCats, + entries.flatMap((entry) => entry.targets), ['kimi'], 'dispatch wakes ONLY preferredCats[0] (first cat); subsequent cats are driven by cat-side @-mentions ("他们自己决定下一个要把谁叫出来" — owner spec 2026-05-27)', ); - assert.equal(entries[0].intent, 'execute', 'first-cat dispatch is always serial (intent execute)'); - const stored = await ctx.messageStore.getById(entries[0].messageId); + assert.equal(entries[0].execution.intent, 'execute', 'first-cat dispatch is always serial (intent execute)'); + const stored = await ctx.messageStore.getById(entries[0].payload.messageId); assert.deepEqual( stored.mentions, ['kimi'], @@ -203,7 +209,11 @@ describe('F128 approve dispatch — initialMessage routing', () => { assert.equal(res.statusCode, 200); const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); - assert.equal(entries[0].intent, 'ideate', 'explicit #ideate must override the proposal-card serial default'); + assert.equal( + entries[0].execution.intent, + 'ideate', + 'explicit #ideate must override the proposal-card serial default', + ); }); test('approve injects "## 主 Thread" header into sub-thread first message (fork-and-return loop)', async () => { @@ -244,7 +254,7 @@ describe('F128 approve dispatch — initialMessage routing', () => { const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); assert.equal(entries.length, 1); - const enqueued = entries[0].content; + const enqueued = entries[0].payload.content; assert.ok( enqueued.includes('## 主 Thread'), `enqueued content must include "## 主 Thread" header; got:\n${enqueued}`, @@ -326,10 +336,10 @@ describe('F128 approve dispatch — initialMessage routing', () => { const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); assert.deepEqual( - entries[0].targetCats, + entries.flatMap((entry) => entry.targets), ['kimi'], 'preferredCats[0]=kimi wakes first, even though message body @s @codex — message @s are prompt-level narrative, dispatch follows card order', ); - assert.equal(entries[0].intent, 'execute', 'first-cat dispatch is always serial'); + assert.equal(entries[0].execution.intent, 'execute', 'first-cat dispatch is always serial'); }); }); diff --git a/packages/api/test/proposal-approve-reconcile.test.js b/packages/api/test/proposal-approve-reconcile.test.js index a3c26d4071..42092ac458 100644 --- a/packages/api/test/proposal-approve-reconcile.test.js +++ b/packages/api/test/proposal-approve-reconcile.test.js @@ -3,9 +3,11 @@ * F128 / #1406 B1: proposal seed reconcile — exactly-once dispatch. * * Sol's hard requirement: queue-full, processNext-throw, and processNext - * started:false must each reconcile on retry, drive the single seed to a - * terminal delivery state, and never produce duplicate thread messages or - * duplicate invocations. + * started:false must each reconcile on retry, drive the single seed through + * Queue exactly once, and never produce duplicate thread messages or + * duplicate invocations. Published agent speech keeps its timeline visibility + * separate from owner-delivery projection; Queue terminalization is not allowed + * to rewrite it to a user-delivery terminal. */ import assert from 'node:assert/strict'; @@ -20,25 +22,41 @@ const router = { }; /** - * Simulate real delivery by mutating the durable message to delivered and - * dequeuing its queue entry. The production QueueProcessor would do this - * through transitionQueueCustody; for exactly-once dispatch tests we only - * care that the seed reaches a terminal state without duplicate wakes. + * Simulate one successful Queue execution by terminalizing its durable entry. + * The production QueueProcessor records response/lifecycle evidence before + * doing this; this proposal test only owns exactly-once seed admission and must + * not emulate retired owner-delivery projection on published agent speech. * - * Crucially, we only mark delivered when the message already has queue - * custody. Part 2's `ensureExistingSeedAdmitted` is what creates that custody - * for a queue-full seed; without it this helper would be a no-op and the - * queue-full test would fail with repeated redispatches. + * Crucially, we only terminalize when the message already has Queue custody. + * Part 2's `ensureExistingSeedAdmitted` is what creates that custody for a + * queue-full seed; without it this helper would be a no-op and the queue-full + * test would fail with repeated redispatches. */ async function simulateDelivery(ctx, invocationQueue, threadId, userId) { - const entry = invocationQueue.peek(threadId, userId); - if (entry?.messageId) { - const msg = await ctx.messageStore.getById(entry.messageId); - if (msg?.queueCustody) { - msg.deliveryStatus = 'delivered'; - msg.deliveredAt = Date.now(); - } - invocationQueue.dequeue(threadId, userId); + const entry = invocationQueue.list(threadId, userId)[0]; + if (entry?.payload.messageId) { + const source = await ctx.messageStore.getById(entry.payload.messageId); + assert.equal(source?.lifecycle?.kind, 'input'); + const targetId = entry.targets[0]; + assert.ok(targetId); + const dispatched = await ctx.messageStore.advanceLifecycleInputDispatch(source.id, { + orderKey: source.lifecycle.orderKey, + ...(source.lifecycle.producerInvocationId ? { producerInvocationId: source.lifecycle.producerInvocationId } : {}), + targetId, + phase: 'dispatched', + statusMessageId: `proposal-response:${entry.id}`, + dispatchedAt: Date.now(), + }); + assert.ok(['applied', 'replayed'].includes(dispatched.kind)); + const settled = await ctx.messageStore.advanceLifecycleInputDispatch(source.id, { + orderKey: source.lifecycle.orderKey, + ...(source.lifecycle.producerInvocationId ? { producerInvocationId: source.lifecycle.producerInvocationId } : {}), + targetId, + phase: 'settled', + statusMessageId: `proposal-response:${entry.id}`, + }); + assert.ok(['applied', 'replayed'].includes(settled.kind)); + await invocationQueue.terminalizeEntryDurable(threadId, userId, entry.id, 'handled'); } return { started: true }; } @@ -49,19 +67,20 @@ const threadReadOptions = { }; describe('F128 proposal seed reconcile — exactly-once dispatch', () => { - test('queue-full seed is reconciled once and driven to terminal delivery', async () => { + test('queue-full seed is reconciled and processed exactly once', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const invocationQueue = new InvocationQueue(); let proposalEnqueueAttempt = 0; - const originalEnqueue = invocationQueue.enqueue.bind(invocationQueue); - invocationQueue.enqueue = (input) => { + const originalEnqueue = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); + invocationQueue.appendAndEnqueueDurable = (...args) => { + const input = args[2]; if (input.idempotencyKey?.startsWith('proposal-initial:')) { proposalEnqueueAttempt += 1; if (proposalEnqueueAttempt === 1) { return { outcome: 'full' }; } } - return originalEnqueue(input); + return originalEnqueue(...args); }; const processCalls = []; @@ -101,7 +120,7 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { assert.equal(second.statusCode, 200); const secondBody = JSON.parse(second.body); assert.equal(secondBody.deduped, true); - assert.ok(!secondBody.warnings || secondBody.warnings.length === 0); + assert.ok(!secondBody.warnings || secondBody.warnings.length === 0, JSON.stringify(secondBody)); const third = await ctx.approve('alice', proposalId); assert.equal(third.statusCode, 200); @@ -112,7 +131,11 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { // Exactly one seed message, one successful processNext, no residual queue entry. const timeline = await ctx.messageStore.getByThread(childId, 10, 'alice', threadReadOptions); assert.equal(timeline.length, 1, 'must materialize exactly one seed message'); - assert.equal(timeline[0].deliveryStatus, 'delivered', 'seed must reach terminal delivery'); + assert.equal( + timeline[0].deliveryStatus, + undefined, + 'published agent seed must not be rewritten as owner-delivered content', + ); assert.equal(processCalls.length, 1, 'must wake the target exactly once'); assert.equal(invocationQueue.size(childId, 'alice'), 0, 'queue must be empty after delivery'); }); @@ -175,7 +198,7 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const timeline = await ctx.messageStore.getByThread(childId, 10, 'alice', threadReadOptions); assert.equal(timeline.length, 1, 'must keep exactly one seed message'); - assert.equal(timeline[0].deliveryStatus, 'delivered'); + assert.equal(timeline[0].deliveryStatus, 'queued'); assert.equal(processCalls.length, 2, 'first throw + one successful redispatch'); assert.equal(invocationQueue.size(childId, 'alice'), 0); }); @@ -232,12 +255,12 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const timeline = await ctx.messageStore.getByThread(childId, 10, 'alice', threadReadOptions); assert.equal(timeline.length, 1, 'must keep exactly one seed message'); - assert.equal(timeline[0].deliveryStatus, 'delivered'); + assert.equal(timeline[0].deliveryStatus, 'queued'); assert.equal(processCalls.length, 2, 'first no-start + one successful redispatch'); assert.equal(invocationQueue.size(childId, 'alice'), 0); }); - test('legacy queue-full seed is repaired and driven to terminal delivery exactly once', async () => { + test('legacy queue-full seed is repaired and processed exactly once', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const invocationQueue = new InvocationQueue(); const processCalls = []; @@ -271,11 +294,11 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const proposal = ctx.proposalStore.get(proposalId); - // Materialize a legacy seed: no idempotency key, no deliveryStatus, no - // queueCustody, but the proposal-specific source envelope and cross-post. + // Materialize a legacy seed: no idempotency key, no deliveryStatus, and no + // lifecycle dispatch refs, but with the proposal source envelope and cross-post. ctx.messageStore.append({ + from: { kind: 'agent', catId: 'codex' }, userId: 'alice', - catId: 'codex', content: `**来源**: ${proposal.title}\n\n${proposal.reason}`, mentions: ['opus'], timestamp: Date.now(), @@ -294,7 +317,7 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const firstBody = JSON.parse(first.body); assert.equal(firstBody.deduped, true); assert.equal(firstBody.legacySeed, true); - assert.ok(!firstBody.warnings || firstBody.warnings.length === 0); + assert.ok(!firstBody.warnings || firstBody.warnings.length === 0, JSON.stringify(firstBody)); const second = await ctx.approve('alice', proposalId); assert.equal(second.statusCode, 200); @@ -305,7 +328,11 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { // Exactly one seed message, one successful processNext, no residual queue entry. const timeline = await ctx.messageStore.getByThread(child.id, 10, 'alice', threadReadOptions); assert.equal(timeline.length, 1, 'must keep exactly one legacy seed message'); - assert.equal(timeline[0].deliveryStatus, 'delivered', 'legacy seed must reach terminal delivery'); + assert.equal( + timeline[0].deliveryStatus, + undefined, + 'published legacy agent seed must not be rewritten as owner-delivered content', + ); assert.equal(processCalls.length, 1, 'must wake the target exactly once'); assert.equal(invocationQueue.size(child.id, 'alice'), 0, 'queue must be empty after delivery'); }); diff --git a/packages/api/test/proposal-chain-protocol.test.js b/packages/api/test/proposal-chain-protocol.test.js index ae56ed54ce..b768f2406d 100644 --- a/packages/api/test/proposal-chain-protocol.test.js +++ b/packages/api/test/proposal-chain-protocol.test.js @@ -21,7 +21,11 @@ describe('F128 chain protocol injection', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: [], intent: { intent: 'execute' }, hasMentions: false }; + return { + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -49,7 +53,7 @@ describe('F128 chain protocol injection', () => { assert.equal(res.statusCode, 200); const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); - const enqueued = entries[0].content; + const enqueued = entries[0].payload.content; assert.ok(enqueued.includes('## 接力链路'), 'must include chain protocol section'); @@ -95,7 +99,11 @@ describe('F128 chain protocol injection', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: [], intent: { intent: 'execute' }, hasMentions: false }; + return { + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -132,17 +140,17 @@ describe('F128 chain protocol injection', () => { // must stay faithful. The fix is that dispatch parseIntent ignores // this header text entirely. assert.ok( - entries[0].content.includes('#ideate'), + entries[0].payload.content.includes('#ideate'), 'enriched content faithfully echoes parent title (contains `#ideate`)', ); assert.deepEqual( - entries[0].targetCats, + entries.flatMap((entry) => entry.targets), ['kimi'], 'serial proposal stays serial — only preferredCats[0] is woken, parent-title `#ideate` does NOT leak into parseIntent', ); assert.equal( - entries[0].intent, + entries[0].execution.intent, 'execute', 'intent stays execute (chain starter) — explicit-tag path requires user-typed `#ideate` in raw initialMessage', ); @@ -180,6 +188,7 @@ describe('F128 chain protocol injection', () => { targetCats, intent: { intent: 'execute' }, hasMentions: targetCats.length > 0, + // real-router contract: a parser ALWAYS hands over its batch (zero attempts on no @) }; }, }; @@ -256,7 +265,11 @@ describe('F128 chain protocol injection', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: true }; + return { + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: true, + }; }, }; const queueProcessor = { @@ -284,7 +297,7 @@ describe('F128 chain protocol injection', () => { assert.equal(res.statusCode, 200); const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); - const enqueued = entries[0].content; + const enqueued = entries[0].payload.content; assert.ok(enqueued.includes('## 主 Thread'), 'main thread header still injected even without preferredCats'); assert.ok(!enqueued.includes('接力链路'), 'chain protocol section must be omitted when preferredCats is empty'); diff --git a/packages/api/test/proposal-explicit-intent.test.js b/packages/api/test/proposal-explicit-intent.test.js index 4a04887aa4..46e2ae25be 100644 --- a/packages/api/test/proposal-explicit-intent.test.js +++ b/packages/api/test/proposal-explicit-intent.test.js @@ -42,7 +42,11 @@ describe('F128 explicit intent override (round-5)', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: [], intent: { intent: 'ideate' }, hasMentions: false }; + return { + targetCats: [], + intent: { intent: 'ideate' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -77,15 +81,15 @@ describe('F128 explicit intent override (round-5)', () => { const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); assert.equal(entries.length, 1); - const enqueued = entries[0].content; + const enqueued = entries[0].payload.content; // Runtime behaviour: dispatch wakes ALL preferredCats in parallel. assert.deepEqual( - entries[0].targetCats, - ['kimi', 'gemini', 'codex'], + new Set(entries.flatMap((entry) => entry.targets)), + new Set(['kimi', 'gemini', 'codex']), 'explicit #ideate must wake all preferredCats in parallel', ); - assert.equal(entries[0].intent, 'ideate', 'intent must be ideate (parallel)'); + assert.equal(entries[0].execution.intent, 'ideate', 'intent must be ideate (parallel)'); // Message contract: cats receive the main thread header but NOT the // serial chain protocol section (woken in parallel, not as a chain). @@ -141,7 +145,11 @@ describe('F128 explicit intent override (round-5)', () => { async resolveTargetsAndIntent() { // Simulate real router: raw `#execute @kimi @gemini @codex` → // resolved.targetCats = [kimi, gemini, codex]. - return { targetCats: ['kimi', 'gemini', 'codex'], intent: { intent: 'execute' }, hasMentions: true }; + return { + targetCats: ['kimi', 'gemini', 'codex'], + intent: { intent: 'execute' }, + hasMentions: true, + }; }, }; const queueProcessor = { @@ -175,10 +183,14 @@ describe('F128 explicit intent override (round-5)', () => { assert.equal(entries.length, 1); assert.deepEqual( - entries[0].targetCats, - ['kimi', 'gemini', 'codex'], + new Set(entries.flatMap((entry) => entry.targets)), + new Set(['kimi', 'gemini', 'codex']), 'explicit #execute + preferredCats=[] + multi-target raw must preserve all router-resolved targets', ); - assert.equal(entries[0].intent, 'execute', 'intent stays execute (serial multi-cat, not parallel ideation)'); + assert.equal( + entries[0].execution.intent, + 'execute', + 'intent stays execute (serial multi-cat, not parallel ideation)', + ); }); }); diff --git a/packages/api/test/proposal-participant-admission.test.js b/packages/api/test/proposal-participant-admission.test.js index 25307de266..96984f13cc 100644 --- a/packages/api/test/proposal-participant-admission.test.js +++ b/packages/api/test/proposal-participant-admission.test.js @@ -100,7 +100,10 @@ describe('F128 proposal approval — participant admission', () => { const approved = await ctx.approve('alice', proposed.json().proposalId); const threadId = approved.json().threadId; assert.deepEqual(await ctx.threadStore.getParticipants(threadId), ['kimi']); - assert.deepEqual(invocationQueue.list(threadId, 'alice')[0].targetCats, ['kimi']); + assert.deepEqual( + invocationQueue.list(threadId, 'alice').flatMap((entry) => entry.targets), + ['kimi'], + ); }); test('#ideate persists every final parallel target with one user-scoped event', async () => { @@ -140,11 +143,9 @@ describe('F128 proposal approval — participant admission', () => { test('queue rejection leaves no participant or participant event', async () => { const invocationQueue = { - enqueue() { - return { outcome: 'full' }; + async appendAndEnqueueDurable() { + return { outcome: 'full', entries: [] }; }, - backfillMessageId() {}, - rollbackEnqueue() {}, }; const router = { async resolveTargetsAndIntent() { diff --git a/packages/api/test/proposal-phase-aa.test.js b/packages/api/test/proposal-phase-aa.test.js index 0edc49579d..592fa70ec7 100644 --- a/packages/api/test/proposal-phase-aa.test.js +++ b/packages/api/test/proposal-phase-aa.test.js @@ -23,7 +23,11 @@ describe('F128 Phase AA — seed message source attribution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; + return { + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -55,7 +59,7 @@ describe('F128 Phase AA — seed message source attribution', () => { const entries = invocationQueue.list(body.threadId, 'alice'); assert.ok(entries.length > 0, 'message was enqueued'); - const stored = await ctx.messageStore.getById(entries[0].messageId); + const stored = await ctx.messageStore.getById(entries[0].payload.messageId); // AC-AA4: the seed message author should be the proposing cat, not the approver assert.equal( @@ -70,7 +74,11 @@ describe('F128 Phase AA — seed message source attribution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; + return { + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -100,7 +108,7 @@ describe('F128 Phase AA — seed message source attribution', () => { const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); - const stored = await ctx.messageStore.getById(entries[0].messageId); + const stored = await ctx.messageStore.getById(entries[0].payload.messageId); // AC-AA5: crossPost metadata for frontend pill + jump-to-source assert.ok(stored.extra?.crossPost, 'AC-AA5: seed message must have extra.crossPost'); diff --git a/packages/api/test/proposal-race-rollback.test.js b/packages/api/test/proposal-race-rollback.test.js index cd60024131..5f4d53c7f4 100644 --- a/packages/api/test/proposal-race-rollback.test.js +++ b/packages/api/test/proposal-race-rollback.test.js @@ -28,15 +28,15 @@ describe('F128 proposal seed race — orphan carrier rollback', () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const invocationQueue = new InvocationQueue(); let proposalEnqueueAttempt = 0; - const originalEnqueue = invocationQueue.enqueue.bind(invocationQueue); - invocationQueue.enqueue = (input) => { + const originalAdmission = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); + invocationQueue.appendAndEnqueueDurable = async (messageStore, message, input) => { if (input.idempotencyKey?.startsWith('proposal-initial:')) { proposalEnqueueAttempt += 1; if (proposalEnqueueAttempt === 1) { return { outcome: 'full' }; } } - return originalEnqueue(input); + return originalAdmission(messageStore, message, input); }; const processCalls = []; @@ -74,12 +74,13 @@ describe('F128 proposal seed race — orphan carrier rollback', () => { const seed = await ctx.messageStore.getByIdempotencyKey('alice', childId, `proposal-initial:${proposalId}`); assert.ok(seed, 'queue-full seed must be materialized with an idempotency key'); - // Simulate another worker completing the seed after this reconcile enqueued a - // fresh carrier but before the seed could be admitted. The prepareQueueAdmission - // call sees a terminal seed, ensureExistingSeedAdmitted returns 'complete', and - // executeQueuedDispatch must roll the orphan carrier back. - const originalPrepare = ctx.messageStore.prepareQueueAdmission.bind(ctx.messageStore); - ctx.messageStore.prepareQueueAdmission = async (id) => { + // Simulate another worker completing the seed after reconcile read it but + // before the atomic existing-message/ledger admission wins. The storage CAS + // rejects the losing admission and removes its staged ledger row. + const originalExistingAdmission = ctx.messageStore.enqueueExistingMessageWithQueueLedgerAdmission.bind( + ctx.messageStore, + ); + ctx.messageStore.enqueueExistingMessageWithQueueLedgerAdmission = async (id, entries, ledgerStore, maxDepth) => { if (id === seed.id) { const msg = await ctx.messageStore.getById(id); if (msg && msg.deliveryStatus !== 'delivered' && msg.deliveryStatus !== 'canceled') { @@ -87,7 +88,7 @@ describe('F128 proposal seed race — orphan carrier rollback', () => { msg.deliveredAt = Date.now(); } } - return originalPrepare(id); + return originalExistingAdmission(id, entries, ledgerStore, maxDepth); }; const second = await ctx.approve('alice', proposalId); diff --git a/packages/api/test/proposal-reporter-handle.test.js b/packages/api/test/proposal-reporter-handle.test.js index fafaac7f6b..e4070f29b2 100644 --- a/packages/api/test/proposal-reporter-handle.test.js +++ b/packages/api/test/proposal-reporter-handle.test.js @@ -41,7 +41,11 @@ describe('F128 parallel reporter handle resolution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['kimi', 'gemini', 'codex'], intent: { intent: 'ideate' }, hasMentions: true }; + return { + targetCats: ['kimi', 'gemini', 'codex'], + intent: { intent: 'ideate' }, + hasMentions: true, + }; }, }; const queueProcessor = { @@ -74,10 +78,14 @@ describe('F128 parallel reporter handle resolution', () => { const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); assert.equal(entries.length, 1); - const enqueued = entries[0].content; + const enqueued = entries[0].payload.content; - assert.deepEqual(entries[0].targetCats, ['kimi', 'gemini', 'codex'], 'wake all router-resolved targets'); - assert.equal(entries[0].intent, 'ideate'); + assert.deepEqual( + new Set(entries.flatMap((entry) => entry.targets)), + new Set(['kimi', 'gemini', 'codex']), + 'wake all router-resolved targets', + ); + assert.equal(entries[0].execution.intent, 'ideate'); assert.ok(!enqueued.includes('最后一棒猫'), 'parallel mode must NOT inherit serial rule'); assert.ok(enqueued.includes('report-back owner'), 'must inject parallel reporter'); const ownerLineMatch = enqueued.match(/report-back owner[^\n]*/); @@ -97,7 +105,11 @@ describe('F128 parallel reporter handle resolution', () => { const router = { async resolveTargetsAndIntent() { // Router resolves Chinese alias `@砚砚` → catId `codex` per cat-template.json. - return { targetCats: ['codex', 'opus'], intent: { intent: 'ideate' }, hasMentions: true }; + return { + targetCats: ['codex', 'opus'], + intent: { intent: 'ideate' }, + hasMentions: true, + }; }, }; const queueProcessor = { @@ -126,7 +138,7 @@ describe('F128 parallel reporter handle resolution', () => { const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); assert.equal(entries.length, 1); - const enqueued = entries[0].content; + const enqueued = entries[0].payload.content; assert.ok(!enqueued.includes('最后一棒猫'), 'CJK alias path must NOT inherit serial rule'); assert.ok(enqueued.includes('report-back owner'), 'must inject parallel reporter'); @@ -146,7 +158,11 @@ describe('F128 parallel reporter handle resolution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['gpt-5.2', 'gpt-5.4'], intent: { intent: 'ideate' }, hasMentions: true }; + return { + targetCats: ['gpt-5.2', 'gpt-5.4'], + intent: { intent: 'ideate' }, + hasMentions: true, + }; }, }; const queueProcessor = { @@ -179,7 +195,7 @@ describe('F128 parallel reporter handle resolution', () => { const body = JSON.parse(res.body); const entries = invocationQueue.list(body.threadId, 'alice'); assert.equal(entries.length, 1); - const enqueued = entries[0].content; + const enqueued = entries[0].payload.content; assert.ok(!enqueued.includes('最后一棒猫'), 'dotted handle path must NOT inherit serial rule'); assert.ok(enqueued.includes('report-back owner'), 'must inject parallel reporter'); diff --git a/packages/api/test/proposal-resilience.test.js b/packages/api/test/proposal-resilience.test.js index 3f449fcb84..0c5f6c2e4d 100644 --- a/packages/api/test/proposal-resilience.test.js +++ b/packages/api/test/proposal-resilience.test.js @@ -6,17 +6,20 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { createProposalTestContext } from './helpers/proposal-test-harness.js'; async function createInvocationWithOrigin(ctx, threadId) { - const origin = await ctx.messageStore.append({ - userId: 'alice', - catId: null, - content: 'Please propose this child thread', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const origin = await ctx.messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'Please propose this child thread', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); return ctx.registry.create('alice', 'opus', threadId, undefined, origin.id); } @@ -76,14 +79,16 @@ describe('F128 partial-commit + dedup + self-heal', () => { const first = await send(); assert.equal(first.statusCode, 500); for (let i = 0; i < 60; i++) { - await ctx.messageStore.append({ - userId: 'alice', - catId: null, - content: `filler ${i}`, - mentions: [], - timestamp: Date.now() + i, - threadId: source.id, - }); + await ctx.messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: `filler ${i}`, + mentions: [], + timestamp: Date.now() + i, + threadId: source.id, + }), + ); } const second = await send(); assert.equal(second.statusCode, 200, `retry must self-heal old card, got ${second.statusCode}`); @@ -122,20 +127,22 @@ describe('F128 partial-commit + dedup + self-heal', () => { projectPath: 'default', createdBy: 'alice', }); - const legacyCard = await ctx.messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'Legacy approval card', - mentions: [], - timestamp: Date.now(), - threadId: source.id, - extra: { - rich: { - v: 1, - blocks: [{ id: `proposal-${legacy.proposalId}`, kind: 'card', v: 1, title: 'Legacy proposal' }], + const legacyCard = await ctx.messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'Legacy approval card', + mentions: [], + timestamp: Date.now(), + threadId: source.id, + extra: { + rich: { + v: 1, + blocks: [{ id: `proposal-${legacy.proposalId}`, kind: 'card', v: 1, title: 'Legacy proposal' }], + }, }, - }, - }); + }), + ); proposalStore.reserveDedup('alice', 'legacy-f128-key', legacy.proposalId); const response = await ctx.app.inject({ diff --git a/packages/api/test/proposal-withdraw.test.js b/packages/api/test/proposal-withdraw.test.js index 706a039f42..ab242bc187 100644 --- a/packages/api/test/proposal-withdraw.test.js +++ b/packages/api/test/proposal-withdraw.test.js @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { createProposalTestContext } from './helpers/proposal-test-harness.js'; async function proposed(ctx, { userId = 'alice', catId = 'opus' } = {}) { @@ -86,14 +87,16 @@ describe('F128 requester withdraw', () => { const ctx = await createProposalTestContext({ proposalStoreOverride: proposalStore }); const { source, proposalId } = await proposed(ctx); ctx.socketEvents.length = 0; - const origin = await ctx.messageStore.append({ - userId: 'alice', - catId: null, - content: `Withdraw thread proposal ${proposalId}`, - mentions: [], - timestamp: Date.now(), - threadId: source.id, - }); + const origin = await ctx.messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: `Withdraw thread proposal ${proposalId}`, + mentions: [], + timestamp: Date.now(), + threadId: source.id, + }), + ); const { invocationId, callbackToken } = await ctx.registry.create('alice', 'opus', source.id, undefined, origin.id); const retry = () => ctx.app.inject({ diff --git a/packages/api/test/propose-session-handoff-route-fixtures.js b/packages/api/test/propose-session-handoff-route-fixtures.js index cd6a08f9f3..72dd11e79c 100644 --- a/packages/api/test/propose-session-handoff-route-fixtures.js +++ b/packages/api/test/propose-session-handoff-route-fixtures.js @@ -1,4 +1,5 @@ import Fastify from 'fastify'; +import { adaptMessageStore, canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; export const ACTIVE = { id: 'sess_active', @@ -10,7 +11,7 @@ export const ACTIVE = { export async function createMessageStore() { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - return new MessageStore(); + return adaptMessageStore(new MessageStore()); } export async function createHandoffStore() { @@ -62,14 +63,16 @@ export async function buildCtx({ messageStoreOverride, sessionChainStoreOverride const key = payload.clientRequestId ? `${userId}:${catId}:${threadId}:${payload.clientRequestId}` : undefined; let origin = key ? originByRequest.get(key) : undefined; if (!origin) { - origin = await messageStore.append({ - userId, - catId: null, - content: 'Please hand off this session', - mentions: [], - timestamp: Date.now(), - threadId, - }); + origin = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: 'Please hand off this session', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); if (key) originByRequest.set(key, origin); } const { invocationId, callbackToken } = await registry.create(userId, catId, threadId, undefined, origin.id); diff --git a/packages/api/test/queue-api.test.js b/packages/api/test/queue-api.test.js index bde2b90721..41c444e16f 100644 --- a/packages/api/test/queue-api.test.js +++ b/packages/api/test/queue-api.test.js @@ -6,13 +6,15 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import Fastify from 'fastify'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; +import { canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); +const { InvocationQueue, queueEntryOwnerId, queueEntryTargetCats } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationQueue.js' +); async function terminalizeFixtureCarrier(deps, queueProcessor, carrier, userId, threadId) { if ((await queueProcessor.finalizeRemovedEntry(carrier, 'user_cancel')) === false) return false; - for (const messageId of [carrier.messageId, ...carrier.mergedMessageIds].filter(Boolean)) { + for (const messageId of carrier.payload.messageId ? [carrier.payload.messageId] : []) { try { const canceled = await deps.messageStore.markCanceled(messageId); if (canceled?.deliveryTransitioned === true) { @@ -22,33 +24,45 @@ async function terminalizeFixtureCarrier(deps, queueProcessor, carrier, userId, return false; } } - try { - await deps.queueCustodyCoordinator.withdrawEntry(carrier); - return true; - } catch { - return false; - } + return Boolean( + await deps.invocationQueue.removeProcessedAcrossUsersDurable( + threadId, + carrier.id, + 'interrupted', + 'invocation_cancelled', + ), + ); } /** Build deps with stubs */ function buildDeps(overrides = {}) { const invocationQueue = new InvocationQueue(); + const threadFixture = { + id: 't1', + title: 'Test Thread', + createdBy: 'system', + participants: ['opus', 'codex'], + }; let deps; const queueProcessor = { canReleaseSlotForUser: mock.fn(() => true), + hasProcessingSlotReservation: mock.fn((threadId, catId) => + Boolean(invocationQueue.findProcessingByCat(threadId, catId)), + ), processNext: mock.fn(async () => ({ started: false })), - isPaused: mock.fn(() => false), - getPauseReason: mock.fn(() => undefined), - clearPause: mock.fn(() => {}), releaseSlot: mock.fn(() => {}), releaseThread: mock.fn(() => {}), + requestDrain: mock.fn(async () => {}), finalizeRemovedEntry: mock.fn(async () => true), - retryFailedTargetWithoutCustody: mock.fn(async (threadId, userId, entryId, catId, recoveryActionId) => { - return invocationQueue.retryFailedTarget(threadId, userId, entryId, catId) - ? { outcome: 'retried', attemptId: recoveryActionId } - : { outcome: 'not_retryable' }; - }), + registerCallerDispatchSteerChanges: mock.fn(() => {}), + registerCallerDispatchQueueWithdrawal: mock.fn(() => {}), + appendExactEntry: mock.fn(async () => ({ outcome: 'appended', entry: {}, acceptedTargetIds: ['opus'] })), + tryAutoAppendExactEntry: mock.fn(async () => ({ outcome: 'appended', entry: {}, acceptedTargetIds: ['opus'] })), }; + queueProcessor.processClaimedSteerEntries = mock.fn(async (threadId, userId, entryIds) => ({ + started: true, + entry: invocationQueue.getEntrySnapshot(threadId, userId, entryIds[0]), + })); queueProcessor.processExactSteerReservation = mock.fn(async (threadId, userId) => queueProcessor.processNext(threadId, userId), ); @@ -58,11 +72,12 @@ function buildDeps(overrides = {}) { }; deps = { threadStore: { - get: mock.fn(async (id) => ({ - id, - title: 'Test Thread', - createdBy: 'system', // default: public thread - })), + get: mock.fn(async (id) => ({ ...threadFixture, id, participants: [...threadFixture.participants] })), + addParticipants: mock.fn(async (_threadId, catIds) => { + for (const catId of catIds) { + if (!threadFixture.participants.includes(catId)) threadFixture.participants.push(catId); + } + }), }, invocationQueue, queueProcessor, @@ -76,8 +91,10 @@ function buildDeps(overrides = {}) { resolveCarrierCapability: mock.fn(() => ({ provider: 'openai_codex', carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', })), + isCatAvailable: mock.fn(() => true), socketManager: { broadcastAgentMessage: mock.fn(), broadcastToRoom: mock.fn(), @@ -87,12 +104,6 @@ function buildDeps(overrides = {}) { markCanceled: mock.fn(async () => ({ deliveryStatus: 'canceled', deliveryTransitioned: true })), getById: mock.fn(async () => null), }, - queueCustodyCoordinator: { - persistEntry: mock.fn(async () => {}), - withdrawEntry: mock.fn(async () => true), - findReminderAttempt: mock.fn(async () => undefined), - requestReminder: mock.fn(async () => true), - }, agentSessionMutex: { forceReleaseByScope: mock.fn(() => ({ releasedHolders: 0, rejectedWaiters: 0, catIds: [] })), }, @@ -102,7 +113,7 @@ function buildDeps(overrides = {}) { }; queueProcessor.retirePrestartProcessingGroup = mock.fn(async (threadId, _catId, userId) => { const inflight = invocationQueue.findProcessingByCat(threadId, _catId); - if (!inflight || inflight.userId !== userId) return 'state_changed'; + if (!inflight || queueEntryOwnerId(inflight) !== userId) return 'state_changed'; const carriers = invocationQueue.getProcessingGroupAcrossUsers(threadId, inflight.id); if (!carriers) return 'state_changed'; for (const carrier of carriers) { @@ -110,23 +121,63 @@ function buildDeps(overrides = {}) { return 'terminalization_failed'; } } - return invocationQueue.removeProcessingGroupAcrossUsers(threadId, inflight.id) ? 'retired' : 'state_changed'; + return 'retired'; }); return deps; } -/** Enqueue a test entry */ +let durableSourceSequence = 0; + +/** Enqueue one canonical durable ledger row for route tests. */ function enqueueEntry(queue, overrides = {}) { - return queue.enqueue({ + durableSourceSequence += 1; + return queue.enqueueDurableNow( + canonicalTestQueueInput({ + threadId: 't1', + userId: 'user-a', + kind: 'conversation_input', + content: 'hello', + source: 'user', + ownerAuthProvenance: 'unknown', + targetCats: ['opus'], + intent: 'execute', + sourceId: `queue-api-source-${durableSourceSequence}`, + ...overrides, + }), + ); +} + +async function enqueueDurableEntry(queue, overrides = {}) { + durableSourceSequence += 1; + return queue.enqueueDurable( + canonicalTestQueueInput({ + threadId: 't1', + userId: 'user-a', + kind: 'conversation_input', + content: 'hello', + source: 'user', + sourceId: `queue-api-source-${durableSourceSequence}`, + ownerAuthProvenance: 'unknown', + targetCats: ['opus'], + intent: 'execute', + ...overrides, + }), + ); +} + +function mockSourceMessage(deps, messageId, overrides = {}) { + const source = { + id: messageId, threadId: 't1', userId: 'user-a', content: 'hello', - source: 'user', - ownerAuthProvenance: 'unknown', - targetCats: ['opus'], - intent: 'execute', + from: { kind: 'user', userId: 'user-a' }, + deliveryStatus: 'queued', + timestamp: 1, ...overrides, - }); + }; + deps.messageStore.getById.mock.mockImplementation(async (id) => (id === messageId ? source : null)); + return source; } function asyncGate() { @@ -141,6 +192,18 @@ function asyncGate() { return { entered, blocked, enter, release }; } +async function markHeadProcessing(queue, userId = 'user-a') { + const head = queue.peekNextQueued('t1', userId); + assert.ok(head); + const targetCatId = queueEntryTargetCats(head)[0] ?? 'opus'; + const claimed = await queue.markProcessingDurable('t1', userId, { + entryId: head.id, + targetCats: [targetCatId], + }); + assert.ok(claimed); + return queue.getEntrySnapshot('t1', userId, head.id); +} + describe('Queue Management API', () => { let app; let deps; @@ -228,13 +291,12 @@ describe('Queue Management API', () => { assert.equal(bodyB.queue[0].content, 'b msg'); }); - it('GET /queue hydrates independent per-target read states for F5', async () => { - const queued = enqueueEntry(deps.invocationQueue, { + it('GET /queue projects one pending source row and never manufactures delivery state', async () => { + enqueueEntry(deps.invocationQueue, { content: 'two targets', targetCats: ['opus', 'codex'], messageId: 'msg-1', }); - deps.invocationQueue.markQueuedSeen('t1', 'user-a', queued.entry.id, 'opus', 'inv-opus'); const res = await app.inject({ method: 'GET', @@ -244,481 +306,574 @@ describe('Queue Management API', () => { const body = JSON.parse(res.body); assert.equal(res.statusCode, 200); - assert.deepEqual(body.queue[0].targetStates, { - opus: 'seen', - codex: 'queued', - }); - }); - - for (const activeParent of [false, true]) { - for (const messagePresent of [false, true]) { - for (const state of ['queued', 'processing', 'failed']) { - it(`projects an executable recovery for ${state} × message=${messagePresent ? 'present' : 'null'} × parent=${activeParent ? 'active' : 'absent'}`, async () => { - deps.invocationTracker.getActiveSlots.mock.mockImplementation(() => - activeParent ? [{ catId: 'opus', startedAt: 100 }] : [], - ); - deps.invocationTracker.getUserId.mock.mockImplementation(() => (activeParent ? 'user-a' : null)); - deps.invocationTracker.getExecutionId.mock.mockImplementation(() => - activeParent ? 'inv-active' : undefined, - ); - const queued = enqueueEntry(deps.invocationQueue, { - source: messagePresent ? 'user' : 'agent', - sourceCategory: messagePresent ? undefined : 'a2a', - autoExecute: !messagePresent, - messageId: messagePresent ? 'msg-recovery' : null, - }); - if (state === 'processing') { - deps.invocationQueue.markProcessingById('t1', queued.entry.id, 'opus'); - } else if (state === 'failed') { - deps.invocationQueue.markQueuedFailedForCatAcrossUsers( - 't1', - 'opus', - 'inv-failed', - new Set([queued.entry.id]), - 'invocation_failed', - 1234, - ); - } - - const res = await app.inject({ - method: 'GET', - url: '/api/threads/t1/queue', - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - const [entry] = JSON.parse(res.body).queue; - const expectedKind = - state === 'processing' - ? 'force_reset' - : state === 'failed' && !messagePresent - ? 'retry_target' - : state === 'failed' || messagePresent - ? 'withdraw' - : 'steer'; - - assert.equal(res.statusCode, 200); - assert.ok(entry.recoveryActions.some((action) => action.kind === expectedKind)); - if (state === 'failed' && messagePresent) { - assert.equal( - entry.recoveryActions.some((action) => action.kind === 'retry_target'), - false, - 'message-backed Retry must not be published until durable custody proves it executable', - ); - } - }); - } - } - } + assert.equal(body.queue.length, 1); + assert.deepEqual(body.queue[0].targetCats, ['opus', 'codex']); + assert.equal('targetStates' in body.queue[0], false); + assert.equal('recoveryActions' in body.queue[0], false); + }); - it('executes the projected fallback when a failed message-backed row has no durable custody', async () => { - const queued = enqueueEntry(deps.invocationQueue, { messageId: 'msg-missing-custody' }); - deps.invocationQueue.markQueuedFailedForCatAcrossUsers( - 't1', - 'opus', - 'inv-failed', - new Set([queued.entry.id]), - 'invocation_failed', - 1234, - ); + it('GET /queue projects the exact provider carrier for each active target', async () => { + deps.invocationTracker.getActiveSlots.mock.mockImplementation(() => [{ catId: 'opus', startedAt: 100 }]); + deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); + deps.invocationTracker.getExecutionId.mock.mockImplementation(() => 'inv-active'); - const projection = await app.inject({ + const res = await app.inject({ method: 'GET', url: '/api/threads/t1/queue', headers: { 'x-cat-cafe-user': 'user-a' }, }); - const [entry] = JSON.parse(projection.body).queue; + const body = JSON.parse(res.body); - assert.equal(projection.statusCode, 200); - assert.deepEqual( - entry.recoveryActions.map((action) => action.kind), - ['withdraw'], - 'known-missing custody must retain only the real fallback action', - ); - const [action] = entry.recoveryActions; - const executed = await app.inject({ - method: action.request.method, - url: action.request.path, - headers: { 'x-cat-cafe-user': 'user-a' }, + assert.deepEqual(body.activeInvocations[0].freshnessCarrierCapability, { + provider: 'openai_codex', + carrier: 'codex_app_server', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', }); - - assert.equal(executed.statusCode, 200, executed.body); - assert.equal(deps.invocationQueue.getEntrySnapshot('t1', 'user-a', queued.entry.id), null); }); - it('executes exact retry for message-less failed A2A once and preserves the failed sibling', async () => { - const queued = enqueueEntry(deps.invocationQueue, { - source: 'agent', - sourceCategory: 'a2a', - autoExecute: true, - messageId: null, - targetCats: ['opus', 'codex'], - }); - deps.invocationQueue.markQueuedFailedForCatAcrossUsers( - 't1', - 'opus', - 'inv-opus-failed', - new Set([queued.entry.id]), - 'invocation_failed', - 1234, - ); - deps.invocationQueue.markQueuedFailedForCatAcrossUsers( - 't1', - 'codex', - 'inv-codex-failed', - new Set([queued.entry.id]), - 'invocation_failed', - 1235, - ); + it('GET /queue projects Append only from the exact supporting Active Run dispatcher', async () => { + const queued = enqueueEntry(deps.invocationQueue, { ownerAuthProvenance: 'strict', messageId: 'message-1' }); + const activeRun = { + threadId: 't1', + targetId: 'opus', + invocationId: 'turn-1', + responseMessageId: 'response-1', + inputEntryIds: ['entry-old'], + inputMessageIds: ['message-old'], + privateInputEntryIds: [], + startedAt: 100, + }; + deps.invocationTracker.getActiveSlots.mock.mockImplementation(() => [{ catId: 'opus', startedAt: 100, activeRun }]); + deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); + deps.invocationTracker.getAgentClientActiveRunDispatcher = mock.fn(() => ({ + invocationId: 'turn-1', + capabilities: { append: true, steer: true }, + handle: { provider: 'openai_codex', carrier: 'codex_app_server', threadId: 'native-1', turnId: 'turn-1' }, + dispatch: async () => ({ accepted: true, handle: {} }), + })); - const projection = await app.inject({ + const available = await app.inject({ method: 'GET', url: '/api/threads/t1/queue', headers: { 'x-cat-cafe-user': 'user-a' }, }); - const entry = JSON.parse(projection.body).queue[0]; - const action = entry.recoveryActions.find( - (candidate) => candidate.kind === 'retry_target' && candidate.targetCatId === 'opus', - ); - assert.ok(action, 'message-less failed target must receive exact executable Retry'); - - const first = await app.inject({ - method: action.request.method, - url: action.request.path, - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: action.request.body, - }); - const second = await app.inject({ - method: action.request.method, - url: action.request.path, - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: action.request.body, + const body = JSON.parse(available.body); + assert.equal(body.queueRevision, deps.invocationQueue.snapshotRevision('t1', 'user-a')); + assert.deepEqual(body.queue[0].lifecycleActions.append, { + kind: 'append', + expectedQueueRevision: body.queueRevision, + expectedRuns: [{ targetId: 'opus', invocationId: 'turn-1', responseMessageId: 'response-1' }], }); - assert.equal(first.statusCode, 202, first.body); - assert.equal(second.statusCode, 409, 'the same recovery fence must be idempotently rejected'); - const current = deps.invocationQueue.getEntrySnapshot('t1', 'user-a', queued.entry.id); - assert.deepEqual(current.queuedFailedByCatIds, ['codex'], 'retrying one target must not reopen its sibling'); - }); - - it('maps a message-backed Queue recovery action to the existing durable retry authority', async () => { - await app.close(); - const retryCalls = []; - const commitCalls = []; - const authorityCalls = []; - const messageById = new Map(); - deps = buildDeps({ - messageStore: { - markCanceled: mock.fn(async () => ({ deliveryStatus: 'canceled', deliveryTransitioned: true })), - getById: mock.fn(async (messageId) => messageById.get(messageId) ?? null), - }, - retryAuthorityPreflight: { - preflight: mock.fn(async (input) => { - authorityCalls.push(input); - return { ok: true, kind: 'user' }; - }), - }, - retryAuthorityCommitter: { - commit: mock.fn(async (input) => { - commitCalls.push(input); - return { outcome: 'committed' }; - }), - }, - }); - deps.queueProcessor.retryFailedTarget = mock.fn(async (...args) => { - retryCalls.push(args); - await args[5]([]); - return { outcome: 'retried', attemptId: 'entry-durable:opus:2' }; - }); - const queued = enqueueEntry(deps.invocationQueue, { messageId: 'msg-durable' }); - deps.invocationQueue.markQueuedFailedForCatAcrossUsers( - 't1', - 'opus', - 'inv-failed', - new Set([queued.entry.id]), - 'invocation_failed', - 1234, - ); - messageById.set('msg-durable', { - id: 'msg-durable', - userId: 'user-a', - threadId: 't1', - queueCustody: makeQueuedMessageCustody({ - entryId: queued.entry.id, - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - failedByCatIds: ['opus'], - targetAttempts: [ - { - id: 'entry-durable:opus:1', - targetCatId: 'opus', - sequence: 1, - state: 'failed', - createdAt: 1000, - updatedAt: 1234, - terminalReason: 'invocation_failed', - }, - ], - }), - }); - const { queueRoutes } = await import('../dist/routes/queue.js'); - app = Fastify(); - await app.register(queueRoutes, deps); - await app.ready(); - - const projection = await app.inject({ + deps.invocationTracker.getAgentClientActiveRunDispatcher.mock.mockImplementation(() => undefined); + const unsupported = await app.inject({ method: 'GET', url: '/api/threads/t1/queue', headers: { 'x-cat-cafe-user': 'user-a' }, }); - const action = JSON.parse(projection.body).queue[0].recoveryActions.find( - (candidate) => candidate.kind === 'retry_target', - ); - const response = await app.inject({ - method: action.request.method, - url: action.request.path, - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: action.request.body, - }); - - assert.equal(response.statusCode, 202, response.body); - assert.deepEqual(retryCalls[0].slice(0, 5), ['t1', 'user-a', queued.entry.id, 'opus', 'entry-durable:opus:1']); - assert.equal(authorityCalls.length, 1); - assert.equal(commitCalls.length, 1); + assert.equal(JSON.parse(unsupported.body).queue[0].lifecycleActions, undefined); + assert.equal(deps.invocationQueue.list('t1', 'user-a')[0].id, queued.entry.id); }); - it('GET /queue projects the exact provider carrier for each active target', async () => { - deps.invocationTracker.getActiveSlots.mock.mockImplementation(() => [{ catId: 'opus', startedAt: 100 }]); - deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); - deps.invocationTracker.getExecutionId.mock.mockImplementation(() => 'inv-active'); - + it('POST /queue/:entryId/append forwards only the echoed server fences', async () => { + const queued = enqueueEntry(deps.invocationQueue, { ownerAuthProvenance: 'strict', messageId: 'message-1' }); + const payload = { + expectedQueueRevision: deps.invocationQueue.snapshotRevision('t1', 'user-a'), + expectedRuns: [{ targetId: 'opus', invocationId: 'turn-1', responseMessageId: 'response-1' }], + }; const res = await app.inject({ - method: 'GET', - url: '/api/threads/t1/queue', + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/append`, headers: { 'x-cat-cafe-user': 'user-a' }, + payload, }); - const body = JSON.parse(res.body); - assert.deepEqual(body.activeInvocations[0].freshnessCarrierCapability, { - provider: 'openai_codex', - carrier: 'codex_app_server', - deliverySemantics: 'exact_active_turn', + assert.equal(res.statusCode, 200); + assert.deepEqual(deps.queueProcessor.appendExactEntry.mock.calls[0].arguments[0], { + threadId: 't1', + userId: 'user-a', + entryId: queued.entry.id, + ...payload, }); }); - it('GET /queue hydrates notified, failed, and partially handled targets independently', async () => { - const queued = enqueueEntry(deps.invocationQueue, { - content: 'three targets', - targetCats: ['opus', 'codex', 'gpt52'], - messageId: 'msg-1', - }); - deps.invocationQueue.markQueuedNotified('t1', 'user-a', queued.entry.id, 'opus'); - deps.invocationQueue.markQueuedSeen('t1', 'user-a', queued.entry.id, 'codex', 'inv-codex'); - deps.invocationQueue.markQueuedFailedForCatAcrossUsers('t1', 'codex', 'inv-codex'); - deps.invocationQueue.markQueuedSeen('t1', 'user-a', queued.entry.id, 'gpt52', 'inv-gpt52'); - deps.invocationQueue.markQueuedHandledForCatAcrossUsers('t1', 'gpt52', 'inv-gpt52'); + it('POST /queue/:entryId/continue appends to the exact active run without canceling it', async () => { + const queued = await enqueueDurableEntry(deps.invocationQueue, { targetCats: [] }); + deps.invocationTracker.has.mock.mockImplementation((_threadId, catId) => catId === 'opus'); + deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); + deps.invocationTracker.getExecutionId.mock.mockImplementation(() => 'turn-1'); const res = await app.inject({ - method: 'GET', - url: '/api/threads/t1/queue', + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/continue`, headers: { 'x-cat-cafe-user': 'user-a' }, + payload: { targetCatId: 'opus' }, }); - const body = JSON.parse(res.body); assert.equal(res.statusCode, 200); - assert.deepEqual(body.queue[0].targetStates, { - opus: 'notified', - codex: 'failed', - gpt52: 'handled', + assert.equal(res.json().outcome, 'appended'); + assert.deepEqual(deps.queueProcessor.tryAutoAppendExactEntry.mock.calls[0].arguments[0], { + threadId: 't1', + userId: 'user-a', + entryId: queued.entry.id, + targetCatId: 'opus', }); - assert.deepEqual(body.queue[0].targetCats, ['opus', 'codex'], 'handled target must not be scheduled again'); + assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0); + const bound = deps.invocationQueue.getEntrySnapshot('t1', 'user-a', queued.entry.id); + assert.deepEqual(bound?.targets, ['opus']); + assert.equal(bound?.delivery.authorIntentByTarget?.opus?.requested, 'continue_current'); + assert.equal(bound?.delivery.authorIntentByTarget?.opus?.boundParentInvocationId, 'turn-1'); }); - it('DELETE /queue/:entryId returns 404 for another user entry', async () => { - const r = enqueueEntry(deps.invocationQueue, { userId: 'user-a' }); - // user-b tries to delete user-a's entry + it('POST /queue/:entryId/continue falls back to a new invocation when no reply is active', async () => { + const queued = await enqueueDurableEntry(deps.invocationQueue, { targetCats: [] }); + const res = await app.inject({ - method: 'DELETE', - url: `/api/threads/t1/queue/${r.entry.id}`, - headers: { 'x-cat-cafe-user': 'user-b' }, + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/continue`, + headers: { 'x-cat-cafe-user': 'user-a' }, + payload: { targetCatId: 'opus' }, }); - assert.equal(res.statusCode, 404); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { + outcome: 'queued', + targetCatId: 'opus', + effective: 'next_work', + reason: 'no_active_parent', + }); + assert.equal(deps.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); + assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0); + assert.equal(deps.queueProcessor.requestDrain.mock.calls.length, 1); + const bound = deps.invocationQueue.getEntrySnapshot('t1', 'user-a', queued.entry.id); + assert.deepEqual(bound?.targets, ['opus']); + assert.equal(bound?.delivery.authorIntentByTarget?.opus?.requested, 'continue_current'); + assert.equal(bound?.delivery.authorIntentByTarget?.opus?.fallbackReason, 'no_active_parent'); }); - it('DELETE /queue clears only requesting user entries', async () => { - enqueueEntry(deps.invocationQueue, { userId: 'user-a', targetCats: ['a'] }); - enqueueEntry(deps.invocationQueue, { userId: 'user-a', targetCats: ['b'] }); - enqueueEntry(deps.invocationQueue, { userId: 'user-b', targetCats: ['c'] }); + it('POST /queue/:entryId/continue keeps unsupported active carriers as next work without canceling', async () => { + const queued = await enqueueDurableEntry(deps.invocationQueue, { targetCats: [] }); + deps.invocationTracker.has.mock.mockImplementation((_threadId, catId) => catId === 'codex'); + deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); + deps.invocationTracker.getExecutionId.mock.mockImplementation(() => 'turn-kimi'); + deps.resolveCarrierCapability.mock.mockImplementation(() => ({ + provider: 'other', + carrier: 'other', + activeInvocationGuidance: 'unsupported', + deliverySemantics: 'unsupported', + })); const res = await app.inject({ - method: 'DELETE', - url: '/api/threads/t1/queue', + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/continue`, headers: { 'x-cat-cafe-user': 'user-a' }, + payload: { targetCatId: 'codex' }, }); - const body = JSON.parse(res.body); - assert.equal(body.cleared.length, 2); - // user-b's entry unaffected - assert.equal(deps.invocationQueue.list('t1', 'user-b').length, 1); - }); - - it('POST /queue/next only processes requesting user queue', async () => { - enqueueEntry(deps.invocationQueue, { userId: 'user-a' }); - enqueueEntry(deps.invocationQueue, { userId: 'user-b', targetCats: ['codex'] }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { + outcome: 'queued', + targetCatId: 'codex', + effective: 'next_work', + reason: 'unsupported_carrier', + }); + assert.equal(deps.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); + assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0); + assert.equal(deps.queueProcessor.requestDrain.mock.calls.length, 1); + const bound = deps.invocationQueue.getEntrySnapshot('t1', 'user-a', queued.entry.id); + assert.deepEqual(bound?.targets, ['codex']); + assert.equal(bound?.delivery.authorIntentByTarget?.codex?.requested, 'continue_current'); + assert.equal(bound?.delivery.authorIntentByTarget?.codex?.fallbackReason, 'unsupported_carrier'); + }); + + it('POST /queue/:entryId/targets atomically merges targets into one source row', async () => { + const messageId = 'message-steer-targets'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: ['opus'], + }); + assert.equal(await deps.invocationQueue.setPositionDurable('t1', 'user-a', queued.entry.id, 3), true); + mockSourceMessage(deps, messageId); - deps.queueProcessor.processNext.mock.mockImplementation(async (_threadId, userId) => { - // Simulate processing user's queue - return { started: true, entry: { userId } }; + const res = await app.inject({ + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, + headers: { 'x-cat-cafe-user': 'user-a' }, + payload: { + sourceRecordId: messageId, + observedPendingTargetIds: ['opus'], + targets: [ + { targetCatId: 'opus', strategy: 'guide_reply', membershipAtOpen: 'member' }, + { targetCatId: 'codex', strategy: 'interrupt_reply', membershipAtOpen: 'member' }, + ], + }, }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual( + res.json().targets.map((target) => ({ targetCatId: target.targetCatId, strategy: target.strategy })), + [ + { targetCatId: 'opus', strategy: 'guide_reply' }, + { targetCatId: 'codex', strategy: 'interrupt_reply' }, + ], + ); + const rows = deps.invocationQueue.list('t1', 'user-a'); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0].targets, ['opus', 'codex']); + assert.equal(rows[0].position, 3, 'target edits preserve source FIFO position'); + assert.equal(deps.queueProcessor.requestDrain.mock.calls.length, 1); + assert.deepEqual(deps.queueProcessor.registerCallerDispatchSteerChanges.mock.calls[0].arguments[1], { + addedTargetIds: ['codex'], + removedTargetIds: [], + }); + }); + + it('POST /queue/:entryId/targets atomically binds a targetless source row', async () => { + const messageId = 'message-steer-targetless-fanout'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: [], + }); + mockSourceMessage(deps, messageId); + + const payload = { + sourceRecordId: messageId, + observedPendingTargetIds: [], + targets: [ + { targetCatId: 'opus', strategy: 'guide_reply', membershipAtOpen: 'member' }, + { targetCatId: 'codex', strategy: 'interrupt_reply', membershipAtOpen: 'member' }, + ], + }; const res = await app.inject({ method: 'POST', - url: '/api/threads/t1/queue/next', + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, headers: { 'x-cat-cafe-user': 'user-a' }, + payload, }); - const body = JSON.parse(res.body); - assert.equal(body.started, true); - // Verify processNext was called with user-a - const call = deps.queueProcessor.processNext.mock.calls[0]; - assert.equal(call.arguments[0], 't1'); - assert.equal(call.arguments[1], 'user-a'); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual( + res.json().targets.map((target) => ({ + entryId: target.entryId, + targetCatId: target.targetCatId, + strategy: target.strategy, + })), + [ + { entryId: queued.entry.id, targetCatId: 'opus', strategy: 'guide_reply' }, + { entryId: queued.entry.id, targetCatId: 'codex', strategy: 'interrupt_reply' }, + ], + ); + const rows = deps.invocationQueue.list('t1', 'user-a'); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0].targets, ['opus', 'codex']); + + const replay = await app.inject({ + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, + headers: { 'x-cat-cafe-user': 'user-a' }, + payload, + }); + assert.equal(replay.statusCode, 200, replay.body); + assert.equal(deps.invocationQueue.list('t1', 'user-a').length, 1); }); - it('POST /queue/:entryId/remind persists a non-interrupting attempt for the exact active invocation', async () => { - const queued = enqueueEntry(deps.invocationQueue, { targetCats: ['opus'] }); - deps.invocationTracker.has.mock.mockImplementation(() => true); - deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); - deps.invocationTracker.getExecutionId.mock.mockImplementation(() => 'inv-active'); + it('POST /queue/:entryId/targets admits a newly selected available member into the thread at confirmation', async () => { + const messageId = 'message-steer-new-participant'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: [], + }); + mockSourceMessage(deps, messageId); const res = await app.inject({ method: 'POST', - url: `/api/threads/t1/queue/${queued.entry.id}/remind`, + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { targetCatId: 'opus' }, + payload: { + sourceRecordId: messageId, + observedPendingTargetIds: [], + targets: [{ targetCatId: 'kimi', strategy: 'interrupt_reply', membershipAtOpen: 'admit' }], + }, }); - const body = JSON.parse(res.body); - assert.equal(res.statusCode, 200); - assert.equal(body.state, 'requested'); - assert.equal(body.invocationId, 'inv-active'); - const call = deps.queueCustodyCoordinator.requestReminder.mock.calls[0]; - assert.equal(call.arguments[0].id, queued.entry.id); - assert.equal(call.arguments[1], 'opus'); - assert.equal(call.arguments[2], 'inv-active'); - assert.equal(typeof call.arguments[3], 'string'); - assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0, 'remind must never interrupt current work'); - assert.equal(deps.queueProcessor.processNext.mock.calls.length, 0, 'remind must never spawn or reorder work'); - }); - - it('POST /queue/:entryId/remind refuses to invent delivery when no invocation is active', async () => { - const queued = enqueueEntry(deps.invocationQueue, { targetCats: ['opus'] }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json().targets, [ + { entryId: queued.entry.id, targetCatId: 'kimi', strategy: 'interrupt_reply' }, + ]); + assert.deepEqual(deps.threadStore.addParticipants.mock.calls[0].arguments, ['t1', ['kimi']]); + assert.deepEqual((await deps.threadStore.get('t1')).participants, ['opus', 'codex', 'kimi']); + const membershipEvent = deps.socketManager.emitToUser.mock.calls.find( + (call) => call.arguments[1] === 'thread_updated', + ); + assert.deepEqual(membershipEvent?.arguments[2], { + threadId: 't1', + participants: ['opus', 'codex', 'kimi'], + }); + }); + + it('POST /queue/:entryId/targets skips a target that became unavailable', async () => { + const messageId = 'message-steer-unavailable'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: ['opus'], + }); + mockSourceMessage(deps, messageId); + deps.isCatAvailable.mock.mockImplementation((catId) => catId !== 'codex'); + const res = await app.inject({ method: 'POST', - url: `/api/threads/t1/queue/${queued.entry.id}/remind`, + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { targetCatId: 'opus' }, + payload: { + sourceRecordId: messageId, + observedPendingTargetIds: ['opus'], + targets: [ + { targetCatId: 'opus', strategy: 'guide_reply', membershipAtOpen: 'member' }, + { targetCatId: 'codex', strategy: 'interrupt_reply', membershipAtOpen: 'member' }, + ], + }, }); - assert.equal(res.statusCode, 409); - assert.equal(JSON.parse(res.body).code, 'NO_ACTIVE_INVOCATION'); - assert.equal(deps.queueCustodyCoordinator.requestReminder.mock.calls.length, 0); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json().targets, [{ targetCatId: 'opus', strategy: 'guide_reply', entryId: queued.entry.id }]); + assert.deepEqual(deps.invocationQueue.list('t1', 'user-a')[0].targets, ['opus']); }); - it('POST /queue/:entryId/remind fails closed for unsupported and undeclared carriers', async () => { - const queued = enqueueEntry(deps.invocationQueue, { targetCats: ['opus'] }); - deps.invocationTracker.has.mock.mockImplementation(() => true); - deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); - deps.invocationTracker.getExecutionId.mock.mockImplementation(() => 'inv-active'); - deps.resolveCarrierCapability.mock.mockImplementationOnce(() => ({ + it('POST /queue/:entryId/targets preserves an unsupported guide request as next-work intent', async () => { + const messageId = 'message-steer-unsupported'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: ['opus'], + }); + mockSourceMessage(deps, messageId); + deps.resolveCarrierCapability.mock.mockImplementation(() => ({ provider: 'anthropic', carrier: 'claude_print_sdk', + activeInvocationGuidance: 'unsupported', deliverySemantics: 'unsupported', })); - const unsupported = await app.inject({ + const res = await app.inject({ method: 'POST', - url: `/api/threads/t1/queue/${queued.entry.id}/remind`, + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { targetCatId: 'opus' }, + payload: { + sourceRecordId: messageId, + observedPendingTargetIds: ['opus'], + targets: [{ targetCatId: 'opus', strategy: 'guide_reply', membershipAtOpen: 'member' }], + }, }); - deps.resolveCarrierCapability.mock.mockImplementation(() => undefined); - const undeclared = await app.inject({ + + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json().targets, [{ targetCatId: 'opus', strategy: 'guide_reply', entryId: queued.entry.id }]); + }); + + it('POST /queue/:entryId/targets treats a target delivered while the modal was open as an idempotent no-op', async () => { + const messageId = 'message-steer-terminal'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: ['opus', 'codex'], + }); + const source = mockSourceMessage(deps, messageId, { + lifecycle: { + kind: 'source', + sourceId: messageId, + dispatchRefs: [ + { + targetId: 'opus', + phase: 'dispatched', + statusMessageId: 'response-opus', + dispatchedAt: 1234, + }, + ], + }, + }); + const claimed = await deps.invocationQueue.claimExactSteerEntryDurable('t1', 'user-a', queued.entry.id, 'opus'); + assert.equal(claimed.outcome, 'claimed'); + assert.ok(await deps.invocationQueue.commitClaimedAdoptionDurable('t1', 'user-a', queued.entry.id, 'opus')); + assert.equal(source.lifecycle.dispatchRefs[0].targetId, 'opus'); + + const res = await app.inject({ method: 'POST', - url: `/api/threads/t1/queue/${queued.entry.id}/remind`, + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { targetCatId: 'opus' }, + payload: { + sourceRecordId: messageId, + observedPendingTargetIds: ['opus', 'codex'], + targets: [ + { targetCatId: 'opus', strategy: 'interrupt_reply', membershipAtOpen: 'member' }, + { targetCatId: 'codex', strategy: 'interrupt_reply', membershipAtOpen: 'member' }, + ], + }, }); - assert.equal(unsupported.statusCode, 409); - assert.equal(JSON.parse(unsupported.body).code, 'REMINDER_UNSUPPORTED_CARRIER'); - assert.equal(undeclared.statusCode, 409); - assert.equal(JSON.parse(undeclared.body).code, 'REMINDER_CAPABILITY_UNDECLARED'); - assert.equal(deps.queueCustodyCoordinator.requestReminder.mock.calls.length, 0); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json().skippedAlreadyDispatched, ['opus']); + assert.deepEqual(res.json().targets, [ + { targetCatId: 'codex', strategy: 'interrupt_reply', entryId: queued.entry.id }, + ]); + assert.deepEqual(deps.invocationQueue.list('t1', 'user-a')[0].targets, ['codex']); }); - it('POST /queue/:entryId/remind returns the existing exact attempt idempotently', async () => { - const queued = enqueueEntry(deps.invocationQueue, { targetCats: ['opus'] }); - deps.invocationTracker.has.mock.mockImplementation(() => true); - deps.invocationTracker.getUserId.mock.mockImplementation(() => 'user-a'); - deps.invocationTracker.getExecutionId.mock.mockImplementation(() => 'inv-active'); - deps.queueCustodyCoordinator.findReminderAttempt.mock.mockImplementation(async () => ({ - id: 'reminder-existing', - targetCatId: 'opus', - invocationId: 'inv-active', - state: 'delivered', - requestedAt: 1, - deliveredAt: 2, + it('GET /queue/:entryId/targets joins pending Queue targets with delivered History refs', async () => { + const messageId = 'message-steer-target-context'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: ['opus', 'codex'], + }); + mockSourceMessage(deps, messageId, { + lifecycle: { + kind: 'source', + sourceId: messageId, + dispatchRefs: [ + { + targetId: 'opus', + phase: 'settled', + statusMessageId: 'response-opus', + dispatchedAt: 1234, + }, + ], + }, + }); + const claimed = await deps.invocationQueue.claimExactSteerEntryDurable('t1', 'user-a', queued.entry.id, 'opus'); + assert.equal(claimed.outcome, 'claimed'); + assert.ok(await deps.invocationQueue.commitClaimedAdoptionDurable('t1', 'user-a', queued.entry.id, 'opus')); + + const res = await app.inject({ + method: 'GET', + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, + headers: { 'x-cat-cafe-user': 'user-a' }, + }); + + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { + sourceRecordId: messageId, + targets: [ + { targetCatId: 'codex', state: 'pending', actionable: true }, + { + targetCatId: 'opus', + state: 'settled', + actionable: false, + dispatchedAt: 1234, + statusMessageId: 'response-opus', + }, + ], + }); + }); + + it('POST /queue/:entryId/targets skips a member removed after the modal snapshot', async () => { + const messageId = 'message-steer-removed-member'; + const queued = await enqueueDurableEntry(deps.invocationQueue, { + sourceId: messageId, + messageId, + targetCats: ['opus'], + }); + mockSourceMessage(deps, messageId); + deps.threadStore.get.mock.mockImplementation(async (id) => ({ + id, + title: 'Test Thread', + createdBy: 'system', + participants: ['codex'], })); const res = await app.inject({ method: 'POST', - url: `/api/threads/t1/queue/${queued.entry.id}/remind`, + url: `/api/threads/t1/queue/${queued.entry.id}/targets`, + headers: { 'x-cat-cafe-user': 'user-a' }, + payload: { + sourceRecordId: messageId, + observedPendingTargetIds: ['opus'], + targets: [{ targetCatId: 'opus', strategy: 'interrupt_reply', membershipAtOpen: 'member' }], + }, + }); + + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json().targets, []); + assert.equal(deps.invocationQueue.list('t1', 'user-a').length, 0); + assert.equal(deps.threadStore.addParticipants.mock.calls.length, 0); + assert.deepEqual(deps.queueProcessor.registerCallerDispatchSteerChanges.mock.calls[0].arguments[1], { + addedTargetIds: [], + removedTargetIds: ['opus'], + }); + }); + + it('GET /queue projects one pending row with the complete target set', async () => { + const queued = enqueueEntry(deps.invocationQueue, { + content: 'three targets', + targetCats: ['opus', 'codex', 'gpt52'], + messageId: 'msg-1', + }); + const res = await app.inject({ + method: 'GET', + url: '/api/threads/t1/queue', headers: { 'x-cat-cafe-user': 'user-a' }, - payload: { targetCatId: 'opus' }, }); const body = JSON.parse(res.body); assert.equal(res.statusCode, 200); - assert.equal(body.reminderId, 'reminder-existing'); - assert.equal(body.state, 'delivered'); - assert.equal(body.idempotent, true); - assert.equal(deps.queueCustodyCoordinator.requestReminder.mock.calls.length, 0); + assert.equal(body.queue.length, 1); + assert.deepEqual(body.queue[0].targetCats, ['opus', 'codex', 'gpt52']); + assert.equal('targetStates' in body.queue[0], false); }); - // ── Functional: GET paused state (P1-2 fix) ── + it('DELETE /queue/:entryId returns 404 for another user entry', async () => { + const r = enqueueEntry(deps.invocationQueue, { userId: 'user-a' }); + // user-b tries to delete user-a's entry + const res = await app.inject({ + method: 'DELETE', + url: `/api/threads/t1/queue/${r.entry.id}`, + headers: { 'x-cat-cafe-user': 'user-b' }, + }); + assert.equal(res.statusCode, 404); + }); - it('GET /queue returns paused=true when queueProcessor reports paused', async () => { - enqueueEntry(deps.invocationQueue); - // Stub isPaused to return true - deps.queueProcessor.isPaused = mock.fn(() => true); - deps.queueProcessor.getPauseReason = mock.fn(() => 'canceled'); + it('DELETE /queue clears only requesting user entries', async () => { + enqueueEntry(deps.invocationQueue, { userId: 'user-a', targetCats: ['a'] }); + enqueueEntry(deps.invocationQueue, { userId: 'user-a', targetCats: ['b'] }); + enqueueEntry(deps.invocationQueue, { userId: 'user-b', targetCats: ['c'] }); const res = await app.inject({ - method: 'GET', + method: 'DELETE', url: '/api/threads/t1/queue', headers: { 'x-cat-cafe-user': 'user-a' }, }); const body = JSON.parse(res.body); - assert.equal(body.paused, true); + assert.equal(body.cleared.length, 2); + + // user-b's entry unaffected + assert.equal(deps.invocationQueue.list('t1', 'user-b').length, 1); }); - it('GET /queue returns paused=false when queueProcessor reports not paused', async () => { - enqueueEntry(deps.invocationQueue); - deps.queueProcessor.isPaused = mock.fn(() => false); - deps.queueProcessor.getPauseReason = mock.fn(() => undefined); + it('does not expose a thread-wide manual Continue endpoint', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/threads/t1/queue/next', + headers: { 'x-cat-cafe-user': 'user-a' }, + }); + assert.equal(res.statusCode, 404); + }); + it('does not expose a second manual reminder delivery endpoint', async () => { + const queued = enqueueEntry(deps.invocationQueue, { targetCats: ['opus'] }); const res = await app.inject({ - method: 'GET', - url: '/api/threads/t1/queue', + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/remind`, headers: { 'x-cat-cafe-user': 'user-a' }, + payload: { targetCatId: 'opus' }, }); - const body = JSON.parse(res.body); - assert.equal(body.paused, false); + assert.equal(res.statusCode, 404); }); - it('GET /queue returns pauseReason when paused', async () => { + it('GET /queue does not project thread-wide pause state', async () => { enqueueEntry(deps.invocationQueue); - deps.queueProcessor.isPaused = mock.fn(() => true); - deps.queueProcessor.getPauseReason = mock.fn(() => 'failed'); const res = await app.inject({ method: 'GET', @@ -726,8 +881,8 @@ describe('Queue Management API', () => { headers: { 'x-cat-cafe-user': 'user-a' }, }); const body = JSON.parse(res.body); - assert.equal(body.paused, true); - assert.equal(body.pauseReason, 'failed'); + assert.equal(Object.hasOwn(body, 'paused'), false); + assert.equal(Object.hasOwn(body, 'pauseReason'), false); }); // ── Functional: GET ── @@ -748,37 +903,11 @@ describe('Queue Management API', () => { // ── Functional: DELETE entry ── - it('DELETE /queue/:entryId withdraws actionable custody without deleting author history', async () => { + it('DELETE /queue/:entryId withdraws the pending row and cancels its hidden source', async () => { const r = enqueueEntry(deps.invocationQueue, { ownerAuthProvenance: 'strict', messageId: 'msg-withdrawn', }); - deps.invocationQueue.backfillMessageId('t1', 'user-a', r.entry.id, 'msg-withdrawn-merged'); - const terminalCustody = { - version: 1, - entryId: r.entry.id, - revision: 2, - ownerAuthProvenance: 'strict', - intent: 'execute', - status: 'terminal', - allTargetCats: ['opus'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - withdrawnByCatIds: ['opus'], - withdrawnAtByCatId: { opus: 20 }, - priority: 'normal', - createdAt: r.entry.createdAt, - updatedAt: 20, - }; - deps.messageStore.getById.mock.mockImplementation(async (messageId) => - messageId === 'msg-withdrawn' || messageId === 'msg-withdrawn-merged' - ? { id: messageId, queueCustody: terminalCustody } - : null, - ); const res = await app.inject({ method: 'DELETE', @@ -795,24 +924,18 @@ describe('Queue Management API', () => { assert.ok(updateCall); assert.equal(updateCall.arguments[2].action, 'removed'); assert.deepEqual(updateCall.arguments[2].queue, []); - assert.deepEqual( - updateCall.arguments[2].messageReceipts.map((projection) => projection.messageId), - ['msg-withdrawn', 'msg-withdrawn-merged'], - ); - assert.deepEqual(updateCall.arguments[2].messageReceipts[0].queueReceipt.targets, [ - { catId: 'opus', state: 'withdrawn', withdrawnAt: 20 }, - ]); - assert.equal(deps.queueCustodyCoordinator.withdrawEntry.mock.calls.length, 1); - assert.equal(deps.queueCustodyCoordinator.withdrawEntry.mock.calls[0].arguments[0].id, r.entry.id); - assert.equal(deps.messageStore.markCanceled.mock.calls.length, 0); + assert.equal('messageReceipts' in updateCall.arguments[2], false); + assert.deepEqual(deps.messageStore.markCanceled.mock.calls[0].arguments, ['msg-withdrawn']); + assert.equal(deps.queueProcessor.registerCallerDispatchQueueWithdrawal.mock.calls.length, 1); + assert.equal(deps.queueProcessor.registerCallerDispatchQueueWithdrawal.mock.calls[0].arguments[0].id, r.entry.id); const deleted = deps.socketManager.emitToUser.mock.calls.find((call) => call.arguments[1] === 'message_deleted'); assert.equal(deleted, undefined); }); it('DELETE /queue/:entryId restores the actionable entry when durable withdrawal fails', async () => { - const r = enqueueEntry(deps.invocationQueue, { ownerAuthProvenance: 'strict' }); - deps.queueCustodyCoordinator.withdrawEntry.mock.mockImplementation(async () => { - throw new Error('custody store unavailable'); + const r = enqueueEntry(deps.invocationQueue, { ownerAuthProvenance: 'strict', messageId: 'msg-fail' }); + deps.messageStore.markCanceled.mock.mockImplementation(async () => { + throw new Error('message store unavailable'); }); const res = await app.inject({ @@ -833,12 +956,12 @@ describe('Queue Management API', () => { ); }); - it('DELETE /queue/:entryId rejects processing entry (409)', async () => { + it('DELETE /queue/:entryId rejects a live reversible claim (409)', async () => { enqueueEntry(deps.invocationQueue); - deps.invocationQueue.markProcessing('t1', 'user-a'); + await markHeadProcessing(deps.invocationQueue); const entries = deps.invocationQueue.list('t1', 'user-a'); - const processingEntry = entries.find((e) => e.status === 'processing'); + const processingEntry = entries.find((e) => e.status === 'claimed'); const res = await app.inject({ method: 'DELETE', @@ -848,98 +971,15 @@ describe('Queue Management API', () => { assert.equal(res.statusCode, 409); }); - // ── Functional: POST next ── - - it('POST /queue/next triggers next entry processing', async () => { - deps.queueProcessor.processNext.mock.mockImplementation(async () => ({ - started: true, - entry: { - ...enqueueEntry(deps.invocationQueue, { ownerAuthProvenance: 'strict' }).entry, - id: 'e1', - }, - })); - - const res = await app.inject({ - method: 'POST', - url: '/api/threads/t1/queue/next', - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - const body = JSON.parse(res.body); - assert.equal(body.started, true); - assert.equal(Object.hasOwn(body.entry, 'ownerAuthProvenance'), false); - }); - - it('POST /queue/next returns started=false when empty', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/threads/t1/queue/next', - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - const body = JSON.parse(res.body); - assert.equal(body.started, false); - }); - // ── Functional: DELETE clear ── it('DELETE /queue clears all entries for user', async () => { - const first = enqueueEntry(deps.invocationQueue, { + enqueueEntry(deps.invocationQueue, { targetCats: ['a'], ownerAuthProvenance: 'strict', messageId: 'msg-clear-a', }); - const second = enqueueEntry(deps.invocationQueue, { targetCats: ['b'], messageId: 'msg-clear-b' }); - const custodyByMessageId = new Map([ - [ - 'msg-clear-a', - { - version: 1, - entryId: first.entry.id, - revision: 2, - ownerAuthProvenance: 'strict', - intent: 'execute', - status: 'terminal', - allTargetCats: ['a'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - withdrawnByCatIds: ['a'], - withdrawnAtByCatId: { a: 30 }, - priority: 'normal', - createdAt: first.entry.createdAt, - updatedAt: 30, - }, - ], - [ - 'msg-clear-b', - { - version: 1, - entryId: second.entry.id, - revision: 2, - ownerAuthProvenance: 'unknown', - intent: 'execute', - status: 'terminal', - allTargetCats: ['b'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - withdrawnByCatIds: ['b'], - withdrawnAtByCatId: { b: 31 }, - priority: 'normal', - createdAt: second.entry.createdAt, - updatedAt: 31, - }, - ], - ]); - deps.messageStore.getById.mock.mockImplementation(async (messageId) => { - const queueCustody = custodyByMessageId.get(messageId); - return queueCustody ? { id: messageId, queueCustody } : null; - }); + enqueueEntry(deps.invocationQueue, { targetCats: ['b'], messageId: 'msg-clear-b' }); const res = await app.inject({ method: 'DELETE', @@ -959,22 +999,23 @@ describe('Queue Management API', () => { const updateCall = emitCalls.find((c) => c.arguments[1] === 'queue_updated'); assert.ok(updateCall); assert.equal(updateCall.arguments[2].action, 'cleared'); + assert.equal('messageReceipts' in updateCall.arguments[2], false); + assert.deepEqual( + deps.messageStore.markCanceled.mock.calls.map((call) => call.arguments[0]), + ['msg-clear-a', 'msg-clear-b'], + ); assert.deepEqual( - updateCall.arguments[2].messageReceipts.map((projection) => projection.messageId), + deps.queueProcessor.registerCallerDispatchQueueWithdrawal.mock.calls.map( + (call) => call.arguments[0].payload.messageId, + ), ['msg-clear-a', 'msg-clear-b'], ); - assert.equal(deps.queueCustodyCoordinator.withdrawEntry.mock.calls.length, 2); - assert.equal(deps.messageStore.markCanceled.mock.calls.length, 0); const deleted = deps.socketManager.emitToUser.mock.calls.find((call) => call.arguments[1] === 'message_deleted'); assert.equal(deleted, undefined); }); it('DELETE /queue retires each exact managed-wake producer after durable carrier withdrawal', async () => { const events = []; - deps.queueCustodyCoordinator.withdrawEntry.mock.mockImplementation(async (entry) => { - events.push(`withdraw:${entry.id}`); - return true; - }); deps.managedCommandWakeRecovery.retireCarrier.mock.mockImplementation(async (messageIds, reason) => { events.push(`retire:${messageIds.join(',')}:${reason}`); return 1; @@ -993,19 +1034,23 @@ describe('Queue Management API', () => { }); assert.equal(res.statusCode, 200, res.body); - assert.deepEqual(events, [`withdraw:${entry.id}`, 'retire:message-managed-wake:withdrawn']); + assert.deepEqual(events, ['retire:message-managed-wake:withdrawn']); assert.equal(deps.invocationQueue.list('t1', 'user-a').length, 0); }); it('DELETE /queue reports partial durable withdrawal and keeps every unsettled entry actionable', async () => { - const first = enqueueEntry(deps.invocationQueue, { targetCats: ['a'], ownerAuthProvenance: 'strict' }); - const second = enqueueEntry(deps.invocationQueue, { targetCats: ['b'] }); - const third = enqueueEntry(deps.invocationQueue, { targetCats: ['c'] }); + const first = enqueueEntry(deps.invocationQueue, { + targetCats: ['a'], + ownerAuthProvenance: 'strict', + messageId: 'msg-partial-a', + }); + const second = enqueueEntry(deps.invocationQueue, { targetCats: ['b'], messageId: 'msg-partial-b' }); + const third = enqueueEntry(deps.invocationQueue, { targetCats: ['c'], messageId: 'msg-partial-c' }); let calls = 0; - deps.queueCustodyCoordinator.withdrawEntry.mock.mockImplementation(async () => { + deps.messageStore.markCanceled.mock.mockImplementation(async () => { calls += 1; - if (calls === 2) throw new Error('custody store unavailable'); - return true; + if (calls === 2) throw new Error('message store unavailable'); + return { deliveryStatus: 'canceled', deliveryTransitioned: true }; }); const res = await app.inject({ @@ -1042,8 +1087,8 @@ describe('Queue Management API', () => { assert.equal(res.statusCode, 200); const queue = deps.invocationQueue.list('t1', 'user-a'); - assert.equal(queue[0].content, 'second'); - assert.equal(queue[1].content, 'first'); + assert.equal(queue[0].payload.content, 'second'); + assert.equal(queue[1].payload.content, 'first'); // Should emit queue_updated with action='reordered' const emitCalls = deps.socketManager.emitToUser.mock.calls; @@ -1065,15 +1110,15 @@ describe('Queue Management API', () => { assert.equal(res.statusCode, 200); const queue = deps.invocationQueue.list('t1', 'user-a'); - assert.equal(queue[0].content, 'second'); + assert.equal(queue[0].payload.content, 'second'); }); - it('PATCH /queue/:entryId/move rejects processing entry (409)', async () => { + it('PATCH /queue/:entryId/move rejects a live reversible claim (409)', async () => { enqueueEntry(deps.invocationQueue); - deps.invocationQueue.markProcessing('t1', 'user-a'); + await markHeadProcessing(deps.invocationQueue); const entries = deps.invocationQueue.list('t1', 'user-a'); - const processingEntry = entries.find((e) => e.status === 'processing'); + const processingEntry = entries.find((e) => e.status === 'claimed'); const res = await app.inject({ method: 'PATCH', @@ -1109,55 +1154,20 @@ describe('Queue Management API', () => { // ── Functional: POST steer ── - it('POST /queue/steer-batch reserves exact A+B before one cancel and never marks unselected C', async () => { - const a = enqueueEntry(deps.invocationQueue, { content: 'a', ownerAuthProvenance: 'strict' }).entry; - const b = enqueueEntry(deps.invocationQueue, { content: 'b', ownerAuthProvenance: 'strict' }).entry; - const c = enqueueEntry(deps.invocationQueue, { content: 'c', ownerAuthProvenance: 'strict' }).entry; - deps.invocationTracker.has = mock.fn(() => true); - deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); - deps.invocationTracker.cancel = mock.fn(() => { - const byId = new Map(deps.invocationQueue.list('t1', 'user-a').map((entry) => [entry.id, entry])); - assert.deepEqual(byId.get(a.id).steerRequestedByCatIds, ['opus'], 'A reserved before cancel'); - assert.deepEqual(byId.get(b.id).steerRequestedByCatIds, ['opus'], 'B reserved before cancel'); - assert.equal(byId.get(c.id).steerRequestedByCatIds, undefined, 'C remains outside reservation'); - return { cancelled: true, catIds: ['opus'], executionIds: ['inv-active'] }; - }); - deps.queueProcessor.processNext = mock.fn(async () => ({ started: true, entry: a })); - - const res = await app.inject({ - method: 'POST', - url: '/api/threads/t1/queue/steer-batch', - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: { entryIds: [a.id, b.id] }, - }); - - assert.equal(res.statusCode, 200); - assert.deepEqual(res.json().entryIds, [a.id, b.id]); - assert.equal(deps.invocationTracker.cancel.mock.calls.length, 1); - assert.equal(deps.queueProcessor.processNext.mock.calls.length, 1); - assert.equal(deps.queueCustodyCoordinator.persistEntry.mock.calls.length, 2); - - const projected = await app.inject({ - method: 'GET', - url: '/api/threads/t1/queue', - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - assert.equal(projected.statusCode, 200); - assert.ok(projected.json().queue.every((entry) => !Object.hasOwn(entry, 'exactSteerBatch'))); - }); - - it('POST /queue/steer does not preempt when the reservation CAS loses', async () => { - // Single-entry steer used to preempt first and reserve second, so a losing - // CAS returned STEER_STATE_CHANGED *after* the running turn was already - // cancelled: the user lost the work, did not get the steer, and was told to - // retry. steer-batch already reserved before cancelling; this makes the - // single path agree. - const target = enqueueEntry(deps.invocationQueue, { content: 'steer me', ownerAuthProvenance: 'strict' }).entry; + it('POST /queue/steer does not preempt when the durable claim loses', async () => { + const target = ( + await enqueueDurableEntry(deps.invocationQueue, { + content: 'steer me', + ownerAuthProvenance: 'strict', + }) + ).entry; deps.invocationTracker.has = mock.fn(() => true); deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); deps.invocationTracker.cancel = mock.fn(() => ({ cancelled: true, catIds: ['opus'], executionIds: ['inv-a'] })); - // The entry moves out from under the request between lookup and reservation. - deps.invocationQueue.reserveExactUserEntry = mock.fn(() => ({ outcome: 'rejected', reason: 'state_changed' })); + deps.invocationQueue.claimExactSteerEntryDurable = mock.fn(async () => ({ + outcome: 'rejected', + reason: 'entry_processing', + })); const res = await app.inject({ method: 'POST', @@ -1167,7 +1177,7 @@ describe('Queue Management API', () => { }); assert.equal(res.statusCode, 409); - assert.equal(res.json().code, 'STEER_STATE_CHANGED'); + assert.equal(res.json().code, 'ENTRY_PROCESSING'); assert.equal( deps.invocationTracker.cancel.mock.calls.length, 0, @@ -1175,144 +1185,106 @@ describe('Queue Management API', () => { ); }); - it('POST /queue/:entryId/steer selects a pending sibling instead of a failed target', async () => { - const queued = enqueueEntry(deps.invocationQueue, { - content: 'steer the pending sibling', + it('POST /queue/:entryId/steer binds a targetless message to the selected current-thread member', async () => { + const queued = await enqueueDurableEntry(deps.invocationQueue, { + content: 'pick one member', ownerAuthProvenance: 'strict', - targetCats: ['opus', 'codex'], + targetCats: [], }); - deps.invocationQueue.markQueuedFailedForCatAcrossUsers('t1', 'opus', 'inv-opus', new Set([queued.entry.id])); - deps.invocationTracker.has = mock.fn((_threadId, catId) => catId === 'codex'); - deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); - deps.invocationTracker.cancel = mock.fn((_threadId, catId) => ({ - cancelled: true, - catIds: [catId], - executionIds: ['inv-codex'], - })); - deps.queueProcessor.processNext = mock.fn(async () => ({ started: true, entry: queued.entry })); const res = await app.inject({ method: 'POST', url: `/api/threads/t1/queue/${queued.entry.id}/steer`, headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: {}, + payload: { targetCatId: 'codex' }, }); assert.equal(res.statusCode, 200); - assert.equal(deps.invocationTracker.cancel.mock.calls[0].arguments[1], 'codex'); - assert.deepEqual(deps.invocationQueue.getEntrySnapshot('t1', 'user-a', queued.entry.id)?.steerRequestedByCatIds, [ + assert.deepEqual(deps.queueProcessor.processClaimedSteerEntries.mock.calls[0].arguments, [ + 't1', + 'user-a', + [queued.entry.id], 'codex', ]); }); - it('POST /queue/:entryId/steer does not preempt when reservation persistence fails', async () => { - // Reserving only in memory just moved the split transaction: a rejected - // durable write would still have landed after the running turn was killed. - // steer-batch persists its reservation before preempting; this must match. - const target = enqueueEntry(deps.invocationQueue, { content: 'steer me', ownerAuthProvenance: 'strict' }).entry; - deps.invocationTracker.has = mock.fn(() => true); - deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); - deps.invocationTracker.cancel = mock.fn(() => ({ cancelled: true, catIds: ['opus'], executionIds: ['inv-a'] })); - deps.queueCustodyCoordinator.persistEntry = mock.fn(async () => { - throw new Error('durable reservation rejected'); - }); + it('POST /queue/:entryId/steer rejects a targetless message without a current-thread member selection', async () => { + const queued = await enqueueDurableEntry(deps.invocationQueue, { targetCats: [] }); const res = await app.inject({ method: 'POST', - url: `/api/threads/t1/queue/${target.id}/steer`, + url: `/api/threads/t1/queue/${queued.entry.id}/steer`, headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, payload: {}, }); - assert.equal(res.statusCode, 503); - assert.equal(res.json().code, 'STEER_RESERVATION_PERSIST_FAILED'); - assert.equal( - deps.invocationTracker.cancel.mock.calls.length, - 0, - 'an unpersistable reservation must not have cancelled the running turn', - ); - const after = deps.invocationQueue.list('t1', 'user-a').find((entry) => entry.id === target.id); - assert.equal(after.steerRequestedByCatIds, undefined, 'the local reservation marker must be rolled back'); + assert.equal(res.statusCode, 400); + assert.equal(res.json().code, 'STEER_TARGET_REQUIRED'); }); - it('POST /queue/:entryId/steer fences ordinary dequeue while its durable reservation is persisting', async () => { - const target = enqueueEntry(deps.invocationQueue, { - content: 'steer me', - ownerAuthProvenance: 'strict', - }).entry; - const persistGate = asyncGate(); - let firstPersist = true; - deps.queueCustodyCoordinator.persistEntry = mock.fn(async () => { - if (!firstPersist) return; - firstPersist = false; - persistGate.enter(); - await persistGate.blocked; + it('POST /queue/:entryId/steer rejects a member outside the current thread before reservation', async () => { + const queued = await enqueueDurableEntry(deps.invocationQueue, { targetCats: [] }); + + const res = await app.inject({ + method: 'POST', + url: `/api/threads/t1/queue/${queued.entry.id}/steer`, + headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, + payload: { targetCatId: 'outsider' }, }); + + assert.equal(res.statusCode, 400); + assert.equal(res.json().code, 'INVALID_STEER_TARGET'); + assert.equal(deps.invocationQueue.getEntrySnapshot('t1', 'user-a', queued.entry.id)?.status, 'queued'); + }); + + it('POST /queue/:entryId/steer does not preempt when the durable claim store fails', async () => { + const target = ( + await enqueueDurableEntry(deps.invocationQueue, { + content: 'steer me', + ownerAuthProvenance: 'strict', + }) + ).entry; deps.invocationTracker.has = mock.fn(() => true); deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); - deps.invocationTracker.cancel = mock.fn(() => ({ - cancelled: true, - catIds: ['opus'], - executionIds: ['inv-active'], - })); - deps.queueProcessor.processNext = mock.fn(async () => ({ started: true, entry: target })); + deps.invocationTracker.cancel = mock.fn(() => ({ cancelled: true, catIds: ['opus'], executionIds: ['inv-a'] })); + deps.invocationQueue.claimExactSteerEntryDurable = mock.fn(async () => { + throw new Error('ledger unavailable'); + }); - const responsePromise = app.inject({ + const res = await app.inject({ method: 'POST', url: `/api/threads/t1/queue/${target.id}/steer`, headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, payload: {}, }); - await persistGate.entered; - - assert.equal( - deps.invocationQueue.markProcessing('t1', 'user-a'), - null, - 'ordinary dequeue must not claim the entry while Steer awaits durable persistence', - ); - assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0, 'preemption has not begun before persistence'); - - const [withdraw, clear] = await Promise.all([ - app.inject({ - method: 'DELETE', - url: `/api/threads/t1/queue/${target.id}`, - headers: { 'x-cat-cafe-user': 'user-a' }, - }), - app.inject({ - method: 'DELETE', - url: '/api/threads/t1/queue', - headers: { 'x-cat-cafe-user': 'user-a' }, - }), - ]); - assert.equal(withdraw.statusCode, 409); - assert.equal(withdraw.json().code, 'ENTRY_STEERING'); - assert.equal(clear.statusCode, 409); - assert.equal(clear.json().code, 'ENTRY_STEERING'); - persistGate.release(); - const res = await responsePromise; - assert.equal(res.statusCode, 200); - const processExactArgs = deps.queueProcessor.processExactSteerReservation.mock.calls[0].arguments; - assert.deepEqual(processExactArgs.slice(0, 3), ['t1', 'user-a', target.id]); + assert.equal(res.statusCode, 503); + assert.equal(res.json().code, 'STEER_CLAIM_FAILED'); assert.equal( - processExactArgs[3], - deps.invocationQueue.getEntrySnapshot('t1', 'user-a', target.id).exactSteerBatch.reservationId, - 'the route must pass the same exact reservation identity across the persistence await', + deps.invocationTracker.cancel.mock.calls.length, + 0, + 'an unpersistable reservation must not have cancelled the running turn', ); - }); - - it('POST /queue/:entryId/steer retains exact ownership across the preemption await', async () => { - const inflight = enqueueEntry(deps.invocationQueue, { - content: 'inflight', - ownerAuthProvenance: 'strict', - targetCats: ['opus'], - }).entry; - deps.invocationQueue.markProcessing('t1', 'user-a'); - const target = enqueueEntry(deps.invocationQueue, { - content: 'steer after preemption', - ownerAuthProvenance: 'strict', - targetCats: ['opus'], - }).entry; + const after = deps.invocationQueue.list('t1', 'user-a').find((entry) => entry.id === target.id); + assert.equal(after.status, 'queued'); + }); + + it('POST /queue/:entryId/steer keeps the ledger claim across the preemption await', async () => { + const inflight = ( + await enqueueDurableEntry(deps.invocationQueue, { + content: 'inflight', + ownerAuthProvenance: 'strict', + targetCats: ['opus'], + }) + ).entry; + await markHeadProcessing(deps.invocationQueue); + const target = ( + await enqueueDurableEntry(deps.invocationQueue, { + content: 'steer after preemption', + ownerAuthProvenance: 'strict', + targetCats: ['opus'], + }) + ).entry; const preemptionGate = asyncGate(); deps.invocationTracker.has = mock.fn(() => false); deps.queueProcessor.finalizeRemovedEntry = mock.fn(async (removed) => { @@ -1321,11 +1293,6 @@ describe('Queue Management API', () => { await preemptionGate.blocked; return true; }); - deps.queueProcessor.processExactSteerReservation = mock.fn(async (threadId, userId, entryId, reservationId) => ({ - started: Boolean(deps.invocationQueue.claimExactSteerReservation(threadId, userId, entryId, reservationId)), - entry: deps.invocationQueue.getEntrySnapshot(threadId, userId, entryId), - })); - const responsePromise = app.inject({ method: 'POST', url: `/api/threads/t1/queue/${target.id}/steer`, @@ -1334,21 +1301,27 @@ describe('Queue Management API', () => { }); await preemptionGate.entered; - assert.equal(deps.invocationQueue.markProcessing('t1', 'user-a'), null); - assert.equal(deps.invocationQueue.markProcessingById('t1', target.id), false); + assert.equal(deps.invocationQueue.getEntrySnapshot('t1', 'user-a', target.id)?.status, 'claimed'); + assert.equal(await deps.invocationQueue.markProcessingByIdDurable('t1', target.id, 'opus'), null); preemptionGate.release(); const res = await responsePromise; assert.equal(res.statusCode, 200); - const reserved = deps.invocationQueue.getEntrySnapshot('t1', 'user-a', target.id); - const reservationId = reserved.exactSteerBatch.reservationId; - assert.equal(deps.invocationQueue.claimExactSteerReservation('t1', 'user-a', target.id, 'wrong'), null); - assert.equal(reserved.status, 'processing'); - assert.equal(deps.queueProcessor.processExactSteerReservation.mock.calls[0].arguments[3], reservationId); + assert.deepEqual(deps.queueProcessor.processClaimedSteerEntries.mock.calls[0].arguments, [ + 't1', + 'user-a', + [target.id], + 'opus', + ]); }); - it('POST /queue/:entryId/steer durably clears the reservation when preemption is refused', async () => { - const target = enqueueEntry(deps.invocationQueue, { content: 'steer me', ownerAuthProvenance: 'strict' }).entry; + it('POST /queue/:entryId/steer restores the claimed row when preemption is refused', async () => { + const target = ( + await enqueueDurableEntry(deps.invocationQueue, { + content: 'steer me', + ownerAuthProvenance: 'strict', + }) + ).entry; deps.invocationTracker.has = mock.fn(() => true); // Another user owns the running turn, so preemption is refused after the // reservation has already been persisted. @@ -1365,59 +1338,12 @@ describe('Queue Management API', () => { assert.equal(res.statusCode, 409); assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0); const after = deps.invocationQueue.list('t1', 'user-a').find((entry) => entry.id === target.id); - assert.equal(after.steerRequestedByCatIds, undefined, 'a refused steer must not leave the entry reserved'); - // The release itself must be durable, not only in memory. - const persistCalls = deps.queueCustodyCoordinator.persistEntry.mock.calls.length; - assert.ok(persistCalls >= 2, `reservation and its release must both persist (saw ${persistCalls})`); - }); - - it('POST /queue/steer-batch rejects an ineligible member atomically before cancel', async () => { - const a = enqueueEntry(deps.invocationQueue, { content: 'a', ownerAuthProvenance: 'strict' }).entry; - const freshness = enqueueEntry(deps.invocationQueue, { - content: 'freshness', - ownerAuthProvenance: 'strict', - sourceCategory: 'freshness', - freshnessClosureId: 'closure-1', - }).entry; - deps.invocationTracker.has = mock.fn(() => true); - - const res = await app.inject({ - method: 'POST', - url: '/api/threads/t1/queue/steer-batch', - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: { entryIds: [a.id, freshness.id] }, - }); - - assert.equal(res.statusCode, 409); - assert.equal(res.json().code, 'BATCH_ENTRY_INELIGIBLE'); - assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0); - assert.equal(deps.queueProcessor.processNext.mock.calls.length, 0); - assert.ok(deps.invocationQueue.list('t1', 'user-a').every((entry) => !entry.steerRequestedByCatIds)); - }); - - it('POST /queue/steer-batch releases the whole reservation when durable reservation persistence fails', async () => { - const a = enqueueEntry(deps.invocationQueue, { content: 'a', ownerAuthProvenance: 'strict' }).entry; - const b = enqueueEntry(deps.invocationQueue, { content: 'b', ownerAuthProvenance: 'strict' }).entry; - deps.queueCustodyCoordinator.persistEntry = mock.fn(async () => { - throw new Error('custody unavailable'); - }); - deps.invocationTracker.has = mock.fn(() => true); - - const res = await app.inject({ - method: 'POST', - url: '/api/threads/t1/queue/steer-batch', - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: { entryIds: [a.id, b.id] }, - }); - - assert.equal(res.statusCode, 503); - assert.equal(res.json().code, 'BATCH_RESERVATION_PERSIST_FAILED'); - assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0); - assert.ok(deps.invocationQueue.list('t1', 'user-a').every((entry) => !entry.steerRequestedByCatIds)); + assert.equal(after.status, 'queued'); + assert.equal(after.steerRequestedByCatIds, undefined); }); it('POST /queue/:entryId/steer rejects promote because Steer has one cancel-and-restart meaning', async () => { - const queued = enqueueEntry(deps.invocationQueue, { content: 'queued correction' }); + const queued = await enqueueDurableEntry(deps.invocationQueue, { content: 'queued correction' }); const res = await app.inject({ method: 'POST', @@ -1431,7 +1357,7 @@ describe('Queue Management API', () => { }); it('POST /queue/:entryId/steer defaults to immediate cancel-and-restart for the same entry', async () => { - const queued = enqueueEntry(deps.invocationQueue, { + const queued = await enqueueDurableEntry(deps.invocationQueue, { content: 'queued correction', ownerAuthProvenance: 'strict', }); @@ -1442,7 +1368,6 @@ describe('Queue Management API', () => { catIds: ['opus'], executionIds: ['inv-active'], })); - deps.queueProcessor.processNext = mock.fn(async () => ({ started: true, entry: queued.entry })); const res = await app.inject({ method: 'POST', @@ -1454,11 +1379,11 @@ describe('Queue Management API', () => { assert.equal(res.statusCode, 200); assert.equal(Object.hasOwn(JSON.parse(res.body).entry, 'ownerAuthProvenance'), false); assert.equal(deps.invocationTracker.cancel.mock.calls.length, 1); - assert.equal(deps.queueProcessor.processNext.mock.calls.length, 1); + assert.equal(deps.queueProcessor.processClaimedSteerEntries.mock.calls.length, 1); }); - it('POST /queue/:entryId/steer publishes the cleared receipt when immediate restart cannot begin', async () => { - const queued = enqueueEntry(deps.invocationQueue, { content: 'queued correction' }); + it('POST /queue/:entryId/steer restores the pending target when immediate restart cannot begin', async () => { + const queued = await enqueueDurableEntry(deps.invocationQueue, { content: 'queued correction' }); deps.invocationTracker.has = mock.fn(() => true); deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); deps.invocationTracker.cancel = mock.fn(() => ({ @@ -1466,7 +1391,7 @@ describe('Queue Management API', () => { catIds: ['opus'], executionIds: ['inv-active'], })); - deps.queueProcessor.processNext = mock.fn(async () => ({ started: false })); + deps.queueProcessor.processClaimedSteerEntries = mock.fn(async () => ({ started: false })); const res = await app.inject({ method: 'POST', @@ -1475,19 +1400,20 @@ describe('Queue Management API', () => { payload: {}, }); - assert.equal(res.statusCode, 409); + assert.equal(res.statusCode, 503); const updates = deps.socketManager.emitToUser.mock.calls .filter((call) => call.arguments[1] === 'queue_updated') .map((call) => call.arguments[2]); assert.equal(updates.at(-1)?.action, 'steer_failed'); - assert.equal(updates.at(-1)?.queue[0].targetStates.opus, 'queued'); + assert.deepEqual(updates.at(-1)?.queue[0].targetCats, ['opus']); + assert.equal('targetStates' in updates.at(-1)?.queue[0], false); }); it('POST /queue/:entryId/steer returns 409 when entry is processing', async () => { - enqueueEntry(deps.invocationQueue); - deps.invocationQueue.markProcessing('t1', 'user-a'); + await enqueueDurableEntry(deps.invocationQueue); + await markHeadProcessing(deps.invocationQueue); const entries = deps.invocationQueue.list('t1', 'user-a'); - const processingEntry = entries.find((e) => e.status === 'processing'); + const processingEntry = entries.find((e) => e.status === 'claimed'); const res = await app.inject({ method: 'POST', @@ -1499,8 +1425,8 @@ describe('Queue Management API', () => { }); it('POST /queue/:entryId/steer immediate cancels active invocation and starts processing', async () => { - const r1 = enqueueEntry(deps.invocationQueue, { content: 'first' }); - enqueueEntry(deps.invocationQueue, { content: 'second' }); + const r1 = await enqueueDurableEntry(deps.invocationQueue, { content: 'first' }); + await enqueueDurableEntry(deps.invocationQueue, { content: 'second' }); deps.invocationTracker.has = mock.fn(() => true); deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); @@ -1509,7 +1435,6 @@ describe('Queue Management API', () => { catIds: ['codex'], executionIds: ['inv-active'], })); - deps.queueProcessor.processNext = mock.fn(async () => ({ started: true, entry: { id: r1.entry.id } })); const res = await app.inject({ method: 'POST', @@ -1517,24 +1442,26 @@ describe('Queue Management API', () => { headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, payload: { mode: 'immediate' }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 200, res.body); assert.equal(deps.invocationTracker.cancel.mock.calls.length, 1); - assert.equal(deps.queueProcessor.processNext.mock.calls.length, 1); + assert.equal(deps.queueProcessor.processClaimedSteerEntries.mock.calls.length, 1); assert.deepEqual(deps.agentSessionMutex.forceReleaseByScope.mock.calls[0].arguments, [ { threadId: 't1', userId: 'user-a', catId: 'opus' }, { preserveHolderExecutionIds: ['inv-active'] }, ]); - // Bugfix: steer must broadcast cancel+done so frontend clears old invocation's "正在回复中" + // The durable canceled response owns the visible terminal; transport emits + // one done event so the frontend clears the old invocation without a + // duplicate centered cancellation notice. const broadcastCalls = deps.socketManager.broadcastAgentMessage.mock.calls; - assert.ok(broadcastCalls.length >= 2, 'should broadcast system_info + done for canceled invocation'); + assert.ok(broadcastCalls.length >= 1, 'should broadcast done for canceled invocation'); const doneCall = broadcastCalls.find((c) => c.arguments[0].type === 'done'); assert.ok(doneCall, 'should broadcast done event to clear frontend loading state'); assert.equal(doneCall.arguments[0].isFinal, true); }); it('POST /queue/:entryId/steer immediate releases QueueProcessor mutex after cancel (P2 race)', async () => { - const r1 = enqueueEntry(deps.invocationQueue, { content: 'first' }); - enqueueEntry(deps.invocationQueue, { content: 'second' }); + const r1 = await enqueueDurableEntry(deps.invocationQueue, { content: 'first' }); + await enqueueDurableEntry(deps.invocationQueue, { content: 'second' }); deps.invocationTracker.has = mock.fn(() => true); deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); @@ -1544,10 +1471,10 @@ describe('Queue Management API', () => { deps.queueProcessor.releaseSlot = mock.fn(() => { locked = false; }); - deps.queueProcessor.processNext = mock.fn(async () => { - if (locked) return { started: false }; - return { started: true, entry: { id: r1.entry.id } }; - }); + deps.queueProcessor.processClaimedSteerEntries = mock.fn(async () => ({ + started: !locked, + entry: r1.entry, + })); const res = await app.inject({ method: 'POST', @@ -1555,7 +1482,7 @@ describe('Queue Management API', () => { headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, payload: { mode: 'immediate' }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 200, res.body); assert.equal(deps.queueProcessor.releaseSlot.mock.calls.length, 1); }); @@ -1564,12 +1491,11 @@ describe('Queue Management API', () => { // pre-start create-await window so invocationTracker.has(opus) is false. The user steers a second // opus entry B. Force-releasing A's slot would double-start opus once create returns; instead // steer must TOMBSTONE A (removeProcessed) so executeEntry self-aborts, and NOT force-release. - const a = enqueueEntry(deps.invocationQueue, { content: 'inflight', targetCats: ['opus'] }); - deps.invocationQueue.markProcessing('t1', 'user-a'); // A → processing (holds opus slot) - const b = enqueueEntry(deps.invocationQueue, { content: 'steered', targetCats: ['opus'] }); + const a = await enqueueDurableEntry(deps.invocationQueue, { content: 'inflight', targetCats: ['opus'] }); + await markHeadProcessing(deps.invocationQueue); // A → processing (holds opus slot) + const b = await enqueueDurableEntry(deps.invocationQueue, { content: 'steered', targetCats: ['opus'] }); deps.invocationTracker.has = mock.fn(() => false); // pre-start: tracker not yet registered - deps.queueProcessor.processExactSteerReservation = mock.fn(async () => ({ started: true, entry: b.entry })); // Precondition: A holds the opus slot (in-flight). assert.equal(deps.invocationQueue.findProcessingByCat('t1', 'opus')?.id, a.entry.id); @@ -1586,7 +1512,12 @@ describe('Queue Management API', () => { assert.equal(body.started, true); // A was tombstoned (removed) so executeEntry self-aborts at its post-startAll guard. - assert.equal(deps.invocationQueue.findProcessingByCat('t1', 'opus'), null, 'in-flight A must be tombstoned'); + assert.equal( + deps.invocationQueue.list('t1', 'user-a').some((entry) => entry.id === a.entry.id), + false, + 'in-flight A must be tombstoned', + ); + assert.equal(deps.invocationQueue.findProcessingByCat('t1', 'opus')?.id, b.entry.id); // Race-safe: NO force slot-release, NO cancel of a non-existent tracker invocation. assert.equal(deps.queueProcessor.releaseSlot.mock.calls.length, 0, 'must NOT force-release a pre-start slot'); assert.equal(deps.invocationTracker.cancel.mock.calls.length, 0); @@ -1598,13 +1529,17 @@ describe('Queue Management API', () => { // user-b holds the opus slot via an in-flight (pre-start) entry; user-a steers their own opus // entry. The cross-user guard must reject (INVOCATION_ACTIVE) and leave user-b's entry intact — // one user cannot interrupt another's in-flight invocation by steering their own. - const other = enqueueEntry(deps.invocationQueue, { + const other = await enqueueDurableEntry(deps.invocationQueue, { userId: 'user-b', content: 'other-inflight', targetCats: ['opus'], }); - deps.invocationQueue.markProcessing('t1', 'user-b'); // user-b's entry → processing (holds opus slot) - const mine = enqueueEntry(deps.invocationQueue, { userId: 'user-a', content: 'mine', targetCats: ['opus'] }); + await markHeadProcessing(deps.invocationQueue, 'user-b'); // user-b's entry → processing (holds opus slot) + const mine = await enqueueDurableEntry(deps.invocationQueue, { + userId: 'user-a', + content: 'mine', + targetCats: ['opus'], + }); deps.invocationTracker.has = mock.fn(() => false); // pre-start: tracker not yet registered @@ -1630,14 +1565,21 @@ describe('Queue Management API', () => { // create-await"; steer cannot tell a slow-but-live create from a hung one (create awaits an // unbounded Redis eval), so NO age threshold is sound. Even an "old" processing entry must be // tombstoned (not force-released) — a force-release would double-start if create later resumes. - enqueueEntry(deps.invocationQueue, { userId: 'user-a', content: 'old-inflight', targetCats: ['opus'] }); - deps.invocationQueue.markProcessing('t1', 'user-a'); + await enqueueDurableEntry(deps.invocationQueue, { + userId: 'user-a', + content: 'old-inflight', + targetCats: ['opus'], + }); + await markHeadProcessing(deps.invocationQueue); const old = deps.invocationQueue.findProcessingByCat('t1', 'opus'); old.processingStartedAt = Date.now() - 60 * 60_000; // 1h old — still must NOT force-release - const steered = enqueueEntry(deps.invocationQueue, { userId: 'user-a', content: 'steered', targetCats: ['opus'] }); + const steered = await enqueueDurableEntry(deps.invocationQueue, { + userId: 'user-a', + content: 'steered', + targetCats: ['opus'], + }); deps.invocationTracker.has = mock.fn(() => false); - deps.queueProcessor.processExactSteerReservation = mock.fn(async () => ({ started: true, entry: steered.entry })); const res = await app.inject({ method: 'POST', @@ -1655,14 +1597,22 @@ describe('Queue Management API', () => { // Tombstoning an in-flight user entry must mirror withdraw/clear F117 cleanup: its message would // otherwise stay permanently 'queued' (undelivered + excluded from context) since executeEntry // self-aborts before its markDelivered block. - enqueueEntry(deps.invocationQueue, { userId: 'user-a', content: 'inflight', targetCats: ['opus'] }); - deps.invocationQueue.markProcessing('t1', 'user-a'); + await enqueueDurableEntry(deps.invocationQueue, { + userId: 'user-a', + content: 'inflight', + targetCats: ['opus'], + messageId: 'msg-inflight', + }); + await markHeadProcessing(deps.invocationQueue); const inflight = deps.invocationQueue.findProcessingByCat('t1', 'opus'); - inflight.messageId = 'msg-inflight'; // user message backing the in-flight entry - const steered = enqueueEntry(deps.invocationQueue, { userId: 'user-a', content: 'steered', targetCats: ['opus'] }); + assert.equal(inflight.payload.messageId, 'msg-inflight'); + const steered = await enqueueDurableEntry(deps.invocationQueue, { + userId: 'user-a', + content: 'steered', + targetCats: ['opus'], + }); deps.invocationTracker.has = mock.fn(() => false); - deps.queueProcessor.processExactSteerReservation = mock.fn(async () => ({ started: true, entry: steered.entry })); const res = await app.inject({ method: 'POST', @@ -1683,14 +1633,22 @@ describe('Queue Management API', () => { // If the message was already canceled/delivered, the receipt reports applied=false. // The transition gate must suppress the message_deleted emit — otherwise the client would // flash-delete a delivered message or duplicate-delete an already-canceled one. - enqueueEntry(deps.invocationQueue, { userId: 'user-a', content: 'inflight', targetCats: ['opus'] }); - deps.invocationQueue.markProcessing('t1', 'user-a'); + await enqueueDurableEntry(deps.invocationQueue, { + userId: 'user-a', + content: 'inflight', + targetCats: ['opus'], + messageId: 'msg-already-delivered', + }); + await markHeadProcessing(deps.invocationQueue); const inflight = deps.invocationQueue.findProcessingByCat('t1', 'opus'); - inflight.messageId = 'msg-already-delivered'; - const steered = enqueueEntry(deps.invocationQueue, { userId: 'user-a', content: 'steered', targetCats: ['opus'] }); + assert.equal(inflight.payload.messageId, 'msg-already-delivered'); + const steered = await enqueueDurableEntry(deps.invocationQueue, { + userId: 'user-a', + content: 'steered', + targetCats: ['opus'], + }); deps.invocationTracker.has = mock.fn(() => false); - deps.queueProcessor.processExactSteerReservation = mock.fn(async () => ({ started: true, entry: steered.entry })); deps.messageStore.markCanceled = mock.fn(async () => ({ deliveryStatus: 'delivered', deliveryTransitioned: false, @@ -1710,14 +1668,13 @@ describe('Queue Management API', () => { }); it('POST /queue/:entryId/steer immediate scopes cancel broadcast to steered cat only (P1 cloud review)', async () => { - const r1 = enqueueEntry(deps.invocationQueue, { content: 'first', targetCats: ['opus'] }); - enqueueEntry(deps.invocationQueue, { content: 'second' }); + const r1 = await enqueueDurableEntry(deps.invocationQueue, { content: 'first', targetCats: ['opus'] }); + await enqueueDurableEntry(deps.invocationQueue, { content: 'second' }); deps.invocationTracker.has = mock.fn(() => true); deps.invocationTracker.getUserId = mock.fn(() => 'user-a'); // cancel returns multi-cat catIds (co-dispatched), but steer targets only opus deps.invocationTracker.cancel = mock.fn(() => ({ cancelled: true, catIds: ['opus', 'codex'] })); - deps.queueProcessor.processNext = mock.fn(async () => ({ started: true, entry: { id: r1.entry.id } })); const res = await app.inject({ method: 'POST', @@ -1725,7 +1682,7 @@ describe('Queue Management API', () => { headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, payload: { mode: 'immediate' }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 200, res.body); // done should only be broadcast for opus (the steered cat), NOT codex const broadcastCalls = deps.socketManager.broadcastAgentMessage.mock.calls; @@ -1738,7 +1695,7 @@ describe('Queue Management API', () => { }); it('POST /queue/:entryId/steer returns 404 for another user entry', async () => { - const r = enqueueEntry(deps.invocationQueue, { userId: 'user-a' }); + const r = await enqueueDurableEntry(deps.invocationQueue, { userId: 'user-a' }); const res = await app.inject({ method: 'POST', url: `/api/threads/t1/queue/${r.entry.id}/steer`, @@ -1779,6 +1736,30 @@ describe('Queue Management API', () => { assert.equal(doneCalls[0].arguments[0].catId, 'opus'); }); + it('POST /cancel/:catId leaves an executing Queue reservation for normal completion to drain', async () => { + await enqueueDurableEntry(deps.invocationQueue, { content: 'currently executing' }); + await markHeadProcessing(deps.invocationQueue); + deps.invocationTracker.has = mock.fn(() => true); + deps.invocationTracker.cancel = mock.fn(() => ({ + cancelled: true, + catIds: ['opus'], + executionIds: ['inv-active'], + })); + + const res = await app.inject({ + method: 'POST', + url: '/api/threads/t1/cancel/opus', + headers: { 'x-cat-cafe-user': 'user-a' }, + }); + + assert.equal(res.statusCode, 200, res.body); + assert.equal( + deps.queueProcessor.releaseSlot.mock.calls.length, + 0, + 'the canceled execute promise still owns retirement and the following Queue drain', + ); + }); + it('POST /cancel/:catId returns 404 when cat is not active (AC-B9)', async () => { deps.invocationTracker.has = mock.fn(() => false); @@ -1814,7 +1795,7 @@ describe('Queue Management API', () => { assert.equal(res.statusCode, 200); const next = deps.invocationQueue.peekOldestAcrossUsers('t1'); - assert.equal(next.content, 'c', 'entry c should be first after reorder'); + assert.equal(next.payload.content, 'c', 'entry c should be first after reorder'); const emitCalls = deps.socketManager.emitToUser.mock.calls; const updateCall = emitCalls.find((c) => c.arguments[1] === 'queue_updated'); @@ -1822,11 +1803,11 @@ describe('Queue Management API', () => { assert.equal(updateCall.arguments[2].action, 'reordered'); }); - it('PATCH /queue/reorder rejects position on processing entry (F175)', async () => { + it('PATCH /queue/reorder rejects position on a live reversible claim (F175)', async () => { enqueueEntry(deps.invocationQueue, { content: 'a' }); - deps.invocationQueue.markProcessing('t1', 'user-a'); + await markHeadProcessing(deps.invocationQueue); const entries = deps.invocationQueue.list('t1', 'user-a'); - const processingEntry = entries.find((e) => e.status === 'processing'); + const processingEntry = entries.find((e) => e.status === 'claimed'); const res = await app.inject({ method: 'PATCH', @@ -1834,7 +1815,7 @@ describe('Queue Management API', () => { headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, payload: { positions: [{ entryId: processingEntry.id, position: 0 }] }, }); - assert.equal(res.statusCode, 400); + assert.equal(res.statusCode, 409); }); it('PATCH /queue/reorder rejects position on system continuation entry (F175)', async () => { diff --git a/packages/api/test/queue-cancel-no-auto-resume.test.js b/packages/api/test/queue-cancel-no-auto-resume.test.js index 2eb6b486cc..62ad4359ea 100644 --- a/packages/api/test/queue-cancel-no-auto-resume.test.js +++ b/packages/api/test/queue-cancel-no-auto-resume.test.js @@ -18,13 +18,15 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, it, mock } from 'node:test'; +import { adaptInvocationQueue, adaptMessageStore, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); +const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); function stubDeps(overrides = {}) { return { - queue: new InvocationQueue(), + queue: adaptInvocationQueue(new InvocationQueue()), invocationTracker: { start: mock.fn(() => new AbortController()), startAll: mock.fn(() => new AbortController()), @@ -40,6 +42,8 @@ function stubDeps(overrides = {}) { update: mock.fn(async () => {}), }, router: { + resolveExplicitTargets: mock.fn(async (requestedCatIds) => [...requestedCatIds]), + resolveConversationTargetsAtAdmission: mock.fn(async (requestedCatIds) => [...requestedCatIds]), routeExecution: mock.fn(async function* () { yield { type: 'done', catId: 'opus', timestamp: Date.now() }; }), @@ -64,19 +68,31 @@ function stubDeps(overrides = {}) { } function enqueueEntry(queue, overrides = {}) { - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'hello', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - ...overrides, - }); + const result = queue.enqueue( + canonicalTestQueueInput({ + kind: 'private_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'hello', + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + ...overrides, + }), + ); return result.entry; } +async function markEntryProcessing(queue, entry, targetCatId = 'opus') { + const claimed = await queue.markProcessingDurable(entry.threadId, 'u1', { + entryId: entry.id, + targetCats: [targetCatId], + }); + assert.ok(claimed, 'fixture entry must be claimable'); + assert.equal(await queue.commitClaimedProcessing(entry.threadId, [entry.id]), true); +} + describe('cancelAll must NOT auto-resume queued entries', () => { let deps; let processor; @@ -93,12 +109,10 @@ describe('cancelAll must NOT auto-resume queued entries', () => { it('canceled_by_user does NOT auto-resume when suppressAutoResume is active', async () => { // Enqueue two entries — first is "processing", second is "queued" const entry1 = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry1.id, 'msg-1'); const entry2 = enqueueEntry(deps.queue, { content: 'second' }); - deps.queue.backfillMessageId('t1', 'u1', entry2.id, 'msg-2'); // Mark first as processing (simulates active invocation) - deps.queue.markProcessing('t1', 'u1', entry1.id); + await markEntryProcessing(deps.queue, entry1); // Suppress auto-resume for this thread+cat (called from cancelAll handler) processor.suppressAutoResume('t1', 'opus', ['inv-cancel-all']); @@ -126,19 +140,14 @@ describe('cancelAll must NOT auto-resume queued entries', () => { ); }); - it('plain canceled from a direct connector does not enter #595 recovery when force-reset suppression is active', async (t) => { + it('plain canceled does not restart private work when force-reset suppression is active', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); - const entry = enqueueEntry(deps.queue, { source: 'connector', content: 'managed-command completion' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-managed-command'); + enqueueEntry(deps.queue, { content: 'managed-command completion' }); processor.suppressAutoResume('t1', 'opus', ['inv-force-reset']); await processor.onInvocationComplete('t1', 'opus', 'canceled', 'inv-force-reset', []); - assert.equal( - processor.isPaused('t1', 'opus'), - false, - 'force-reset cancellation must terminate without arming the delayed #595 restart path', - ); + assert.equal(processor.isAutoResumeSuppressed('t1', 'opus'), false, 'the exact terminal consumes its fence'); assert.equal( deps.router.routeExecution.mock.callCount(), 0, @@ -146,10 +155,9 @@ describe('cancelAll must NOT auto-resume queued entries', () => { ); }); - it('a late-bound direct connector identity makes an ID-less cancel-all fence consumable', async (t) => { + it('a late-bound private wake identity makes an ID-less cancel-all fence consumable', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); - const entry = enqueueEntry(deps.queue, { source: 'connector', content: 'preserved after pre-bind reset' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-pre-bind-reset'); + const entry = enqueueEntry(deps.queue, { content: 'preserved after pre-bind reset' }); processor.suppressAutoResume('t1', 'opus'); processor.bindAutoResumeSuppressionExecution('t1', 'opus', 'inv-late-bound'); @@ -162,7 +170,6 @@ describe('cancelAll must NOT auto-resume queued entries', () => { false, 'the exact late-bound terminal must consume the formerly ID-less fence', ); - assert.equal(processor.isPaused('t1', 'opus'), false, 'the consumed stop fence must not leave a stale pause'); assert.equal(deps.router.routeExecution.mock.callCount(), 0, 'the preserved connector wake must not auto-resume'); assert.equal( deps.queue.list('t1', 'u1').find((candidate) => candidate.id === entry.id)?.status, @@ -171,93 +178,8 @@ describe('cancelAll must NOT auto-resume queued entries', () => { ); }); - it('force-reset suppression disarms a #595 recovery timer that was already scheduled', async (t) => { - t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000 }); - const entry = enqueueEntry(deps.queue, { content: 'queued before force-reset' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-before-reset'); - - await processor.onInvocationComplete('t1', 'opus', 'failed'); - assert.equal(processor.isPaused('t1', 'opus'), true, 'failed completion should arm #595 recovery'); - - processor.suppressAutoResume('t1', 'opus', ['inv-force-reset']); - processor.clearPause('t1', 'opus'); - t.mock.timers.tick(10_000); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal( - deps.router.routeExecution.mock.callCount(), - 0, - 'a pre-armed recovery timer must not dispatch while force-reset suppression owns the slot', - ); - assert.equal( - deps.queue.list('t1', 'u1').find((candidate) => candidate.id === entry.id)?.status, - 'queued', - 'the preserved entry must remain durably queued after reset', - ); - - t.mock.timers.tick(50_000); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal( - deps.router.routeExecution.mock.callCount(), - 0, - 'clearing the old pause must invalidate its timer even after the suppression TTL expires', - ); - }); - - it('re-arms #595 recovery for the remaining suppression TTL when the same pause stays live', async (t) => { - t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000 }); - const entry = enqueueEntry(deps.queue, { content: 'recover after the stop fence expires' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-recover-after-fence'); - - processor.suppressAutoResume('t1', 'opus', ['inv-missing-terminal']); - await processor.onInvocationComplete('t1', 'opus', 'failed', undefined, []); - assert.equal(processor.isPaused('t1', 'opus'), true, 'unrelated failure should own a #595 pause generation'); - - t.mock.timers.tick(10_000); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(deps.router.routeExecution.mock.callCount(), 0, 'the 10-second timer must defer to the live fence'); - assert.equal(processor.isPaused('t1', 'opus'), true, 'defer must retain the same pause generation'); - - t.mock.timers.tick(49_999); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(deps.router.routeExecution.mock.callCount(), 0, 'recovery must wait for the full suppression TTL'); - - t.mock.timers.tick(1); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(processor.isPaused('t1', 'opus'), false, 'the same pause must retire when its fence expires'); - assert.equal(deps.router.routeExecution.mock.callCount(), 1, 'queued work must recover exactly once after expiry'); - }); - - it('moves deferred #595 recovery to the renewed suppression expiry without duplicating it', async (t) => { - t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000 }); - const entry = enqueueEntry(deps.queue, { content: 'recover after renewed fence' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-renewed-fence'); - - processor.suppressAutoResume('t1', 'opus', ['inv-first-missing-terminal']); - await processor.onInvocationComplete('t1', 'opus', 'failed', undefined, []); - - t.mock.timers.tick(5_000); - processor.suppressAutoResume('t1', 'opus', ['inv-second-missing-terminal']); - t.mock.timers.tick(55_000); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal( - deps.router.routeExecution.mock.callCount(), - 0, - 'renewal must move recovery to the latest fence expiry', - ); - assert.equal(processor.isPaused('t1', 'opus'), true, 'the renewed fence must retain the same pause'); - - t.mock.timers.tick(5_000); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(processor.isPaused('t1', 'opus'), false, 'the pause must retire at the renewed expiry'); - assert.equal(deps.router.routeExecution.mock.callCount(), 1, 'renewal must not create duplicate recovery attempts'); - }); - it('an unrelated replacement cancellation cannot consume suppression owned by cancel-all', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); - const entry = enqueueEntry(deps.queue, { content: 'preserved by cancel-all' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-preserved'); - processor.suppressAutoResume('t1', 'opus', ['inv-canceled-by-all']); await processor.onInvocationComplete('t1', 'opus', 'canceled', 'inv-replacement', []); @@ -276,16 +198,7 @@ describe('cancelAll must NOT auto-resume queued entries', () => { 'the exact canceled execution terminal must consume its own marker', ); - assert.equal( - deps.router.routeExecution.mock.callCount(), - 0, - 'the original cancel-all terminal must still suppress queue restart', - ); - assert.equal( - deps.queue.list('t1', 'u1').find((candidate) => candidate.id === entry.id)?.status, - 'queued', - 'cancel-all must preserve queued work after an unrelated replacement terminates', - ); + assert.equal(deps.router.routeExecution.mock.callCount(), 0, 'neither terminal can invent queue work'); }); it('an older cancel-all terminal cannot consume a newer stop marker on the same slot', async () => { @@ -362,23 +275,17 @@ describe('cancelAll must NOT auto-resume queued entries', () => { // auto-resume (backward compat for single-cat cancel scenarios). // ───────────────────────────────────────────────────────────────────────── it('canceled_by_user still auto-resumes when suppressAutoResume is NOT active', async () => { + const requestDrain = mock.method(processor, 'requestDrain', async () => {}); const entry1 = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry1.id, 'msg-1'); - const entry2 = enqueueEntry(deps.queue, { content: 'second' }); - deps.queue.backfillMessageId('t1', 'u1', entry2.id, 'msg-2'); + enqueueEntry(deps.queue, { content: 'second' }); - deps.queue.markProcessing('t1', 'u1', entry1.id); + await markEntryProcessing(deps.queue, entry1); // Do NOT call suppressAutoResume — normal cancel flow await processor.onInvocationComplete('t1', 'opus', 'canceled_by_user'); // entry2 should have been picked up (auto-resume is the default) - const remaining = deps.queue.list('t1', 'u1'); - // At least one entry should be processing or the router should have been called - assert.ok( - deps.router.routeExecution.mock.callCount() > 0 || remaining.some((e) => e.status === 'processing'), - 'Without suppress, canceled_by_user should auto-resume the next entry', - ); + assert.equal(requestDrain.mock.callCount(), 1, 'without suppression, completion must request the canonical drain'); }); // ───────────────────────────────────────────────────────────────────────── @@ -395,10 +302,8 @@ describe('cancelAll must NOT auto-resume queued entries', () => { // ───────────────────────────────────────────────────────────────────────── it('succeeded completion does NOT consume suppress flag (race: steer after cancelAll)', async () => { const entry1 = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry1.id, 'msg-1'); - const entry2 = enqueueEntry(deps.queue, { content: 'second' }); - deps.queue.backfillMessageId('t1', 'u1', entry2.id, 'msg-2'); - deps.queue.markProcessing('t1', 'u1', entry1.id); + enqueueEntry(deps.queue, { content: 'second' }); + await markEntryProcessing(deps.queue, entry1); // cancelAll sets suppress processor.suppressAutoResume('t1', 'opus', ['inv-cancel-all']); @@ -436,57 +341,97 @@ describe('cancelAll must NOT auto-resume queued entries', () => { // an older invocation's terminal marker. // ───────────────────────────────────────────────────────────────────────── it('multi-cat cancelAll does NOT leave stale suppress on secondary cats', async () => { + const requestDrain = mock.method(processor, 'requestDrain', async () => {}); // Simulate cancelAll fencing both slots for the old aggregate invocation. processor.suppressAutoResume('t1', 'opus', ['inv-old-aggregate']); processor.suppressAutoResume('t1', 'codex', ['inv-old-aggregate']); // Enqueue an entry for codex - const codexEntry = enqueueEntry(deps.queue, { targetCats: ['codex'], content: 'codex msg' }); - deps.queue.backfillMessageId('t1', 'u1', codexEntry.id, 'msg-codex'); + enqueueEntry(deps.queue, { targetCats: ['codex'], content: 'codex msg' }); // codex invocation completes with canceled_by_user (new per-cat cancel) await processor.onInvocationComplete('t1', 'codex', 'canceled_by_user', 'inv-new-codex', []); await new Promise((r) => setTimeout(r, 50)); // INVARIANT: codex should auto-resume normally (no stale suppress) - const remaining = deps.queue.list('t1', 'u1'); - const codexStatus = remaining.find((e) => e.id === codexEntry.id); + assert.equal(requestDrain.mock.callCount(), 1); + assert.equal(requestDrain.mock.calls[0].arguments[0], 't1'); + assert.equal(requestDrain.mock.calls[0].arguments[1]?.bypassSuppressionForCatId, 'codex'); assert.ok( - deps.router.routeExecution.mock.callCount() > 0 || (codexStatus && codexStatus.status === 'processing'), - 'codex must auto-resume — no stale suppress from multi-cat cancelAll', + Number.isInteger(requestDrain.mock.calls[0].arguments[1]?.bypassSuppressionEpoch), + 'the drain request must bind its bypass to the exact observed fence epoch', ); }); + it('a drain bypass cannot cross a newer suppression epoch armed during target resolution', async () => { + let admitResolution; + let releaseResolution; + const resolutionEntered = new Promise((resolve) => { + admitResolution = resolve; + }); + const resolutionGate = new Promise((resolve) => { + releaseResolution = resolve; + }); + deps.router.resolveExplicitTargets = mock.fn(async (requestedCatIds) => { + admitResolution(); + await resolutionGate; + return [...requestedCatIds]; + }); + enqueueEntry(deps.queue, { targetCats: ['codex'], content: 'must remain queued' }); + processor.suppressAutoResume('t1', 'codex', ['inv-old']); + + const completion = processor.onInvocationComplete('t1', 'codex', 'canceled_by_user', 'inv-other', []); + await resolutionEntered; + processor.suppressAutoResume('t1', 'codex', ['inv-newer']); + releaseResolution(); + await completion; + + assert.equal(deps.router.routeExecution.mock.calls.length, 0); + assert.equal(deps.queue.list('t1', 'u1').length, 1, 'the newer fence must keep the body queued'); + }); + // ───────────────────────────────────────────────────────────────────────── // Gate 2 / operator Queue correction: explicit user cancellation consumes every // entry that was included in the attempted batch. Cleanup must not recreate // or immediately dispatch any of those bodies. // ───────────────────────────────────────────────────────────────────────── - it('single-cat user_cancel consumes the attempted batch and does not blind-spawn from cleanup', async () => { + it('single-cat user_cancel consumes the attempted primary and does not blind-spawn from cleanup', async () => { // Override start() to return a pre-aborted controller with 'user_cancel' const abortedController = new AbortController(); abortedController.abort('user_cancel'); deps.invocationTracker.start = mock.fn(() => abortedController); deps.invocationTracker.startAll = mock.fn(() => abortedController); - const entry1 = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry1.id, 'msg-1'); - const entry2 = enqueueEntry(deps.queue, { content: 'second' }); - deps.queue.backfillMessageId('t1', 'u1', entry2.id, 'msg-2'); + deps.messageStore = adaptMessageStore(new MessageStore()); + const source = deps.messageStore.append({ + threadId: 't1', + userId: 'u1', + catId: 'opus', + content: 'hello', + timestamp: Date.now(), + deliveryStatus: 'queued', + lifecycle: { kind: 'input', orderKey: '1:source-cancel', dispatchRefs: [] }, + }); + enqueueEntry(deps.queue, { + kind: 'conversation_input', + messageId: source.id, + intent: 'user_message', + }); // Process entry1 — will detect pre-aborted controller with 'user_cancel' await processor.processNext('t1', 'u1'); // Let fire-and-forget settle await new Promise((r) => setTimeout(r, 100)); - // Both entries belonged to the same attempted user batch, so explicit stop - // terminalizes them instead of silently requeueing either one. + // Explicit stop terminalizes the attempted primary instead of silently + // requeueing it from cleanup. const remaining = deps.queue.list('t1', 'u1'); assert.deepEqual(remaining, []); assert.equal(deps.router.routeExecution.mock.callCount(), 1, 'cleanup must not spawn a second attempt'); }); it('suppressAutoResume is consumed after one use (single-shot)', async () => { + const requestDrain = mock.method(processor, 'requestDrain', async () => {}); // Suppress, then consume it with a canceled_by_user completion processor.suppressAutoResume('t1', 'opus', ['inv-first']); await processor.onInvocationComplete('t1', 'opus', 'canceled_by_user', 'inv-first', []); @@ -494,19 +439,13 @@ describe('cancelAll must NOT auto-resume queued entries', () => { // Verify suppress flag is gone by checking the internal state: // A second suppressAutoResume + onInvocationComplete should suppress, // but WITHOUT a second suppress call, it should NOT suppress. - const entry = enqueueEntry(deps.queue, { content: 'after-suppress' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-3'); + enqueueEntry(deps.queue, { content: 'after-suppress' }); // This completion should auto-resume (no suppress active) await processor.onInvocationComplete('t1', 'opus', 'succeeded'); + await new Promise((resolve) => setTimeout(resolve, 50)); // entry should have been picked up (auto-resume is back to normal) - const remaining = deps.queue.list('t1', 'u1'); - const entryStatus = remaining.find((e) => e.id === entry.id); - // Either entry is now processing, or routeExecution was called - assert.ok( - deps.router.routeExecution.mock.callCount() > 0 || (entryStatus && entryStatus.status === 'processing'), - 'After suppress is consumed, subsequent completions should auto-resume normally', - ); + assert.equal(requestDrain.mock.callCount(), 1, 'after consumption, subsequent completions request a drain'); }); }); diff --git a/packages/api/test/queue-enrichment-publication.test.js b/packages/api/test/queue-enrichment-publication.test.js index 7380575af6..91ee13d5a8 100644 --- a/packages/api/test/queue-enrichment-publication.test.js +++ b/packages/api/test/queue-enrichment-publication.test.js @@ -1,158 +1,74 @@ /** - * F220 intake — ordered, frozen, bounded queue_updated publication. + * F220 intake — ordered, frozen, bounded Queue publication. + * + * The public Queue projection contains pending source rows only. Delivery and + * terminal truth belongs to History lifecycle dispatchRefs/response messages. */ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -const { emitQueueUpdated, enrichQueueEntries, projectPublicQueueEntry } = await import( - '../dist/utils/queue-enrichment.js' -); +const { emitQueueUpdated, projectPublicQueueEntry } = await import('../dist/utils/queue-enrichment.js'); describe('F220 intake: queue snapshot publication ordering', () => { const makeEntry = (overrides = {}) => ({ + version: 2, id: 'queue-entry', threadId: 't1', - userId: 'u1', - content: 'queued work', - messageId: 'msg-entry', - mergedMessageIds: [], - source: 'user', - targetCats: ['opus'], - intent: 'execute', + owner: { kind: 'user', userId: 'u1' }, + kind: 'conversation_input', + from: { kind: 'user', userId: 'user-1' }, + targets: ['opus'], + payload: { sourceRecordId: 'msg-entry', content: 'queued work', messageId: 'msg-entry' }, + execution: { intent: 'execute', ownerAuthProvenance: 'strict', autoExecute: false }, + delivery: {}, status: 'queued', - createdAt: 1, - autoExecute: false, + enqueuedAt: 1, priority: 'normal', ...overrides, }); - const makeWithdrawnCustody = (overrides = {}) => ({ - version: 1, - entryId: 'withdrawn-entry', - revision: 2, - ownerAuthProvenance: 'strict', - intent: 'execute', - status: 'terminal', - allTargetCats: ['opus'], - pendingTargetCats: [], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - withdrawnByCatIds: ['opus'], - withdrawnAtByCatId: { opus: 20 }, - priority: 'normal', - createdAt: 1, - updatedAt: 20, - ...overrides, - }); - - it('keeps recovery action identity stable across an unchanged serialized Queue snapshot', () => { - const beforeRestart = makeEntry({ - source: 'agent', - messageId: null, - queuedFailedByCatIds: ['opus'], - queuedFailureAtByCatId: { opus: 1234 }, - queuedFailureReasonByCatId: { opus: 'invocation_failed' }, - }); - const afterRestart = structuredClone(beforeRestart); - - assert.deepEqual( - projectPublicQueueEntry(afterRestart).recoveryActions, - projectPublicQueueEntry(beforeRestart).recoveryActions, + it('never publishes private system input content', async () => { + const emitted = []; + await emitQueueUpdated( + { emitToUser: (_userId, _event, data) => emitted.push(data) }, + 'u1', + 't1', + [ + makeEntry({ + kind: 'private_input', + from: { kind: 'system', service: 'podcast' }, + payload: { sourceRecordId: 'private-prompt', content: 'secret podcast prompt' }, + }), + ], + null, + 'private', ); - assert.equal(projectPublicQueueEntry(afterRestart).recoveryActions[0]?.kind, 'retry_target'); + assert.deepEqual(emitted[0].queue, []); }); - it('does not project Retry when durable receipt truth says the failed target has no carrier', async () => { - const entry = makeEntry({ - queuedFailedByCatIds: ['opus'], - queuedFailureAtByCatId: { opus: 1234 }, - }); - const messageStore = { - getById: async () => ({ - queueCustody: { - version: 1, - entryId: entry.id, - revision: 1, - intent: 'execute', - status: 'queued', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: ['opus'], - handledByCatIds: [], - carrierByTargetCatId: { codex: { entryId: 'other-carrier' } }, - targetAttempts: [ - { - id: 'attempt-1', - targetCatId: 'opus', - sequence: 1, - state: 'failed', - createdAt: 1000, - updatedAt: 1234, - terminalReason: 'invocation_failed', + it('projects one pending source row without Queue-owned processing or terminal state', () => { + const projected = projectPublicQueueEntry( + makeEntry({ + targets: ['opus', 'codex'], + delivery: { + authorIntentByTarget: { + opus: { + requested: 'continue_current', + fallbackAt: 12, + fallbackReason: 'no_active_parent', }, - ], - priority: 'normal', - createdAt: 1000, - updatedAt: 1234, + removed: { requested: 'next_work' }, + }, }, }), - }; - - const [projected] = await enrichQueueEntries([entry], messageStore); - - assert.deepEqual( - projected.recoveryActions.map((action) => action.kind), - ['withdraw'], ); - }); - it('fails message-backed Retry closed when durable enrichment throws', async () => { - const entry = makeEntry({ - queuedFailedByCatIds: ['opus'], - queuedFailureAtByCatId: { opus: 1234 }, - }); - const messageStore = { - getById: async () => { - throw new Error('synthetic message-store failure'); - }, - }; - - const [projected] = await enrichQueueEntries([entry], messageStore); - - assert.deepEqual( - projected.recoveryActions.map((action) => action.kind), - ['withdraw'], - ); - }); - - it('fails message-backed Retry closed when the referenced message has no custody', async () => { - const entry = makeEntry({ - queuedFailedByCatIds: ['opus'], - queuedFailureAtByCatId: { opus: 1234 }, - }); - const messageStore = { - getById: async (messageId) => ({ - id: messageId, - threadId: entry.threadId, - userId: entry.userId, - catId: null, - content: entry.content, - }), - }; - - const [projected] = await enrichQueueEntries([entry], messageStore); - - assert.deepEqual( - projected.recoveryActions.map((action) => action.kind), - ['withdraw'], - ); + assert.deepEqual(projected.targetCats, ['opus', 'codex']); + assert.equal(projected.status, 'queued'); + assert.equal(projected.authorIntentByTarget.opus.effective, 'next_work'); + assert.equal('queueReceipt' in projected, false); + assert.equal('targetStates' in projected, false); }); it('serializes same-scope snapshots while a different user remains independent', async () => { @@ -170,31 +86,23 @@ describe('F220 intake: queue snapshot publication ordering', () => { releaseOlder = resolve; }); } - return messageId === 'msg-terminal' ? { id: messageId, queueCustody: makeWithdrawnCustody() } : null; + return null; }, }; const socketManager = { - emitToUser: (userId, _event, data) => - emitted.push({ - userId, - action: data.action, - queue: data.queue, - messageReceipts: data.messageReceipts, - }), + emitToUser: (userId, _event, data) => emitted.push({ userId, ...data }), }; const older = emitQueueUpdated( socketManager, 'u1', 't1', - [makeEntry({ id: 'older', messageId: 'msg-older' })], + [makeEntry({ payload: { sourceRecordId: 'msg-older', content: 'older', messageId: 'msg-older' } })], messageStore, 'older', ); await olderStartedPromise; - const newer = emitQueueUpdated(socketManager, 'u1', 't1', [], messageStore, 'newer', { - receiptMessageIds: ['msg-terminal'], - }); + const newer = emitQueueUpdated(socketManager, 'u1', 't1', [], messageStore, 'newer'); const independent = emitQueueUpdated(socketManager, 'u2', 't1', [], messageStore, 'independent'); try { @@ -203,12 +111,10 @@ describe('F220 intake: queue snapshot publication ordering', () => { assert.deepEqual( emitted.filter((event) => event.userId === 'u1'), [], - 'newer same-scope snapshot must wait behind the older enrichment', ); assert.deepEqual( emitted.filter((event) => event.userId === 'u2').map((event) => event.action), ['independent'], - 'different user scope must not share the tail', ); } finally { releaseOlder?.(); @@ -219,17 +125,10 @@ describe('F220 intake: queue snapshot publication ordering', () => { emitted.filter((event) => event.userId === 'u1').map((event) => event.action), ['older', 'newer'], ); - assert.deepEqual(emitted.find((event) => event.action === 'newer')?.messageReceipts, [ - { - messageId: 'msg-terminal', - queueReceipt: { - version: 1, - entryId: 'withdrawn-entry', - targets: [{ catId: 'opus', state: 'withdrawn', withdrawnAt: 20 }], - reminderAttempts: [], - }, - }, - ]); + assert.equal( + emitted.some((event) => 'messageReceipts' in event), + false, + ); }); it('freezes mutable queue entries at publication call time', async () => { @@ -248,24 +147,24 @@ describe('F220 intake: queue snapshot publication ordering', () => { return null; }, }; - const socketManager = { - emitToUser: (_userId, _event, data) => emitted.push(data), - }; - const entry = makeEntry({ queuedNotifiedByCatIds: ['opus'] }); - - const publication = emitQueueUpdated(socketManager, 'u1', 't1', [entry], messageStore, 'frozen'); + const entry = makeEntry(); + const publication = emitQueueUpdated( + { emitToUser: (_userId, _event, data) => emitted.push(data) }, + 'u1', + 't1', + [entry], + messageStore, + 'frozen', + ); await lookupStartedPromise; - entry.targetCats.push('codex'); - entry.queuedNotifiedByCatIds.push('codex'); + entry.targets = ['codex']; releaseLookup(); await publication; assert.deepEqual(emitted[0].queue[0].targetCats, ['opus']); - assert.deepEqual(emitted[0].queue[0].queuedNotifiedByCatIds, ['opus']); - assert.deepEqual(emitted[0].queue[0].targetStates, { opus: 'notified' }); }); - it('falls back to a projected raw snapshot after the enrichment deadline and releases the tail', async (t) => { + it('releases the publication tail with the pending projection after the enrichment deadline', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const emitted = []; let lookupStarted; @@ -278,47 +177,25 @@ describe('F220 intake: queue snapshot publication ordering', () => { return new Promise(() => {}); }, }; - const socketManager = { - emitToUser: (_userId, _event, data) => emitted.push(data), - }; - const stalledEntry = makeEntry({ - id: 'stalled', - messageId: 'msg-stalled', - queuedFailedByCatIds: ['opus'], - queuedFailureAtByCatId: { opus: 1234 }, - }); - let stalledSettled = false; - let followingSettled = false; + const socketManager = { emitToUser: (_userId, _event, data) => emitted.push(data) }; - const stalled = emitQueueUpdated(socketManager, 'u1', 't1', [stalledEntry], messageStore, 'stalled').then(() => { - stalledSettled = true; - }); + const stalled = emitQueueUpdated(socketManager, 'u1', 't1', [makeEntry()], messageStore, 'stalled'); await lookupStartedPromise; - const following = emitQueueUpdated(socketManager, 'u1', 't1', [], messageStore, 'following').then(() => { - followingSettled = true; - }); - + const following = emitQueueUpdated(socketManager, 'u1', 't1', [], messageStore, 'following'); t.mock.timers.tick(2_000); await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(stalledSettled, true, 'deadline must settle the stalled head publication'); - assert.equal(followingSettled, true, 'deadline must release the next same-scope publication'); await Promise.all([stalled, following]); + assert.deepEqual( - emitted.map((event) => ({ action: event.action, targetStates: event.queue[0]?.targetStates })), + emitted.map((event) => ({ action: event.action, targetCats: event.queue[0]?.targetCats })), [ - { action: 'stalled', targetStates: { opus: 'failed' } }, - { action: 'following', targetStates: undefined }, + { action: 'stalled', targetCats: ['opus'] }, + { action: 'following', targetCats: undefined }, ], ); - assert.deepEqual( - emitted[0].queue[0].recoveryActions.map((action) => action.kind), - ['withdraw'], - 'deadline fallback must never advertise a custody-dependent Retry', - ); }); - it('keeps timely message enrichment and legacy optional queue fields compatible', async () => { + it('enriches a targetless pending source row without inventing a delivery target', async () => { const emitted = []; const messageStore = { getById: async (id) => ({ @@ -329,58 +206,21 @@ describe('F220 intake: queue snapshot publication ordering', () => { replyTo: 'msg-parent', }), }; - const socketManager = { - emitToUser: (_userId, _event, data) => emitted.push(data), - }; - const legacyEntry = makeEntry({ - targetCats: undefined, - mergedMessageIds: undefined, - queuedNotifiedByCatIds: undefined, - allTargetCats: undefined, - }); - await emitQueueUpdated(socketManager, 'u1', 't1', [legacyEntry], messageStore, 'enriched'); + await emitQueueUpdated( + { emitToUser: (_userId, _event, data) => emitted.push(data) }, + 'u1', + 't1', + [makeEntry({ targets: [] })], + messageStore, + 'enriched', + ); assert.deepEqual(emitted[0].queue[0].messagePreview, { contentBlocks: [{ kind: 'text', text: 'preview text' }], replyTo: 'msg-parent', }); - assert.deepEqual(emitted[0].queue[0].targetStates, {}); - }); - - it('publishes message-bound terminal receipts after withdrawal empties the Queue', async () => { - const emitted = []; - const terminalCustody = makeWithdrawnCustody(); - const messageStore = { - getById: async (messageId) => - messageId === 'msg-withdrawn' ? { id: messageId, queueCustody: terminalCustody } : null, - }; - const socketManager = { - emitToUser: (_userId, _event, data) => emitted.push(data), - }; - - await emitQueueUpdated(socketManager, 'u1', 't1', [], messageStore, 'removed', { - receiptMessageIds: ['msg-withdrawn', 'msg-withdrawn', 'msg-missing'], - }); - - assert.deepEqual(emitted, [ - { - threadId: 't1', - queue: [], - action: 'removed', - messageReceipts: [ - { - messageId: 'msg-withdrawn', - queueReceipt: { - version: 1, - entryId: 'withdrawn-entry', - targets: [{ catId: 'opus', state: 'withdrawn', withdrawnAt: 20 }], - reminderAttempts: [], - }, - }, - ], - }, - ]); + assert.deepEqual(emitted[0].queue[0].targetCats, []); }); it('does not poison a same-scope successor when the previous emitter throws', async () => { @@ -398,7 +238,6 @@ describe('F220 intake: queue snapshot publication ordering', () => { const failed = emitQueueUpdated(socketManager, 'u1', 't1', [], null, 'first'); const following = emitQueueUpdated(socketManager, 'u1', 't1', [], null, 'second'); - await assert.rejects(failed, /synthetic emit failure/); await following; assert.deepEqual(emitted, ['second']); diff --git a/packages/api/test/queue-entry-settlement.test.js b/packages/api/test/queue-entry-settlement.test.js deleted file mode 100644 index 964d68f317..0000000000 --- a/packages/api/test/queue-entry-settlement.test.js +++ /dev/null @@ -1,165 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -const { resolveQueueEntrySettlement } = await import( - '../dist/domains/cats/services/agents/invocation/queue-entry-settlement.js' -); -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { createInitialQueuedMessageCustody, QueuedMessageCustodyCoordinator } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' -); -const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - -function enqueue(queue, overrides = {}) { - const result = queue.enqueue({ - threadId: 'thread-settlement', - userId: 'user-settlement', - ownerAuthProvenance: 'strict', - content: 'durable work', - source: 'connector', - sourceCategory: 'scheduled', - targetCats: ['codex-sol'], - intent: 'execute', - autoExecute: true, - ...overrides, - }); - assert.equal(result.outcome, 'enqueued'); - return result.entry; -} - -describe('Queue entry settlement decision', () => { - test('maps terminal reason, replacement proof and action fence to one disposition', () => { - const cases = [ - [{ terminalReason: 'succeeded', replacement: { kind: 'none' }, actionFence: { kind: 'none' } }, 'consume'], - [{ terminalReason: 'user_cancel', replacement: { kind: 'none' }, actionFence: { kind: 'none' } }, 'consume'], - [{ terminalReason: 'system_failure', replacement: { kind: 'none' }, actionFence: { kind: 'none' } }, 'rollback'], - [ - { - terminalReason: 'system_failure', - replacement: { kind: 'none' }, - actionFence: { kind: 'action_successor', leaseId: 'lease-1', generation: 1 }, - }, - 'consume', - ], - [ - { - terminalReason: 'system_failure', - replacement: { kind: 'none' }, - actionFence: { kind: 'none' }, - durableTerminalOwner: { - kind: 'freshness_supplement', - supplementId: 'f254-supplement:message-1:1', - }, - }, - 'consume', - ], - [ - { - terminalReason: 'superseded', - replacement: { - kind: 'verified', - previousEntryId: 'entry-old', - replacementEntryId: 'entry-new', - sourceMessageId: 'message-1', - }, - actionFence: { kind: 'none' }, - }, - 'transfer', - ], - [{ terminalReason: 'superseded', replacement: { kind: 'none' }, actionFence: { kind: 'none' } }, 'retain'], - ]; - - for (const [input, expected] of cases) { - assert.equal(resolveQueueEntrySettlement(input), expected, JSON.stringify(input)); - } - }); - - test('explicit user cancel wins over a replacement and cannot silently transfer work', () => { - assert.equal( - resolveQueueEntrySettlement({ - terminalReason: 'user_cancel', - replacement: { - kind: 'verified', - previousEntryId: 'entry-old', - replacementEntryId: 'entry-new', - sourceMessageId: 'message-1', - }, - actionFence: { kind: 'none' }, - }), - 'consume', - ); - }); -}); - -describe('verified Queue custody replacement', () => { - test('atomically rebinds the queued source from an absent old entry to one exact replacement', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store }); - const oldEntry = enqueue(queue); - const message = store.append({ - threadId: oldEntry.threadId, - userId: oldEntry.userId, - catId: null, - content: oldEntry.content, - mentions: oldEntry.targetCats, - timestamp: oldEntry.createdAt, - deliveryStatus: 'queued', - }); - queue.backfillMessageId(oldEntry.threadId, oldEntry.userId, oldEntry.id, message.id); - const boundOld = queue.getEntrySnapshot(oldEntry.threadId, oldEntry.userId, oldEntry.id); - assert.equal( - store.initializeQueueCustody(message.id, createInitialQueuedMessageCustody(boundOld)).kind, - 'initialized', - ); - assert.ok(queue.remove(oldEntry.threadId, oldEntry.userId, oldEntry.id)); - - const replacement = enqueue(queue, { content: oldEntry.content, messageId: message.id }); - const proof = { - kind: 'verified', - previousEntryId: oldEntry.id, - replacementEntryId: replacement.id, - sourceMessageId: message.id, - }; - assert.equal(await coordinator.transferEntryCustody(replacement, proof), true); - - const rebound = store.getById(message.id); - assert.equal(rebound.deliveryStatus, 'queued'); - assert.equal(rebound.queueCustody.entryId, replacement.id); - assert.equal(rebound.queueCustody.status, 'queued'); - }); - - test('rejects a forged replacement proof without changing durable custody', async () => { - const queue = new InvocationQueue(); - const store = new MessageStore(); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: store }); - const oldEntry = enqueue(queue); - const message = store.append({ - threadId: oldEntry.threadId, - userId: oldEntry.userId, - catId: null, - content: oldEntry.content, - mentions: oldEntry.targetCats, - timestamp: oldEntry.createdAt, - deliveryStatus: 'queued', - }); - queue.backfillMessageId(oldEntry.threadId, oldEntry.userId, oldEntry.id, message.id); - const boundOld = queue.getEntrySnapshot(oldEntry.threadId, oldEntry.userId, oldEntry.id); - assert.equal( - store.initializeQueueCustody(message.id, createInitialQueuedMessageCustody(boundOld)).kind, - 'initialized', - ); - const replacement = enqueue(queue, { messageId: message.id, targetCats: ['opus'] }); - - await assert.rejects( - coordinator.transferEntryCustody(replacement, { - kind: 'verified', - previousEntryId: 'forged-old-entry', - replacementEntryId: replacement.id, - sourceMessageId: message.id, - }), - /replacement|custody|target/i, - ); - assert.equal(store.getById(message.id).queueCustody.entryId, oldEntry.id); - }); -}); diff --git a/packages/api/test/queue-failed-only-fairness.test.js b/packages/api/test/queue-failed-only-fairness.test.js index 627bfd74d7..6880c610ed 100644 --- a/packages/api/test/queue-failed-only-fairness.test.js +++ b/packages/api/test/queue-failed-only-fairness.test.js @@ -2,408 +2,77 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); -const { projectPublicQueueEntry } = await import('../dist/utils/queue-enrichment.js'); -const { QueuedMessageCustodyCoordinator, createInitialQueuedMessageCustody } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' -); -const { StartupReconciler } = await import('../dist/domains/cats/services/agents/invocation/StartupReconciler.js'); -const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); -const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); -function enqueue(queue, overrides = {}) { - return queue.enqueue({ +function queueInput(sourceId, overrides = {}) { + return { threadId: 'thread-fairness', userId: 'user-owner', - content: 'queued work', - source: 'user', + sourceId, + kind: 'conversation_input', ownerAuthProvenance: 'strict', + content: `body-${sourceId}`, + messageId: `message-${sourceId}`, + from: { kind: 'user', userId: 'user-owner' }, targetCats: ['opus'], intent: 'execute', ...overrides, - }).entry; -} - -function markFailed(queue, entry, catId) { - queue.markQueuedFailedForCatAcrossUsers(entry.threadId, catId, `failed-invocation-${catId}`, new Set([entry.id])); -} -function createProcessorHarness({ - queue = new InvocationQueue(), - messageStore = new MessageStore(), - busyCatIds = new Set(), - persistCustody = false, -} = {}) { - const startedCats = []; - let invocationSequence = 0; - const socketManager = { - broadcastAgentMessage() {}, - broadcastToRoom() {}, - emitToUser() {}, - }; - const deps = { - queue, - messageStore, - socketManager, - invocationTracker: { - has(_threadId, catId) { - return busyCatIds.has(catId); - }, - startAll(_threadId, catIds) { - startedCats.push(...catIds); - return new AbortController(); - }, - waitForSessionSealRelease() { - return Promise.resolve(); - }, - completeAll() {}, - }, - invocationRecordStore: { - create() { - invocationSequence += 1; - return { outcome: 'created', invocationId: `inv-fairness-${invocationSequence}` }; - }, - update(id, data) { - return { id, ...data }; - }, - }, - router: { - async *routeExecution(_userId, _content, _threadId, _messageId, targetCats) { - for (const catId of targetCats) yield { type: 'done', catId, timestamp: Date.now() }; - }, - async ackCollectedCursors() {}, - }, - log: { info() {}, warn() {}, error() {} }, }; - if (persistCustody) { - deps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - } - return { deps, processor: new QueueProcessor(deps), queue, messageStore, socketManager, startedCats }; } -const flushDispatch = () => new Promise((resolve) => setTimeout(resolve, 25)); -async function enqueueFreshA2A(harness, { crossThread }) { - const triggerMessage = harness.messageStore.append({ - userId: 'user-a2a', - catId: 'opus', - content: crossThread ? 'cross-thread fresh A2A' : 'same-thread fresh A2A', - mentions: ['codex'], - timestamp: 200, - threadId: 'thread-fairness', - ...(crossThread ? { deliveryStatus: 'queued' } : {}), - ...(crossThread - ? { - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'inv-source', - effectClass: 'coordinate', - }, - }, - } - : {}), +async function terminalizeFailed(queue, entry, targetCatId = entry.targets[0], reason = 'invocation_failed') { + assert.ok(targetCatId); + const claimed = await queue.markProcessingDurable(entry.threadId, 'user-owner', { + entryId: entry.id, + targetCats: [targetCatId], }); - return enqueueA2ATargets( - { - router: harness.deps.router, - invocationRecordStore: harness.deps.invocationRecordStore, - socketManager: harness.socketManager, - invocationTracker: harness.deps.invocationTracker, - messageStore: harness.messageStore, - queueProcessor: harness.processor, - invocationQueue: harness.queue, - log: harness.deps.log, - }, - { - targetCats: ['codex'], - content: triggerMessage.content, - userId: 'user-a2a', - ownerAuthProvenance: 'strict', - threadId: triggerMessage.threadId, - triggerMessage, - callerCatId: 'opus', - parentInvocationId: 'inv-source', - }, - ); + assert.ok(claimed); + assert.equal(await queue.commitClaimedProcessing(entry.threadId, [entry.id], 2_000), true); + assert.ok(await queue.removeProcessedAcrossUsersDurable(entry.threadId, entry.id, 'failed', reason, 3_000)); } -describe('#1371 failed-only non-agent fairness gate', () => { - for (const crossThread of [false, true]) { - it(`${crossThread ? 'cross-thread' : 'same-thread'} enqueueA2ATargets skips a failed-only connector and starts fresh A2A`, async () => { - const harness = createProcessorHarness({ persistCustody: crossThread }); - const failedConnector = enqueue(harness.queue, { - userId: 'user-connector', - content: 'old scheduled connector', - source: 'connector', - sourceCategory: 'scheduled', - autoExecute: false, - targetCats: ['opus'], - }); - markFailed(harness.queue, failedConnector, 'opus'); - const result = await enqueueFreshA2A(harness, { crossThread }); - await flushDispatch(); - assert.deepEqual(result.enqueued, ['codex']); - assert.deepEqual(harness.startedCats, ['codex'], 'fresh A2A must start without replaying the failed target'); - const retained = harness.queue.getEntrySnapshot('thread-fairness', 'user-connector', failedConnector.id); - assert.equal(retained.status, 'queued'); - assert.deepEqual(retained.queuedFailedByCatIds, ['opus']); - }); - } - - it('restart-restored failed-only connector does not poison a later same-thread A2A', async () => { - const messageStore = new MessageStore(); - const beforeRestartQueue = new InvocationQueue(); - const failedConnector = enqueue(beforeRestartQueue, { - userId: 'user-connector', - content: 'durable scheduled connector', - source: 'connector', - sourceCategory: 'scheduled', - autoExecute: false, - targetCats: ['opus'], - }); - const connectorMessage = messageStore.append({ - userId: failedConnector.userId, - catId: null, - content: failedConnector.content, - mentions: failedConnector.targetCats, - timestamp: failedConnector.createdAt, - threadId: failedConnector.threadId, - deliveryStatus: 'queued', - source: 'connector', - queueCustody: createInitialQueuedMessageCustody(failedConnector), - }); - beforeRestartQueue.backfillMessageId( - failedConnector.threadId, - failedConnector.userId, - failedConnector.id, - connectorMessage.id, - ); - markFailed(beforeRestartQueue, failedConnector, 'opus'); - const custodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - await custodyCoordinator.persistEntry( - beforeRestartQueue.getEntrySnapshot(failedConnector.threadId, failedConnector.userId, failedConnector.id), - ); - messageStore.scanByDeliveryStatus = (status) => (status === 'queued' ? [connectorMessage.id] : []); - - const restartedQueue = new InvocationQueue(); - const startup = new StartupReconciler({ - invocationRecordStore: { - async scanByStatus() { - return []; - }, - async get() { - return null; - }, - async update() {}, - }, - invocationQueue: restartedQueue, - messageStore, - taskProgressStore: { async deleteSnapshot() {} }, - log: { info() {}, warn() {} }, - }); - const recovery = await startup.reconcileOrphans(); - assert.equal(recovery.queueEntriesRestored, 1); - const restored = restartedQueue.getEntrySnapshot( - failedConnector.threadId, - failedConnector.userId, - failedConnector.id, - ); - assert.deepEqual(restored.queuedFailedByCatIds, ['opus']); - assert.ok( - projectPublicQueueEntry(restored).recoveryActions.some( - (action) => action.kind === 'retry_target' && action.targetCatId === 'opus', - ), - 'restart restoration must retain an executable exact Retry projection', - ); - const harness = createProcessorHarness({ queue: restartedQueue, messageStore }); - await enqueueFreshA2A(harness, { crossThread: false }); - await flushDispatch(); - assert.deepEqual(harness.startedCats, ['codex']); - assert.deepEqual( - restartedQueue.getEntrySnapshot(failedConnector.threadId, failedConnector.userId, failedConnector.id) - .queuedFailedByCatIds, - ['opus'], +describe('#1371 terminal-failure fairness over ADR-043 ledger', () => { + it('a failed terminal row cannot block a later independent admission', async () => { + const queue = new InvocationQueue(); + const failed = await queue.enqueueDurable(queueInput('failed-first')); + await terminalizeFailed(queue, failed.entry); + + const later = await queue.enqueueDurable( + queueInput('later-a2a', { + from: { kind: 'agent', catId: 'codex' }, + sourceCategory: 'a2a', + targetCats: ['codex'], + autoExecute: true, + }), ); - }); - - it('mixed connector still wins fairness through its ordinary-eligible sibling', async () => { - const harness = createProcessorHarness(); - const mixedConnector = enqueue(harness.queue, { - userId: 'user-connector', - source: 'connector', - targetCats: ['opus', 'gemini'], - }); - markFailed(harness.queue, mixedConnector, 'opus'); - enqueue(harness.queue, { - userId: 'user-a2a', - source: 'agent', - sourceCategory: 'a2a', + const claimed = await queue.markProcessingDurable('thread-fairness', 'user-owner', { + entryId: later.entry.id, targetCats: ['codex'], - autoExecute: true, }); - await harness.processor.tryAutoExecute('thread-fairness'); - await flushDispatch(); - assert.equal(harness.startedCats[0], 'gemini', 'eligible non-agent sibling must retain fairness priority'); - assert.equal(harness.startedCats.includes('opus'), false, 'failed sibling must never be replayed'); - }); - it('mixed connector starts only its unpaused sibling before later A2A advances', async () => { - const harness = createProcessorHarness(); - const { promise: routeBarrier, resolve: releaseRoute } = Promise.withResolvers(); - harness.deps.router.routeExecution = async function* (_userId, _content, _threadId, _messageId, targetCats) { - await routeBarrier; - for (const catId of targetCats) yield { type: 'done', catId, timestamp: Date.now() }; - }; - enqueue(harness.queue, { - userId: 'user-connector', - source: 'connector', - targetCats: ['opus', 'gemini'], - }); - await harness.processor.onInvocationComplete( - 'thread-fairness', - 'opus', - 'failed', - undefined, - [], - false, - {}, - [], - {}, - true, + assert.equal(claimed?.id, later.entry.id); + assert.deepEqual( + queue.list('thread-fairness', 'user-owner').map((entry) => entry.id), + [later.entry.id], ); - assert.equal(harness.processor.isPaused('thread-fairness', 'opus'), true); - enqueue(harness.queue, { - userId: 'user-a2a', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - autoExecute: true, - }); - await harness.processor.tryAutoExecute('thread-fairness'); - assert.deepEqual(harness.startedCats, ['gemini']); - releaseRoute(); - await flushDispatch(); - assert.deepEqual(harness.startedCats, ['gemini', 'codex']); - }); - - it('busy ordinary-eligible connector still holds the fairness gate', async () => { - const harness = createProcessorHarness({ busyCatIds: new Set(['gemini']) }); - enqueue(harness.queue, { - userId: 'user-connector', - source: 'connector', - targetCats: ['gemini'], - }); - const a2a = enqueue(harness.queue, { - userId: 'user-a2a', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - autoExecute: true, - }); - - await harness.processor.tryAutoExecute('thread-fairness'); - await new Promise((resolve) => setTimeout(resolve, 25)); - - assert.deepEqual(harness.startedCats, []); - assert.equal(harness.queue.getEntrySnapshot(a2a.threadId, a2a.userId, a2a.id).status, 'queued'); + assert.equal(await queue.getDurableEntry('thread-fairness', failed.entry.id), null); }); - it('failed-only row in one user scope cannot block concurrent A2A in other user scopes', async () => { - const harness = createProcessorHarness(); - const failedConnector = enqueue(harness.queue, { - userId: 'user-connector', - source: 'connector', - targetCats: ['opus'], - }); - markFailed(harness.queue, failedConnector, 'opus'); - enqueue(harness.queue, { - userId: 'user-a2a-1', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex'], - autoExecute: true, - }); - enqueue(harness.queue, { - userId: 'user-a2a-2', - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['codex-terra'], - autoExecute: true, - }); - - await harness.processor.tryAutoExecute('thread-fairness'); - await flushDispatch(); - - assert.deepEqual(new Set(harness.startedCats), new Set(['codex', 'codex-terra'])); - }); - - it('failed target remains closed to ordinary scheduling until exact Retry reopens it', () => { + it('terminalizing one fan-out target preserves the queued sibling', async () => { const queue = new InvocationQueue(); - const failedConnector = enqueue(queue, { - userId: 'user-connector', - source: 'connector', - targetCats: ['opus'], - }); - markFailed(queue, failedConnector, 'opus'); - - assert.equal(queue.markProcessingAcrossUsers('thread-fairness'), null); - const retry = queue.retryFailedTarget('thread-fairness', 'user-connector', failedConnector.id, 'opus'); - assert.ok(retry, 'exact Retry must be the only operation that reopens the failed target'); - const processing = queue.markProcessingAcrossUsers('thread-fairness'); - assert.equal(processing.id, failedConnector.id); - assert.deepEqual(processing.queuedFailedByCatIds, undefined); - }); - - it('message-less A2A Retry consumes one exact failure fence without reopening its sibling', async () => { - const harness = createProcessorHarness({ busyCatIds: new Set(['opus']) }); - const entry = enqueue(harness.queue, { - userId: 'user-a2a', - source: 'agent', - sourceCategory: 'a2a', - autoExecute: true, - messageId: null, - targetCats: ['opus', 'codex'], + const fanout = await queue.enqueueDurable(queueInput('fanout', { targetCats: ['opus', 'gemini'] })); + const [sourceEntry] = fanout.entries; + assert.ok(sourceEntry); + assert.deepEqual(sourceEntry.targets, ['opus', 'gemini']); + + await terminalizeFailed(queue, sourceEntry, 'opus', 'provider_failed'); + + const remaining = queue.getEntrySnapshot('thread-fairness', 'user-owner', sourceEntry.id); + assert.equal(remaining.status, 'queued'); + assert.deepEqual(remaining.targets, ['gemini']); + const claimed = await queue.markProcessingDurable('thread-fairness', 'user-owner', { + entryId: sourceEntry.id, + targetCats: ['gemini'], }); - harness.queue.markQueuedFailedForCatAcrossUsers( - entry.threadId, - 'opus', - 'inv-opus', - new Set([entry.id]), - 'invocation_failed', - 1234, - ); - harness.queue.markQueuedFailedForCatAcrossUsers( - entry.threadId, - 'codex', - 'inv-codex', - new Set([entry.id]), - 'invocation_failed', - 1235, - ); - const projected = projectPublicQueueEntry(harness.queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id)); - const action = projected.recoveryActions.find( - (candidate) => candidate.kind === 'retry_target' && candidate.targetCatId === 'opus', - ); - assert.ok(action); - - const first = await harness.processor.retryFailedTargetWithoutCustody( - entry.threadId, - entry.userId, - entry.id, - 'opus', - action.id, - ); - const second = await harness.processor.retryFailedTargetWithoutCustody( - entry.threadId, - entry.userId, - entry.id, - 'opus', - action.id, - ); - - assert.equal(first.outcome, 'retried'); - assert.equal(second.outcome, 'not_retryable'); - const current = harness.queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id); - assert.deepEqual(current.queuedFailedByCatIds, ['codex']); - assert.equal(current.queuedAttemptIdByCatId.opus, `${action.id}:attempt`); + assert.equal(claimed?.id, sourceEntry.id); }); }); diff --git a/packages/api/test/queue-gate-thread-level.test.js b/packages/api/test/queue-gate-thread-level.test.js deleted file mode 100644 index c747216cda..0000000000 --- a/packages/api/test/queue-gate-thread-level.test.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Queue gate thread-level regression test - * - * Bug: messages.ts used invocationTracker.has(threadId, primaryCat) which only - * checks the target cat's slot. When cat B is active in the thread but user - * sends to cat A, the message bypasses the queue and executes immediately, - * causing two cats to talk simultaneously. - * - * Fix: Use has(threadId) (no catId) to check if ANY cat is active in the thread. - */ - -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); - -function buildDeps(overrides = {}) { - const invocationQueue = new InvocationQueue(); - return { - registry: new InvocationRegistry(), - messageStore: { - append: mock.fn(async (msg) => ({ id: `msg-${Date.now()}`, ...msg })), - getByThread: mock.fn(async () => []), - getByThreadBefore: mock.fn(async () => []), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - router: { - resolveTargetsAndIntent: mock.fn(async () => ({ - targetCats: ['opus'], - intent: { intent: 'execute' }, - })), - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - route: mock.fn(async function* () { - yield { type: 'done' }; - }), - }, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - tryStartThread: mock.fn(() => new AbortController()), - tryStartThreadAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - // Realistic slot-aware mock: only gemini slot is active - has: mock.fn((threadId, catId) => { - if (catId === undefined) { - // Thread-level check: any slot active? - return threadId === 'thread-1'; - } - // Slot-level check: only gemini is active - return threadId === 'thread-1' && catId === 'gemini'; - }), - cancel: mock.fn(() => ({ cancelled: true, catIds: ['opus'] })), - isDeleting: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'created', - invocationId: 'inv-stub', - })), - update: mock.fn(async () => {}), - }, - invocationQueue, - threadStore: { - get: mock.fn(async () => ({ - id: 'thread-1', - title: 'Test Thread', - createdBy: 'test-user', - })), - updateTitle: mock.fn(async () => {}), - }, - ...overrides, - }; -} - -describe('Queue gate: thread-level enqueue (regression)', () => { - let app; - let deps; - - beforeEach(async () => { - deps = buildDeps(); - const { messagesRoutes } = await import('../dist/routes/messages.js'); - app = Fastify(); - await app.register(messagesRoutes, deps); - await app.ready(); - }); - - afterEach(async () => { - if (app) await app.close(); - }); - - it('cat B active in thread → message to cat A should be queued, not immediate', async () => { - // Setup: gemini is active in thread-1, user sends message targeting opus - // has('thread-1', 'opus') → false (opus slot not active) - // has('thread-1') → true (thread has active invocation) - // Bug: old code called has(threadId, primaryCat) → false → immediate - // Fix: code should call has(threadId) → true → queue - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus 你好', threadId: 'thread-1' }, - }); - - // Should be queued (202), NOT immediate (200) - assert.equal(res.statusCode, 202, 'message should be queued when another cat is active in thread'); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued'); - - // Should NOT have created InvocationRecord (queued, not executing) - assert.equal( - deps.invocationRecordStore.create.mock.calls.length, - 0, - 'should not start immediate execution when thread has active invocation', - ); - }); - - it('no cat active in thread → message executes immediately', async () => { - // Override: no activity in thread-2 - deps.invocationTracker.has.mock.mockImplementation((_threadId, _catId) => false); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus 你好', threadId: 'thread-2' }, - }); - - assert.equal(res.statusCode, 200, 'message should execute immediately when no cat is active'); - const body = JSON.parse(res.body); - assert.equal(body.status, 'processing'); - }); - - it('explicit deliveryMode=queue + different cat active → must enqueue', async () => { - // User explicitly requests queue mode; gemini is active but target is opus - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: '@opus 排队发', threadId: 'thread-1', deliveryMode: 'queue' }, - }); - - assert.equal(res.statusCode, 202, 'explicit queue mode should enqueue when thread has activity'); - const body = JSON.parse(res.body); - assert.equal(body.status, 'queued'); - }); -}); diff --git a/packages/api/test/queue-integration-f175.test.js b/packages/api/test/queue-integration-f175.test.js index 252d122a90..381c641d6c 100644 --- a/packages/api/test/queue-integration-f175.test.js +++ b/packages/api/test/queue-integration-f175.test.js @@ -5,28 +5,32 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { adaptInvocationQueue, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); describe('#564 regression: urgent connector does not break A2A chain', () => { it('urgent enqueue does not abort active signal', () => { - const queue = new InvocationQueue(); + const queue = adaptInvocationQueue(new InvocationQueue()); // 1. Simulate active invocation (signal alive) const controller = new AbortController(); // 2. Urgent connector message arrives and enqueues (F175: no preemption) - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'CI failed', - source: 'connector', - targetCats: ['opus'], - intent: 'execute', - priority: 'urgent', - sourceCategory: 'ci', - }); + const result = queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'CI failed', + source: 'connector', + targetCats: ['opus'], + intent: 'execute', + priority: 'urgent', + sourceCategory: 'ci', + }), + ); // 3. Signal NOT aborted (the critical invariant from #564) assert.equal(controller.signal.aborted, false, 'active signal must not be aborted by urgent enqueue'); @@ -40,20 +44,23 @@ describe('#564 regression: urgent connector does not break A2A chain', () => { }); it('multiple urgent enqueues during active invocation all preserve signal', () => { - const queue = new InvocationQueue(); + const queue = adaptInvocationQueue(new InvocationQueue()); const controller = new AbortController(); for (let i = 0; i < 3; i++) { - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: `urgent-${i}`, - source: 'connector', - targetCats: ['opus'], - intent: 'execute', - priority: 'urgent', - }); + queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: `urgent-${i}`, + source: 'connector', + targetCats: ['opus'], + intent: 'execute', + priority: 'urgent', + }), + ); } assert.equal(controller.signal.aborted, false); @@ -62,103 +69,130 @@ describe('#564 regression: urgent connector does not break A2A chain', () => { }); describe('cross-priority auto-dequeue', () => { - it('urgent dequeues before normal regardless of enqueue order', () => { - const queue = new InvocationQueue(); + it('urgent dequeues before normal regardless of enqueue order', async () => { + const queue = adaptInvocationQueue(new InvocationQueue()); // Normal enqueued first - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'normal first', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); + queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'normal first', + source: 'user', + targetCats: ['opus'], + intent: 'execute', + priority: 'normal', + }), + ); // Urgent enqueued second - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u2', - content: 'urgent second', - source: 'connector', - targetCats: ['opus'], - intent: 'execute', - priority: 'urgent', - }); + queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u2', + content: 'urgent second', + source: 'connector', + targetCats: ['opus'], + intent: 'execute', + priority: 'urgent', + }), + ); // markProcessingAcrossUsers should pick urgent first - const first = queue.markProcessingAcrossUsers('t1'); + const head = queue.peekOldestAcrossUsers('t1'); + const first = await queue.markProcessingAcrossUsersDurable('t1', { + entryId: head.id, + targetCats: ['opus'], + }); assert.equal(first.priority, 'urgent'); - assert.equal(first.content, 'urgent second'); + assert.equal(first.payload.content, 'urgent second'); }); - it('after urgent completes, normal entry is next in line', () => { - const queue = new InvocationQueue(); + it('after urgent completes, normal entry is next in line', async () => { + const queue = adaptInvocationQueue(new InvocationQueue()); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'normal', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u2', - content: 'urgent', - source: 'connector', - targetCats: ['opus'], - intent: 'execute', - priority: 'urgent', - }); + queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'normal', + source: 'user', + targetCats: ['opus'], + intent: 'execute', + priority: 'normal', + }), + ); + queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u2', + content: 'urgent', + source: 'connector', + targetCats: ['opus'], + intent: 'execute', + priority: 'urgent', + }), + ); // Process urgent - const urgent = queue.markProcessingAcrossUsers('t1'); + const urgentHead = queue.peekOldestAcrossUsers('t1'); + const urgent = await queue.markProcessingAcrossUsersDurable('t1', { + entryId: urgentHead.id, + targetCats: ['opus'], + }); assert.equal(urgent.priority, 'urgent'); - queue.removeProcessedAcrossUsers('t1', urgent.id); + await queue.commitClaimedProcessing('t1', [urgent.id]); + await queue.removeProcessedAcrossUsersDurable('t1', urgent.id, 'handled'); // Next should be normal const normal = queue.peekOldestAcrossUsers('t1'); assert.equal(normal.priority, 'normal'); - assert.equal(normal.content, 'normal'); + assert.equal(normal.payload.content, 'normal'); }); - it('position override trumps priority in dequeue order', () => { - const queue = new InvocationQueue(); + it('position override trumps priority in dequeue order', async () => { + const queue = adaptInvocationQueue(new InvocationQueue()); - const urgentResult = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'urgent', - source: 'connector', - targetCats: ['opus'], - intent: 'execute', - priority: 'urgent', - }); + const _urgentResult = queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'urgent', + source: 'connector', + targetCats: ['opus'], + intent: 'execute', + priority: 'urgent', + }), + ); - const normalResult = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'normal-pinned', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); + const normalResult = queue.enqueue( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'normal-pinned', + source: 'user', + targetCats: ['opus'], + intent: 'execute', + priority: 'normal', + }), + ); // User drags normal entry to position 0 - queue.setPosition('t1', 'u1', normalResult.entry.id, 0); + await queue.setPositionDurable('t1', 'u1', normalResult.entry.id, 0); const next = queue.peekOldestAcrossUsers('t1'); - assert.equal(next.content, 'normal-pinned', 'position override should trump urgent priority'); + assert.equal(next.payload.content, 'normal-pinned', 'position override should trump urgent priority'); }); }); diff --git a/packages/api/test/queue-integration.test.js b/packages/api/test/queue-integration.test.js index 05f8a18bee..4702fdd054 100644 --- a/packages/api/test/queue-integration.test.js +++ b/packages/api/test/queue-integration.test.js @@ -2,9 +2,12 @@ import assert from 'node:assert'; import { beforeEach, describe, it } from 'node:test'; +import { resolveManagedCommandWakeEventCarrier } from '../dist/domains/ball-custody/managed-command-wake-lifecycle.js'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { ConnectorInvokeTrigger } from '../dist/infrastructure/email/ConnectorInvokeTrigger.js'; +import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; // ─── Shared Mocks ─────────────────────────────────────────────── @@ -28,17 +31,36 @@ function noopLog() { function mockRouter(opts = {}) { const calls = /** @type {any[]} */ ([]); const ackCalls = /** @type {any[]} */ ([]); + let lifecycleInvocationSequence = 0; return { calls, ackCalls, /** @type {any} */ router: { - async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, _options) { + async resolveExplicitTargets(requestedCatIds) { + return [...requestedCatIds]; + }, + async resolveConversationTargetsAtAdmission(requestedCatIds) { + return requestedCatIds.length > 0 ? [...requestedCatIds] : ['opus']; + }, + async *routeExecution(userId, message, threadId, userMessageId, targetCats, intent, options) { calls.push({ userId, message, threadId, userMessageId, targetCats, intent }); if (/** @type {any} */ (opts).throwError) throw /** @type {any} */ (opts).throwError; + if (options.onLifecycleInvocationStarted) { + lifecycleInvocationSequence += 1; + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: `integration-turn-${lifecycleInvocationSequence}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); + } + yield { type: 'text', catId: targetCats[0], content: `Processed: ${message}`, timestamp: Date.now() }; yield { type: 'done', @@ -222,7 +244,7 @@ describe('Queue Integration (E2E scenarios)', () => { invocationRecordStore: recordMock.store, router: routerMock.router, socketManager: socketMock.manager, - messageStore: { getById: async () => null }, + messageStore: new MessageStore(), log: noopLog(), }); }); @@ -232,15 +254,19 @@ describe('Queue Integration (E2E scenarios)', () => { trackerMock.setActive('thread-1'); // 2. Enqueue a user message (simulating what POST /api/messages does) - const result = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'Fix the bug', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); + const result = await queue.enqueueDurable( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'conversation_input', + sourceId: 'integration-fix-bug', + content: 'Fix the bug', + source: 'user', + targetCats: ['opus'], + intent: 'execute', + }), + ); assert.strictEqual(result.outcome, 'enqueued'); // 3. Previous invocation completes (succeeded → auto-dequeue) @@ -268,19 +294,23 @@ describe('Queue Integration (E2E scenarios)', () => { invocationRecordStore: recordMock.store, router: routerMock.router, socketManager: socketMock.manager, - messageStore: /** @type {any} */ ({ getById: async () => null }), + messageStore: new MessageStore(), log: noopLog(), }); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'Run only after the old session pointer is cleared', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - }); + await queue.enqueueDurable( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'conversation_input', + sourceId: 'integration-session-seal', + content: 'Run only after the old session pointer is cleared', + source: 'user', + targetCats: ['opus'], + intent: 'execute', + autoExecute: true, + }), + ); const start = await localProcessor.processNext('thread-1', 'user-1'); assert.equal(start.started, true); @@ -298,44 +328,63 @@ describe('Queue Integration (E2E scenarios)', () => { assert.equal(queue.list('thread-1', 'user-1').length, 0); }); - it('E2E: cancel → queue paused → processNext → resumes', async () => { + it('E2E: terminal cancellation requests the next Queue drain', async () => { // 1. Enqueue a message trackerMock.setActive('thread-1'); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'Continue working', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); + await queue.enqueueDurable( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'user-1', + kind: 'conversation_input', + sourceId: 'integration-terminal-cancel', + content: 'Continue working', + source: 'user', + targetCats: ['opus'], + intent: 'execute', + }), + ); - // 2. Cancel invocation → queue pauses + // 2. Terminal cancellation requests a drain. trackerMock.clearActive('thread-1'); await processor.onInvocationComplete('thread-1', 'opus', 'canceled'); - - // 3. Verify queue_paused emitted - const pauseEmit = socketMock.userEmits.find((e) => e.event === 'queue_paused'); - assert.ok(pauseEmit, 'Should emit queue_paused'); - assert.strictEqual(pauseEmit.data.reason, 'canceled'); - assert.ok(processor.isPaused('thread-1'), 'Thread should be paused'); - - // 4. No auto-dequeue should have happened - assert.strictEqual(routerMock.calls.length, 0, 'Should NOT auto-dequeue on cancel'); - - // 5. co-creator manually triggers processNext - const processResult = await processor.processNext('thread-1', 'user-1'); - assert.strictEqual(processResult.started, true); await settle(); - // 6. Verify message was processed + // 3. Verify the message was processed without a Continue transition. assert.strictEqual(routerMock.calls.length, 1); assert.strictEqual(routerMock.calls[0].message, 'Continue working'); - assert.strictEqual(processor.isPaused('thread-1'), false, 'Thread should be unpaused'); + assert.equal( + socketMock.userEmits.some((event) => event.event === 'queue_paused'), + false, + ); }); it('E2E: connector message arrives during active invocation → queued', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const messageStore = new MessageStore(); + const sourceMessage = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + from: { kind: 'external', connectorId: 'email' }, + content: 'Review email content', + mentions: ['opus'], + origin: 'connector', + timestamp: Date.now(), + threadId: 'thread-1', + deliveryStatus: 'queued', + }), + ); + const localProcessor = new QueueProcessor({ + queue, + invocationTracker: trackerMock.tracker, + invocationRecordStore: recordMock.store, + router: routerMock.router, + socketManager: socketMock.manager, + messageStore, + log: noopLog(), + }); + // 1. Simulate active invocation trackerMock.setActive('thread-1'); @@ -346,10 +395,12 @@ describe('Queue Integration (E2E scenarios)', () => { invocationRecordStore: recordMock.store, invocationTracker: trackerMock.tracker, invocationQueue: queue, + queueProcessor: localProcessor, + messageStore, log: noopLog(), }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review email content', 'msg-connector-1'); + trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', sourceMessage.content, sourceMessage.id); await settle(); // 3. Verify it was queued (NOT directly executed) @@ -358,9 +409,9 @@ describe('Queue Integration (E2E scenarios)', () => { const entries = queue.list('thread-1', 'user-1'); assert.strictEqual(entries.length, 1); - assert.strictEqual(entries[0].source, 'connector'); - assert.strictEqual(entries[0].content, 'Review email content'); - assert.strictEqual(entries[0].messageId, 'msg-connector-1'); + assert.deepEqual(entries[0].from, { kind: 'external', connectorId: 'email' }); + assert.strictEqual(entries[0].payload.content, 'Review email content'); + assert.strictEqual(entries[0].payload.messageId, sourceMessage.id); // 4. queue_updated emitted const queueUpdate = socketMock.userEmits.find((e) => e.event === 'queue_updated'); @@ -368,142 +419,184 @@ describe('Queue Integration (E2E scenarios)', () => { // 5. Active invocation completes → auto-dequeue trackerMock.clearActive('thread-1'); - await processor.onInvocationComplete('thread-1', 'opus', 'succeeded'); + await localProcessor.onInvocationComplete('thread-1', 'opus', 'succeeded'); await settle(); assert.strictEqual(routerMock.calls.length, 1, 'Should auto-dequeue after completion'); assert.strictEqual(routerMock.calls[0].message, 'Review email content'); }); - it('E2E: force mode aborts + executes immediately (queue unchanged)', async () => { - // Setup: active invocation + one queued message - trackerMock.setActive('thread-1'); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'Queued msg', - source: 'user', - targetCats: ['opus'], - intent: 'execute', + it('E2E: a managed-command wake waits behind the active turn and settles from its response terminal', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const messageStore = new MessageStore(); + const wakeMessage = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + from: { kind: 'system', service: 'managed-command-wake' }, + content: '[定时任务] 持球唤醒(命令完成):门禁已结束。', + mentions: ['opus'], + origin: 'connector', + timestamp: Date.now(), + threadId: 'thread-1', + deliveryStatus: 'queued', + idempotencyKey: 'hold-ball-completion:hold-ball-e2e', + source: { + connector: 'hold-ball', + label: '持球通知', + icon: '🏓', + meta: { + managedHold: true, + phase: 'wake', + taskId: 'hold-ball-e2e', + threadId: 'thread-1', + catId: 'opus', + wakeWhen: true, + }, + }, + }), + ); + const routeCalls = []; + let responseMessageId; + const lifecycleRouter = { + async resolveExplicitTargets(requestedCatIds) { + return [...requestedCatIds]; + }, + async resolveConversationTargetsAtAdmission(requestedCatIds) { + return [...requestedCatIds]; + }, + async *routeExecution(userId, message, threadId, _messageId, targetCats, _intent, options) { + routeCalls.push({ userId, message, threadId, targetCats }); + const invocationId = 'managed-wake-invocation'; + const startedAt = Date.now(); + const admission = await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId, + parentInvocationId: options.parentInvocationId, + startedAt, + }); + responseMessageId = admission.responseMessageId; + const terminal = await messageStore.commitLifecycleResponseTerminal(responseMessageId, { + invocationId, + status: 'completed', + completedAt: startedAt + 1, + content: '门禁失败,已读取结果并完成处置。', + mentions: [], + origin: 'stream', + }); + assert.equal(terminal.kind, 'applied'); + yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: startedAt + 1 }; + }, + async ackCollectedCursors() {}, + }; + const localProcessor = new QueueProcessor({ + queue, + invocationTracker: trackerMock.tracker, + invocationRecordStore: recordMock.store, + router: lifecycleRouter, + socketManager: socketMock.manager, + messageStore, + log: noopLog(), + }); + const trigger = new ConnectorInvokeTrigger({ + socketManager: socketMock.manager, + invocationQueue: queue, + queueProcessor: localProcessor, + messageStore, + log: noopLog(), }); - // Force send simulates what POST /api/messages does with deliveryMode=force: - // 1. Cancel active invocation (InvocationTracker.cancel) - // 2. Direct execution (bypass queue) - // The force send itself is handled by messages.ts, not the queue. - // After force completes → onInvocationComplete('canceled') pauses queue. - - trackerMock.clearActive('thread-1'); - await processor.onInvocationComplete('thread-1', 'opus', 'canceled'); - - // Queue should be paused (canceled status) - assert.ok(processor.isPaused('thread-1'), 'Queue should pause after force-cancel'); - - // Queue message should still be there (not auto-dequeued) - const entries = queue.list('thread-1', 'user-1'); - assert.strictEqual(entries.length, 1, 'Queued message should persist'); - assert.strictEqual(entries[0].content, 'Queued msg'); - - // pause event emitted - const pauseEmit = socketMock.userEmits.find((e) => e.event === 'queue_paused'); - assert.ok(pauseEmit, 'Should emit queue_paused'); - }); - - // ── F39 bugfix: clearPause prevents state poisoning ── - - it('bugfix: clearPause prevents stale pause from old invocation cleanup', async () => { - // Simulate force-send flow: - // 1. Active invocation running trackerMock.setActive('thread-1'); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'Queued msg', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); + assert.equal( + await trigger.trigger( + 'thread-1', + /** @type {any} */ ('opus'), + 'user-1', + wakeMessage.content, + wakeMessage.id, + undefined, + { priority: 'urgent', sourceCategory: 'scheduled' }, + ), + 'enqueued', + ); - // 2. Force cancel → clearPause (what messages.ts now does) - trackerMock.clearActive('thread-1'); - processor.clearPause('thread-1'); + assert.equal(routeCalls.length, 0, 'the wake must not join or preempt the active provider turn'); + assert.equal(queue.findEntryWithMessageId('thread-1', wakeMessage.id)?.status, 'queued'); + assert.equal((await messageStore.getById(wakeMessage.id)).deliveryStatus, 'queued'); - // 3. Old invocation's async cleanup calls onInvocationComplete('canceled') - await processor.onInvocationComplete('thread-1', 'opus', 'canceled'); + trackerMock.clearActive('thread-1'); + await localProcessor.onInvocationComplete('thread-1', 'opus', 'succeeded'); + await settle(200); - // Queue SHOULD be paused (the old cleanup still fires) - // But the force-send's new invocation will start() and run independently. - // The key: clearPause was called BEFORE the stale pause, so the pause - // wins. But a subsequent succeeded completion will clear it. - // For now verify clearPause itself works: - processor.clearPause('thread-1'); - assert.ok(!processor.isPaused('thread-1'), 'clearPause should remove paused state'); + assert.equal(routeCalls.length, 1, 'the wake must start as the next Queue-owned invocation'); + assert.equal(queue.findEntryWithMessageId('thread-1', wakeMessage.id), undefined); + const settledWake = await messageStore.getById(wakeMessage.id); + assert.equal(settledWake.deliveryStatus, 'delivered'); + assert.equal(settledWake.lifecycle.dispatchRefs.length, 1); + const response = await messageStore.getById(responseMessageId); + assert.deepEqual( + resolveManagedCommandWakeEventCarrier(settledWake, response, false, { + threadId: 'thread-1', + userId: 'user-1', + catId: 'opus', + }), + { state: 'handled', invocationId: 'managed-wake-invocation' }, + 'the canonical response terminal must retire the hold without a manual disposition tool', + ); }); - it('bugfix: ConnectorInvokeTrigger abort mid-loop → should NOT ack or mark succeeded', async () => { - // Setup: no active invocation so trigger goes to direct execution - const controller = new AbortController(); - trackerMock.tracker.tryStartThread = () => { - trackerMock.activeThreads.add('thread-1'); - return controller; - }; - - // Router yields one msg, then aborts (simulating external cancel), then ends normally - const ackCalls = /** @type {any[]} */ ([]); - const customRouter = { - async *routeExecution(_userId, _message, _threadId, _userMessageId, _targetCats, _intent, options) { - const startedAt = Date.now(); - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ - type: 'invocation_created', - invocationId: 'child-abort-test', - parentInvocationId: options.parentInvocationId, - startedAt, - }), - timestamp: startedAt, - }; - yield { type: 'text', catId: 'opus', content: 'partial', timestamp: Date.now() }; - // External cancel while connector is streaming - controller.abort(); - // Generator ends normally (no throw) - }, - async ackCollectedCursors(userId, threadId) { - ackCalls.push({ userId, threadId }); - }, - }; - + it('E2E: connector admission honors an active cancel/reset suppression fence', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const messageStore = new MessageStore(); + const sourceMessage = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + from: { kind: 'external', connectorId: 'email' }, + content: 'Do not restart after reset', + mentions: ['opus'], + origin: 'connector', + timestamp: Date.now(), + threadId: 'thread-1', + deliveryStatus: 'queued', + }), + ); + const localProcessor = new QueueProcessor({ + queue, + invocationTracker: trackerMock.tracker, + invocationRecordStore: recordMock.store, + router: routerMock.router, + socketManager: socketMock.manager, + messageStore, + log: noopLog(), + }); + localProcessor.suppressAutoResume('thread-1', 'opus', ['cancelled-invocation']); const trigger = new ConnectorInvokeTrigger({ - router: /** @type {any} */ (customRouter), + router: routerMock.router, socketManager: socketMock.manager, invocationRecordStore: recordMock.store, invocationTracker: trackerMock.tracker, invocationQueue: queue, + queueProcessor: localProcessor, + messageStore, log: noopLog(), }); - trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review content', 'msg-conn-abort'); - await settle(300); - - // ackCollectedCursors should NOT be called - assert.strictEqual(ackCalls.length, 0, 'should NOT ack cursors for aborted connector invocation'); - - // invocationRecordStore should have 'canceled', NOT 'succeeded' - const succeededUpdate = recordMock.updates.find((u) => u.data.status === 'succeeded'); - assert.ok(!succeededUpdate, 'should NOT mark connector invocation as succeeded when aborted'); + await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', sourceMessage.content, sourceMessage.id); + await settle(); - const canceledUpdate = recordMock.updates.find((u) => u.data.status === 'canceled'); - assert.ok(canceledUpdate, 'should mark connector invocation as canceled when aborted'); + assert.equal(routerMock.calls.length, 0, 'a late connector wake must not bypass the reset owner'); + assert.equal(queue.findEntryWithMessageId('thread-1', sourceMessage.id)?.status, 'queued'); + assert.equal(localProcessor.isAutoResumeSuppressed('thread-1', 'opus'), true); }); - // ── F122B bugfix: autoExecute retry on completion ── + // ── RFC #1356: no-lost-wakeup drain on completion ── it('bugfix: autoExecute entry orphaned when target cat busy at enqueue → recovered on completion', async () => { // Scenario: gpt52 is executing via messages.ts path (tracked by invocationTracker). - // An autoExecute entry for gpt52 is enqueued. tryAutoExecute skips it because + // An entry for gpt52 is enqueued. requestDrain stops at it because // invocationTracker.has(thread, 'gpt52') = true. // Later, gpt52's messages.ts execution completes and calls onInvocationComplete. // The completion chain (tryExecuteNextAcrossUsers) should pick up the orphaned entry. @@ -515,20 +608,24 @@ describe('Queue Integration (E2E scenarios)', () => { trackerMock.setActive('thread-1'); // 2. autoExecute entry for gpt52 is enqueued - const enqResult = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'agent-user', - content: 'P1 修完,请 review', - source: 'agent', - targetCats: ['gpt52'], - intent: 'execute', - autoExecute: true, - }); + const enqResult = await queue.enqueueDurable( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'agent-user', + kind: 'private_input', + sourceId: 'integration-orphan-review', + content: 'P1 修完,请 review', + source: 'agent', + targetCats: ['gpt52'], + intent: 'execute', + autoExecute: true, + }), + ); assert.strictEqual(enqResult.outcome, 'enqueued'); - // 3. tryAutoExecute is called (as enqueueA2ATargets does after enqueue) - await processor.tryAutoExecute('thread-1'); + // 3. The enqueue signal requests the per-thread drain. + await processor.requestDrain('thread-1'); // 4. Entry should still be queued (not picked up because gpt52 slot busy) const entries = queue.list('thread-1', 'agent-user'); @@ -546,11 +643,10 @@ describe('Queue Integration (E2E scenarios)', () => { assert.deepStrictEqual(routerMock.calls[0].targetCats, ['gpt52']); }); - it('bugfix: multi-cat autoExecute — tryExecuteNextAcrossUsers only picks one, re-scan picks the rest', async () => { + it('one completion drain starts every consecutively unblocked strict head', async () => { // When gpt52 completes, tryExecuteNextAcrossUsers picks the oldest entry. // If that entry is for a DIFFERENT free cat (codex), it starts codex's entry. - // But a SECOND free cat's entry (opus) would only be started via tryAutoExecute re-scan, - // since tryExecuteNextAcrossUsers returns after starting one entry. + // The dirty-bit drain keeps owning the thread and starts the next strict head too. const activeSlots = new Set(); /** @type {any} */ @@ -584,7 +680,7 @@ describe('Queue Integration (E2E scenarios)', () => { invocationRecordStore: recordMock.store, router: routerMock.router, socketManager: socketMock.manager, - messageStore: /** @type {any} */ ({ getById: async () => null }), + messageStore: new MessageStore(), log: noopLog(), }); @@ -592,28 +688,36 @@ describe('Queue Integration (E2E scenarios)', () => { activeSlots.add('thread-1:codex'); activeSlots.add('thread-1:opus'); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'agent-user', - content: 'review request for codex', - source: 'agent', - targetCats: ['codex'], - intent: 'execute', - autoExecute: true, - }); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'agent-user', - content: 'review request for opus', - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - }); + await queue.enqueueDurable( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'agent-user', + kind: 'private_input', + sourceId: 'integration-codex-review', + content: 'review request for codex', + source: 'agent', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + }), + ); + await queue.enqueueDurable( + canonicalTestQueueInput({ + ownerAuthProvenance: 'unknown', + threadId: 'thread-1', + userId: 'agent-user', + kind: 'private_input', + sourceId: 'integration-opus-review', + content: 'review request for opus', + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + autoExecute: true, + }), + ); - await localProcessor.tryAutoExecute('thread-1'); + await localProcessor.requestDrain('thread-1'); assert.strictEqual(routerMock.calls.length, 0, 'All entries skipped — all cats busy'); // All three cats complete at once @@ -630,33 +734,4 @@ describe('Queue Integration (E2E scenarios)', () => { const startedFromCompletion = routerMock.calls.length - beforeCount; assert.strictEqual(startedFromCompletion, 2, 'Both entries should execute from the same onInvocationComplete call'); }); - - it('bugfix: clearPause + succeeded new invocation → auto-dequeue resumes', async () => { - // 1. Active invocation + queued message - trackerMock.setActive('thread-1'); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'Queued msg', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - - // 2. Force cancel - trackerMock.clearActive('thread-1'); - processor.clearPause('thread-1'); - - // 3. Old cleanup pauses (race) - await processor.onInvocationComplete('thread-1', 'opus', 'canceled'); - - // 4. New force-send invocation succeeds → should auto-dequeue - await processor.onInvocationComplete('thread-1', 'opus', 'succeeded'); - await settle(); - - // The queued message should have been auto-dequeued and executed - assert.strictEqual(routerMock.calls.length, 1, 'Should auto-dequeue after force-send succeeds'); - assert.strictEqual(routerMock.calls[0].message, 'Queued msg'); - }); }); diff --git a/packages/api/test/queue-ledger-redis.test.js b/packages/api/test/queue-ledger-redis.test.js new file mode 100644 index 0000000000..43dbd0142e --- /dev/null +++ b/packages/api/test/queue-ledger-redis.test.js @@ -0,0 +1,701 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import { + assertRedisIsolationOrThrow, + cleanupPrefixedRedisKeys, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; + +describe('RFC #1356 Redis Queue ledger', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { + let redis; + let store; + let messageStore; + let InvocationQueue; + let queueEntryId; + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'RFC #1356 Redis Queue ledger'); + const { createRedisClient } = await import('@cat-cafe/shared/utils'); + const module = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.js' + ); + ({ queueEntryId } = await import('../dist/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.js')); + ({ InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js')); + const { RedisMessageStore } = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); + redis = createRedisClient({ url: REDIS_URL }); + await redis.ping(); + store = new module.RedisQueueLedgerStore(redis); + messageStore = new RedisMessageStore(redis); + }); + + after(async () => { + if (!redis) return; + await cleanupPrefixedRedisKeys(redis, ['queue:*', 'msg:*']); + await redis.quit(); + }); + + beforeEach(async () => { + await cleanupPrefixedRedisKeys(redis, ['queue:*', 'msg:*']); + }); + + function row(sourceRecordId, targets, overrides = {}) { + return { + version: 2, + id: queueEntryId(sourceRecordId), + threadId: 'thread-redis', + owner: { kind: 'user', userId: 'owner-1' }, + kind: 'conversation_input', + from: { kind: 'user', userId: 'owner-1' }, + targets, + payload: { sourceRecordId: sourceRecordId, content: sourceRecordId, messageId: sourceRecordId }, + execution: { intent: 'execute', ownerAuthProvenance: 'strict', autoExecute: false }, + delivery: {}, + status: 'queued', + enqueuedAt: 100, + priority: 'normal', + ...overrides, + }; + } + + it('stores one source row with every pending target and rejects a conflicting replay', async () => { + const entry = row('m1', ['opus', 'codex']); + assert.equal((await store.enqueue([entry], 5)).outcome, 'enqueued'); + assert.equal((await store.enqueue([entry], 5)).outcome, 'replayed'); + assert.equal((await store.enqueue([{ ...entry, targets: ['opus'] }], 5)).outcome, 'conflict'); + assert.deepEqual(await store.list('thread-redis'), [entry]); + assert.deepEqual((await store.getByMessageIds('thread-redis', ['m1', 'missing'])).get('m1'), [entry]); + }); + + it('atomically migrates v1 scalar rows without resurrecting admitted targets', async () => { + const threadId = 'thread-legacy-v1'; + const rowsKey = `queue:{${threadId}}:entries`; + const orderKey = `queue:{${threadId}}:order`; + const messagesKey = `queue:{${threadId}}:messages`; + const sourceId = 'legacy-source'; + const legacy = (id, targetCatId, status, authorIntent) => ({ + version: 1, + id, + threadId, + owner: { kind: 'user', userId: 'owner-1' }, + kind: 'conversation_input', + from: { kind: 'user', userId: 'owner-1' }, + target: { kind: 'cat', catId: targetCatId }, + payload: { sourceId, content: 'legacy body', messageId: sourceId }, + execution: { intent: 'execute', ownerAuthProvenance: 'strict', autoExecute: false }, + delivery: authorIntent ? { authorIntent } : {}, + status, + enqueuedAt: 100, + priority: 'normal', + ...(status === 'claimed' ? { claimId: 'crashed-claim', claimedAt: 110 } : {}), + ...(status === 'processing' ? { processingStartedAt: 120 } : {}), + ...(status === 'terminal' ? { terminalAt: 130 } : {}), + }); + const oldRows = [ + legacy('legacy-opus', 'opus', 'queued', { requested: 'next_work', requestedAt: 1 }), + legacy('legacy-codex', 'codex', 'claimed', { requested: 'continue_current', requestedAt: 2 }), + legacy('legacy-sonnet', 'sonnet', 'processing'), + legacy('legacy-kimi', 'kimi', 'terminal'), + ]; + await redis.hset( + rowsKey, + Object.fromEntries(oldRows.map((candidate) => [candidate.id, JSON.stringify(candidate)])), + ); + await redis.rpush(orderKey, ...oldRows.slice(0, 3).map((candidate) => candidate.id)); + await redis.hset(messagesKey, sourceId, JSON.stringify(oldRows.map((candidate) => candidate.id))); + + const [migrated] = await store.list(threadId); + assert.equal(await redis.get(`queue:{${threadId}}:schema`), '2'); + assert.equal(migrated.version, 2); + assert.equal(migrated.id, queueEntryId(sourceId)); + assert.equal(migrated.payload.sourceRecordId, sourceId); + assert.equal(Object.hasOwn(migrated.payload, 'sourceId'), false); + assert.equal(migrated.status, 'queued', 'a crashed short claim is restored as pending'); + assert.deepEqual(migrated.targets, ['opus', 'codex']); + assert.deepEqual(migrated.delivery.authorIntentByTarget, { + opus: { requested: 'next_work', requestedAt: 1 }, + codex: { requested: 'continue_current', requestedAt: 2 }, + }); + assert.deepEqual(await redis.lrange(orderKey, 0, -1), [migrated.id]); + assert.deepEqual(JSON.parse(await redis.hget(messagesKey, sourceId)), [migrated.id]); + assert.equal(await redis.hlen(rowsKey), 1, 'processing and terminal v1 rows are not requeued'); + }); + + it('claims one exact target and restores the original row and order', async () => { + const first = row('m1', ['opus', 'codex']); + const second = row('m2', ['opus'], { enqueuedAt: 101 }); + await store.enqueue([first]); + await store.enqueue([second]); + const claimed = await store.claim('thread-redis', first.id, 'claim-1', 200, 'codex'); + assert.equal(claimed.outcome, 'claimed'); + assert.deepEqual(claimed.entries[0].claimedTargetIds, ['codex']); + assert.equal((await store.restore('thread-redis', first.id, 'stale')).outcome, 'state_changed'); + assert.equal((await store.restore('thread-redis', first.id, 'claim-1')).outcome, 'updated'); + assert.deepEqual( + (await store.list('thread-redis')).map((entry) => [entry.id, entry.targets]), + [ + [first.id, ['opus', 'codex']], + [second.id, ['opus']], + ], + ); + }); + + it('binds and restores a targetless row inside the same atomic claim', async () => { + const targetless = row('m1', []); + await store.enqueue([targetless]); + const claimed = await store.claim('thread-redis', targetless.id, 'claim-targetless', 200, 'codex', 199); + assert.equal(claimed.outcome, 'claimed'); + assert.deepEqual(claimed.entries[0].targets, ['codex']); + assert.deepEqual(claimed.entries[0].claimedTargetIds, ['codex']); + assert.equal(claimed.entries[0].delivery.steerRequestedAt, 199); + const restored = await store.restore('thread-redis', targetless.id, 'claim-targetless'); + assert.equal(restored.outcome, 'updated'); + assert.deepEqual(restored.entry.targets, []); + assert.equal(restored.entry.delivery.steerRequestedAt, undefined); + }); + + it('claims a prefix all-or-nothing without inventing terminal tombstones', async () => { + const first = row('m1', ['opus']); + const second = row('m2', ['opus'], { enqueuedAt: 101 }); + await store.enqueue([first]); + await store.enqueue([second]); + const claimed = await store.claimPrefix('thread-redis', [first.id, second.id], 'batch-1', 200, undefined, 199); + assert.equal(claimed.outcome, 'claimed'); + assert.ok(claimed.entries.every((entry) => entry.delivery.steerRequestedAt === 199)); + assert.equal((await store.commit('thread-redis', first.id, 'batch-1', 'processing', 201)).outcome, 'updated'); + assert.equal(await store.get('thread-redis', first.id), null); + assert.equal((await store.commit('thread-redis', first.id, '', 'withdrawn', 300)).outcome, 'not_found'); + assert.deepEqual( + (await store.listAll('thread-redis')).map((entry) => [entry.id, entry.status]), + [[second.id, 'claimed']], + ); + }); + + it('removes only the committed target and deletes the row after the final target', async () => { + const entry = row('m-partial', ['opus', 'codex'], { + delivery: { + authorIntentByTarget: { + opus: { requested: 'continue_current', requestedAt: 1 }, + codex: { requested: 'next_work', requestedAt: 1 }, + }, + }, + }); + await store.enqueue([entry]); + await store.claim('thread-redis', entry.id, 'claim-opus', 200, 'opus'); + const first = await store.commit('thread-redis', entry.id, 'claim-opus', 'processing', 201); + assert.equal(first.outcome, 'updated'); + assert.deepEqual(first.entry.targets, ['opus']); + assert.equal(first.entry.status, 'processing'); + const remaining = await store.get('thread-redis', entry.id); + assert.deepEqual(remaining.targets, ['codex']); + assert.deepEqual(Object.keys(remaining.delivery.authorIntentByTarget), ['codex']); + + await store.claim('thread-redis', entry.id, 'claim-codex', 202, 'codex'); + const second = await store.commit('thread-redis', entry.id, 'claim-codex', 'processing', 203); + assert.equal(second.outcome, 'updated'); + assert.deepEqual(second.entry.targets, ['codex']); + assert.equal(await store.get('thread-redis', entry.id), null); + assert.deepEqual(await store.listAll('thread-redis'), []); + assert.equal((await store.getByMessageIds('thread-redis', ['m-partial'])).has('m-partial'), false); + }); + + it('retires subsets from one shared multi-target claim', async () => { + const entry = row('m-shared-claim', ['opus', 'codex']); + await store.enqueue([entry]); + const claimed = await store.claimPrefix('thread-redis', [entry.id], 'claim-shared', 200); + assert.equal(claimed.outcome, 'claimed'); + assert.deepEqual(claimed.entries[0].claimedTargetIds, ['opus', 'codex']); + + const first = await store.commit('thread-redis', entry.id, 'claim-shared', 'processing', 201, undefined, ['opus']); + assert.equal(first.outcome, 'updated'); + assert.deepEqual(first.entry.targets, ['opus']); + const remaining = await store.get('thread-redis', entry.id); + assert.equal(remaining.status, 'claimed'); + assert.equal(remaining.claimId, 'claim-shared'); + assert.deepEqual(remaining.targets, ['codex']); + assert.deepEqual(remaining.claimedTargetIds, ['codex']); + + const second = await store.commit('thread-redis', entry.id, 'claim-shared', 'processing', 202, undefined, [ + 'codex', + ]); + assert.equal(second.outcome, 'updated'); + assert.deepEqual(second.entry.targets, ['codex']); + assert.equal(await store.get('thread-redis', entry.id), null); + }); + + it('atomically reconciles only the explicit Steer target delta', async () => { + const entry = row('m-reconcile', ['opus', 'codex', 'sonnet'], { + delivery: { + authorIntentByTarget: { + opus: { requested: 'next_work', requestedAt: 1 }, + codex: { requested: 'next_work', requestedAt: 1 }, + sonnet: { requested: 'next_work', requestedAt: 1 }, + }, + }, + }); + await store.enqueue([entry]); + + const result = await store.reconcileTargets('thread-redis', entry.id, ['kimi'], ['codex'], { + opus: { requested: 'continue_current', requestedAt: 2 }, + kimi: { requested: 'next_work', requestedAt: 2 }, + }); + + assert.equal(result.outcome, 'updated'); + assert.deepEqual(result.entry.targets, ['opus', 'sonnet', 'kimi']); + assert.deepEqual(Object.keys(result.entry.delivery.authorIntentByTarget).sort(), ['kimi', 'opus', 'sonnet']); + assert.equal(result.entry.delivery.authorIntentByTarget.opus.requested, 'continue_current'); + assert.equal(result.entry.delivery.authorIntentByTarget.sonnet.requested, 'next_work'); + assert.deepEqual( + (await store.list('thread-redis')).map((candidate) => candidate.id), + [entry.id], + ); + }); + + it('atomically deletes the source row and message index after removing its final target', async () => { + const entry = row('m-reconcile-empty', ['opus']); + await store.enqueue([entry]); + const result = await store.reconcileTargets('thread-redis', entry.id, [], ['opus'], {}); + assert.deepEqual(result, { outcome: 'updated', entry: null }); + assert.equal(await store.get('thread-redis', entry.id), null); + assert.equal((await store.getByMessageIds('thread-redis', ['m-reconcile-empty'])).size, 0); + }); + + it('keeps post-admission execution evidence process-local rather than in Queue storage', async () => { + const queue = new InvocationQueue(store); + const entry = row('m-processing-evidence', ['opus']); + await store.enqueue([entry]); + await queue.hydrateFromLedger(); + assert.ok( + await queue.markProcessingDurable('thread-redis', 'owner-1', { + entryId: entry.id, + targetCats: ['opus'], + }), + ); + assert.equal(await queue.commitClaimedProcessing('thread-redis', [entry.id], 200), true); + assert.equal(await store.get('thread-redis', entry.id), null); + + assert.equal( + await queue.markProcessingAwakened('thread-redis', 'owner-1', entry.id, 'opus', 'inv-processing-evidence', 210), + true, + ); + assert.deepEqual( + await queue.markProcessingSeen('thread-redis', 'owner-1', entry.id, 'opus', 'inv-processing-evidence', 220), + { changed: true, newlySeen: true }, + ); + const admitted = queue.findProcessingByCat('thread-redis', 'opus'); + assert.equal('awakenedInvocationId' in admitted.delivery, false); + assert.equal('seenInvocationId' in admitted.delivery, false); + assert.deepEqual(await store.listAll('thread-redis'), []); + }); + + it('retires one unread-adopted target while preserving the remaining target on the same row', async () => { + const queue = new InvocationQueue(store); + const admitted = await queue.appendAndEnqueueDurable( + messageStore, + { + from: { kind: 'user', userId: 'owner-1' }, + userId: 'owner-1', + content: 'shared input', + mentions: ['opus', 'codex'], + timestamp: 100, + threadId: 'thread-redis', + idempotencyKey: 'shared-input', + deliveryStatus: 'queued', + }, + { + from: { kind: 'user', userId: 'owner-1' }, + threadId: 'thread-redis', + userId: 'owner-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + idempotencyKey: 'shared-input', + content: 'shared input', + targetCats: ['opus', 'codex'], + intent: 'execute', + }, + ); + assert.equal(admitted.outcome, 'enqueued'); + assert.equal(admitted.entries.length, 1); + assert.deepEqual(admitted.entry.targets, ['opus', 'codex']); + + const claimed = await queue.claimExactExposureDurable( + 'thread-redis', + 'owner-1', + admitted.entry.id, + 'opus', + admitted.message.id, + ); + assert.equal(claimed?.id, admitted.entry.id); + const committed = await queue.commitClaimedAdoptionDurable( + 'thread-redis', + 'owner-1', + admitted.entry.id, + 'opus', + 'turn-read', + 200, + ); + assert.equal(committed?.newlySeen, true); + assert.deepEqual((await store.get('thread-redis', admitted.entry.id)).targets, ['codex']); + assert.deepEqual((await store.getByMessageIds('thread-redis', [admitted.message.id])).get(admitted.message.id), [ + await store.get('thread-redis', admitted.entry.id), + ]); + }); + + it('atomically stores hidden user source + one Queue row and rejects capacity overflow without a ghost', async () => { + const queue = new InvocationQueue(store); + const input = (idempotencyKey) => ({ + from: { kind: 'user', userId: 'owner-1' }, + threadId: 'thread-redis', + userId: 'owner-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + idempotencyKey, + content: idempotencyKey, + targetCats: ['opus', 'codex'], + intent: 'execute', + }); + const message = (idempotencyKey) => ({ + from: { kind: 'user', userId: 'owner-1' }, + userId: 'owner-1', + content: idempotencyKey, + mentions: ['opus', 'codex'], + timestamp: 100, + threadId: 'thread-redis', + idempotencyKey, + deliveryStatus: 'queued', + }); + + for (let index = 0; index < 5; index += 1) { + const id = `request-${index}`; + const admitted = await queue.appendAndEnqueueDurable(messageStore, message(id), input(id)); + assert.equal(admitted.outcome, 'enqueued'); + assert.equal(admitted.entries.length, 1); + assert.deepEqual(admitted.entry.targets, ['opus', 'codex']); + assert.equal(admitted.message.timelinePublishedAtAppend, undefined); + } + assert.equal((await store.list('thread-redis')).length, 5); + assert.deepEqual(await messageStore.getByThreadAfter('thread-redis', undefined, undefined, 'owner-1'), []); + + const replay = await queue.appendAndEnqueueDurable(messageStore, message('request-0'), input('request-0')); + assert.equal(replay.deduped, true); + assert.equal((await store.list('thread-redis')).length, 5); + + const rejected = await queue.appendAndEnqueueDurable( + messageStore, + message('over-capacity'), + input('over-capacity'), + ); + assert.deepEqual(rejected, { outcome: 'full' }); + assert.equal(await messageStore.getByIdempotencyKey('owner-1', 'thread-redis', 'over-capacity'), null); + + const hidden = await messageStore.getByIdempotencyKey('owner-1', 'thread-redis', 'request-0'); + const delivered = await messageStore.markDelivered(hidden.id, 500); + assert.equal(delivered.deliveryTransitioned, true); + assert.equal(delivered.timelineOrderAt, 500); + assert.equal(await redis.zscore('msg:thread:thread-redis', hidden.id), '500'); + }); + + it('adds Queue custody to published Agent speech without hiding the History source', async () => { + const queue = new InvocationQueue(store); + const source = await messageStore.append({ + from: { kind: 'agent', catId: 'opus' }, + userId: 'owner-1', + content: '@codex inspect this result', + mentions: ['codex'], + timestamp: 100, + threadId: 'thread-redis', + idempotencyKey: 'published-agent-source', + }); + + const admitted = await queue.enqueueExistingMessageDurable(messageStore, source.id, { + from: { kind: 'agent', catId: 'opus' }, + threadId: 'thread-redis', + userId: 'owner-1', + kind: 'message_wake', + ownerAuthProvenance: 'strict', + content: source.content, + messageId: source.id, + sourceId: source.id, + sourceCategory: 'a2a', + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + }); + + assert.equal(admitted.outcome, 'enqueued'); + assert.equal(admitted.message.deliveryStatus, undefined); + assert.equal(admitted.message.lifecycle.kind, 'input'); + assert.deepEqual(admitted.message.lifecycle.dispatchRefs, []); + assert.deepEqual(admitted.entry.targets, ['codex']); + const stored = await messageStore.getById(source.id); + assert.equal(stored.deliveryStatus, undefined); + assert.equal(stored.lifecycle.kind, 'input'); + assert.deepEqual( + (await messageStore.getByThreadAfter('thread-redis', undefined, undefined, 'owner-1')).map( + (message) => message.id, + ), + [source.id], + ); + }); + + it('admits one fresh external source once and rejects the same target after History records dispatch', async () => { + const queue = new InvocationQueue(store); + const source = await messageStore.append({ + from: { kind: 'external', connectorId: 'github' }, + userId: 'owner-1', + content: 'fresh connector event', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-redis', + idempotencyKey: 'fresh-external-source', + }); + const input = { + from: source.from, + threadId: source.threadId, + userId: source.userId, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + content: source.content, + messageId: source.id, + sourceId: source.id, + targetCats: ['opus'], + intent: 'execute', + autoExecute: true, + }; + + const admitted = await queue.enqueueExistingMessageDurable(messageStore, source.id, input); + assert.equal(admitted.outcome, 'enqueued'); + assert.equal(admitted.message.deliveryStatus, 'queued'); + assert.deepEqual(admitted.message.lifecycle.dispatchRefs, []); + + const claimed = await queue.markProcessingDurable(source.threadId, source.userId, { + entryId: admitted.entry.id, + targetCats: ['opus'], + }); + assert.equal(claimed.status, 'claimed'); + assert.equal(await queue.commitClaimedProcessing(source.threadId, [admitted.entry.id], 200), true); + const dispatched = await messageStore.advanceLifecycleInputDispatch(source.id, { + orderKey: admitted.message.lifecycle.orderKey, + ...(admitted.message.lifecycle.producerInvocationId + ? { producerInvocationId: admitted.message.lifecycle.producerInvocationId } + : {}), + targetId: 'opus', + phase: 'dispatched', + statusMessageId: 'external-response', + dispatchedAt: 200, + }); + assert.equal(dispatched.kind, 'applied'); + + await assert.rejects( + queue.enqueueExistingMessageDurable(messageStore, source.id, input), + /cannot replay an already dispatched source target/, + ); + assert.equal(await store.get(source.threadId, admitted.entry.id), null); + }); + + it('expands a targetless source on the same row and preserves FIFO metadata', async () => { + const queue = new InvocationQueue(store); + const admitted = await queue.appendAndEnqueueDurable( + messageStore, + { + from: { kind: 'user', userId: 'owner-1' }, + userId: 'owner-1', + content: 'targetless fan-out', + mentions: [], + timestamp: 100, + threadId: 'thread-redis', + idempotencyKey: 'targetless-fan-out', + deliveryStatus: 'queued', + }, + { + from: { kind: 'user', userId: 'owner-1' }, + threadId: 'thread-redis', + userId: 'owner-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + idempotencyKey: 'targetless-fan-out', + content: 'targetless fan-out', + targetCats: [], + intent: 'execute', + }, + ); + assert.deepEqual(admitted.entry.targets, []); + assert.equal(await queue.setPositionDurable('thread-redis', 'owner-1', admitted.entry.id, 2), true); + + const expansionInput = { + from: { kind: 'user', userId: 'owner-1' }, + threadId: 'thread-redis', + userId: 'owner-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + content: 'targetless fan-out', + messageId: admitted.message.id, + sourceId: admitted.message.id, + targetCats: ['codex'], + intent: 'execute', + }; + const expanded = await queue.mapQueuedMessageTargetsDurable( + messageStore, + admitted.message.id, + admitted.entry.id, + 'opus', + [], + expansionInput, + ); + assert.equal(expanded.outcome, 'expanded'); + assert.equal(expanded.entries.length, 1); + assert.deepEqual(expanded.entries[0].targets, ['opus', 'codex']); + assert.equal(expanded.entries[0].position, 2); + assert.equal(expanded.entries[0].enqueuedAt, admitted.entry.enqueuedAt); + + const replay = await queue.mapQueuedMessageTargetsDurable( + messageStore, + admitted.message.id, + admitted.entry.id, + 'opus', + [], + expansionInput, + ); + assert.equal(replay.outcome, 'replayed'); + assert.equal((await store.list('thread-redis')).length, 1); + }); + + it('terminalizes one response bubble while Queue stores one source row with pending targets and no assigned refs', async () => { + const queue = new InvocationQueue(store); + const response = ( + await messageStore.appendAndObservePriorFrontier({ + from: { kind: 'agent', catId: 'opus' }, + userId: 'owner-1', + content: '', + mentions: [], + timestamp: 100, + threadId: 'thread-redis', + lifecycle: { + kind: 'response', + orderKey: '0000000000100:response-atomic', + invocationId: 'invocation-atomic', + targetId: 'opus', + inputEntryIds: ['entry-input'], + inputMessageIds: ['message-input'], + status: 'processing', + startedAt: 100, + }, + }) + ).message; + const input = { + from: { kind: 'agent', catId: 'opus' }, + threadId: 'thread-redis', + userId: 'owner-1', + kind: 'message_wake', + ownerAuthProvenance: 'strict', + content: '@codex @sonnet review', + messageId: response.id, + sourceId: response.id, + sourceCategory: 'a2a', + targetCats: ['codex', 'sonnet'], + intent: 'execute', + autoExecute: true, + }; + const patch = { + invocationId: 'invocation-atomic', + status: 'completed', + completedAt: 200, + content: input.content, + mentions: ['codex', 'sonnet'], + origin: 'stream', + }; + + const applied = await queue.terminalizeResponseAndEnqueueDurable(messageStore, response.id, patch, input); + assert.equal(applied.outcome, 'enqueued'); + assert.equal(applied.message.lifecycle.status, 'completed'); + assert.deepEqual(applied.message.lifecycle.dispatchRefs ?? [], []); + assert.equal(applied.entries.length, 1); + assert.deepEqual(applied.entry.targets, ['codex', 'sonnet']); + + const replay = await queue.terminalizeResponseAndEnqueueDurable(messageStore, response.id, patch, input); + assert.equal(replay.deduped, true); + assert.equal((await store.list('thread-redis')).length, 1); + + assert.ok(await queue.terminalizeEntryDurable('thread-redis', 'owner-1', applied.entry.id)); + assert.equal( + ( + await messageStore.advanceLifecycleInputDispatch(response.id, { + orderKey: applied.message.lifecycle.orderKey, + producerInvocationId: applied.message.lifecycle.producerInvocationId, + targetId: 'codex', + phase: 'dispatched', + statusMessageId: 'response-codex', + dispatchedAt: 210, + }) + ).kind, + 'applied', + ); + const afterDispatchReplay = await queue.terminalizeResponseAndEnqueueDurable( + messageStore, + response.id, + patch, + input, + ); + assert.equal(afterDispatchReplay.deduped, false); + assert.deepEqual(afterDispatchReplay.entry.targets, ['sonnet']); + assert.deepEqual( + (await store.list('thread-redis')).map((entry) => entry.targets), + [['sonnet']], + ); + }); + + it('leaves a processing response unchanged when its one outbound Queue identity conflicts', async () => { + const queue = new InvocationQueue(store); + const response = await messageStore.append({ + from: { kind: 'agent', catId: 'opus' }, + userId: 'owner-1', + content: '', + mentions: [], + timestamp: 100, + threadId: 'thread-redis', + lifecycle: { + kind: 'response', + orderKey: '0000000000100:response-conflict', + invocationId: 'invocation-conflict', + targetId: 'opus', + inputEntryIds: ['entry-input'], + inputMessageIds: ['message-input'], + status: 'processing', + startedAt: 100, + }, + }); + const conflicting = row(response.id, ['codex'], { priority: 'urgent' }); + await store.enqueue([conflicting]); + + await assert.rejects( + queue.terminalizeResponseAndEnqueueDurable( + messageStore, + response.id, + { + invocationId: 'invocation-conflict', + status: 'completed', + completedAt: 200, + content: '@codex review', + mentions: ['codex'], + }, + { + from: { kind: 'agent', catId: 'opus' }, + threadId: 'thread-redis', + userId: 'owner-1', + kind: 'message_wake', + ownerAuthProvenance: 'strict', + content: '@codex review', + messageId: response.id, + targetCats: ['codex'], + intent: 'execute', + autoExecute: true, + sourceCategory: 'a2a', + }, + ), + /Queue admission conflict/, + ); + assert.equal((await messageStore.getById(response.id)).lifecycle.status, 'processing'); + assert.deepEqual(await store.get('thread-redis', conflicting.id), conflicting); + }); +}); diff --git a/packages/api/test/queue-ledger.test.js b/packages/api/test/queue-ledger.test.js new file mode 100644 index 0000000000..e61583ea22 --- /dev/null +++ b/packages/api/test/queue-ledger.test.js @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { InMemoryQueueLedgerStore } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js' +); +const { assertQueueLedgerEntry, queueEntryId } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.js' +); +const { createQueueLedgerAdmission } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.js' +); + +function row(sourceId, targetCatId, overrides = {}) { + const targets = Array.isArray(targetCatId) ? targetCatId : targetCatId ? [targetCatId] : []; + return { + version: 2, + id: queueEntryId(sourceId), + threadId: 'thread-1', + owner: { kind: 'user', userId: 'owner-1' }, + kind: 'conversation_input', + from: { kind: 'user', userId: 'owner-1' }, + targets, + payload: { sourceRecordId: sourceId, content: `body:${sourceId}`, messageId: sourceId }, + execution: { intent: 'execute', ownerAuthProvenance: 'strict', autoExecute: false }, + delivery: {}, + status: 'queued', + enqueuedAt: 100, + priority: 'normal', + ...overrides, + }; +} + +describe('ADR-043 queue ledger', () => { + it('stores one durable Queue Entry for one source and keeps pending targets on that entry', () => { + const entries = createQueueLedgerAdmission({ + sourceId: 'source-multi-target', + threadId: 'thread-1', + owner: { kind: 'user', userId: 'owner-1' }, + kind: 'conversation_input', + from: { kind: 'user', userId: 'owner-1' }, + targetCatIds: ['opus', 'codex'], + content: 'hello', + intent: 'execute', + ownerAuthProvenance: 'strict', + enqueuedAt: 100, + }); + + assert.equal(entries.length, 1); + assert.equal(entries[0].id, queueEntryId('source-multi-target')); + assert.deepEqual(entries[0].targets, ['opus', 'codex']); + assert.equal('target' in entries[0], false, 'the durable row must not mirror a scalar target'); + }); + + it('derives one deterministic primary key from source identity', () => { + assert.equal(queueEntryId('message-1'), queueEntryId('message-1')); + assert.notEqual(queueEntryId('message-1'), queueEntryId('message-2')); + }); + + it('rejects malformed or obsolete persisted rows before they enter scheduling', () => { + assert.doesNotThrow(() => assertQueueLedgerEntry(row('message-valid', 'opus'))); + assert.throws( + () => assertQueueLedgerEntry({ ...row('message-target', 'opus'), targets: ['opus', 'opus'] }), + /targets are invalid/, + ); + assert.throws( + () => assertQueueLedgerEntry({ ...row('message-status', 'opus'), status: 'processing-ish' }), + /status is invalid/, + ); + assert.throws( + () => assertQueueLedgerEntry({ ...row('message-owner', 'opus'), owner: { kind: 'connector', id: 'github' } }), + /owner is incomplete/, + ); + assert.throws( + () => assertQueueLedgerEntry({ ...row('message-payload', 'opus'), payload: null }), + /payload identity is incomplete/, + ); + }); + + it('keeps every resolved target on one row and keeps targetless user work assignable', () => { + const base = { + sourceId: 'source-1', + threadId: 'thread-1', + owner: { kind: 'user', userId: 'owner-1' }, + kind: 'conversation_input', + from: { kind: 'user', userId: 'owner-1' }, + content: 'hello', + intent: 'execute', + ownerAuthProvenance: 'strict', + enqueuedAt: 100, + }; + const fanout = createQueueLedgerAdmission({ ...base, targetCatIds: ['opus', 'codex'] }); + assert.equal(fanout.length, 1); + assert.deepEqual(fanout[0].targets, ['opus', 'codex']); + assert.deepEqual(createQueueLedgerAdmission({ ...base, targetCatIds: [] })[0].targets, []); + }); + + it('atomically stores a source and all pending targets as one entry', async () => { + const store = new InMemoryQueueLedgerStore(); + const result = await store.enqueue([row('message-1', ['opus', 'codex'])], 5); + assert.equal(result.outcome, 'enqueued'); + assert.equal((await store.list('thread-1')).length, 1); + assert.deepEqual((await store.list('thread-1'))[0].targets, ['opus', 'codex']); + assert.deepEqual( + (await store.getByMessageIds('thread-1', ['message-1', 'missing'])).get('message-1'), + result.entries, + ); + }); + + it('rejects a replay whose pending target set does not match', async () => { + const store = new InMemoryQueueLedgerStore(); + await store.enqueue([row('message-1', 'opus')]); + const result = await store.enqueue([row('message-1', ['opus', 'codex'])]); + assert.equal(result.outcome, 'conflict'); + assert.deepEqual((await store.list('thread-1'))[0].targets, ['opus']); + }); + + it('claims without removing and restores the exact original queue position', async () => { + const store = new InMemoryQueueLedgerStore(); + const first = row('message-1', 'opus'); + const second = row('message-2', 'opus', { enqueuedAt: 101 }); + await store.enqueue([first]); + await store.enqueue([second]); + + const claimed = await store.claim('thread-1', first.id, 'claim-1', 200); + assert.equal(claimed.outcome, 'claimed'); + assert.deepEqual( + (await store.list('thread-1')).map((entry) => [entry.id, entry.status]), + [ + [first.id, 'claimed'], + [second.id, 'queued'], + ], + ); + + assert.equal((await store.restore('thread-1', first.id, 'stale-claim')).outcome, 'state_changed'); + assert.equal((await store.restore('thread-1', first.id, 'claim-1')).outcome, 'updated'); + assert.deepEqual( + (await store.list('thread-1')).map((entry) => entry.id), + [first.id, second.id], + ); + }); + + it('atomically binds one targetless row while claiming it for Steer', async () => { + const store = new InMemoryQueueLedgerStore(); + const targetless = row('message-1', []); + await store.enqueue([targetless]); + const claimed = await store.claim('thread-1', targetless.id, 'claim-targetless', 200, 'codex', 199); + assert.equal(claimed.outcome, 'claimed'); + assert.deepEqual(claimed.entries[0].targets, ['codex']); + assert.equal(claimed.entries[0].delivery.steerRequestedAt, 199); + const restored = await store.restore('thread-1', targetless.id, 'claim-targetless', true); + assert.equal(restored.outcome, 'updated'); + assert.equal(restored.entry.delivery.steerRequestedAt, undefined); + assert.deepEqual(restored.entry.targets, []); + }); + + it('expands pending targets on the same durable row', async () => { + const store = new InMemoryQueueLedgerStore(); + const targetless = row('message-1', []); + const sibling = row('message-1', 'codex'); + await store.enqueue([targetless]); + assert.equal((await store.expandTargets('thread-1', targetless.id, 'opus', [], [sibling])).outcome, 'expanded'); + const [expanded] = await store.list('thread-1'); + assert.equal((await store.list('thread-1')).length, 1); + assert.deepEqual(expanded.targets, ['opus', 'codex']); + }); + + it('reconciles an explicit Steer delta without deleting concurrent targets', async () => { + const store = new InMemoryQueueLedgerStore(); + const entry = row('message-reconcile', ['opus', 'codex', 'sonnet'], { + delivery: { + authorIntentByTarget: { + opus: { requested: 'next_work', requestedAt: 1 }, + codex: { requested: 'next_work', requestedAt: 1 }, + sonnet: { requested: 'next_work', requestedAt: 1 }, + }, + }, + }); + await store.enqueue([entry]); + + const result = await store.reconcileTargets('thread-1', entry.id, ['kimi'], ['codex'], { + opus: { requested: 'continue_current', requestedAt: 2 }, + kimi: { requested: 'next_work', requestedAt: 2 }, + }); + + assert.equal(result.outcome, 'updated'); + assert.deepEqual(result.entry.targets, ['opus', 'sonnet', 'kimi']); + assert.deepEqual(Object.keys(result.entry.delivery.authorIntentByTarget), ['opus', 'sonnet', 'kimi']); + assert.equal(result.entry.delivery.authorIntentByTarget.opus.requested, 'continue_current'); + assert.equal(result.entry.delivery.authorIntentByTarget.sonnet.requested, 'next_work'); + }); + + it('deletes the pending Queue row when Steer removes its final target', async () => { + const store = new InMemoryQueueLedgerStore(); + const entry = row('message-reconcile-empty', ['opus']); + await store.enqueue([entry]); + const result = await store.reconcileTargets('thread-1', entry.id, [], ['opus'], {}); + assert.deepEqual(result, { outcome: 'updated', entry: null }); + assert.equal(await store.get('thread-1', entry.id), null); + assert.equal((await store.getByMessageIds('thread-1', ['message-reconcile-empty'])).size, 0); + }); + + it('counts a fan-out group as one user queue message', async () => { + const store = new InMemoryQueueLedgerStore(); + assert.equal((await store.enqueue([row('message-1', ['opus', 'codex'])], 1)).outcome, 'enqueued'); + assert.equal((await store.enqueue([row('message-2', 'opus')], 1)).outcome, 'full'); + }); + + it('commits one claimed target without moving the Queue Entry into processing', async () => { + const store = new InMemoryQueueLedgerStore(); + const entry = row('message-1', ['opus', 'codex']); + await store.enqueue([entry]); + await store.claim('thread-1', entry.id, 'claim-1', 200, 'opus'); + assert.equal((await store.commit('thread-1', entry.id, 'wrong', 'processing', 201)).outcome, 'state_changed'); + const processing = await store.commit('thread-1', entry.id, 'claim-1', 'processing', 201); + assert.equal(processing.outcome, 'updated'); + assert.equal(processing.entry.status, 'processing'); + assert.deepEqual(processing.entry.targets, ['opus']); + assert.deepEqual((await store.get('thread-1', entry.id)).targets, ['codex']); + assert.equal((await store.get('thread-1', entry.id)).status, 'queued'); + assert.deepEqual(await store.listAll('thread-1'), await store.list('thread-1')); + }); + + it('deletes the Queue Entry when its final target is committed and retains no terminal tombstone', async () => { + const store = new InMemoryQueueLedgerStore(); + const entry = row('message-terminal', 'opus'); + await store.enqueue([entry]); + await store.claim('thread-1', entry.id, 'claim-terminal', 200, 'opus'); + const processing = await store.commit('thread-1', entry.id, 'claim-terminal', 'processing', 201); + assert.equal(processing.outcome, 'updated'); + assert.deepEqual(await store.list('thread-1'), []); + assert.deepEqual(await store.listAll('thread-1'), []); + assert.equal(await store.get('thread-1', entry.id), null); + assert.equal((await store.getByMessageIds('thread-1', ['message-terminal'])).has('message-terminal'), false); + }); + + it('claims a prefix all-or-nothing', async () => { + const store = new InMemoryQueueLedgerStore(); + const first = row('message-1', 'opus'); + const second = row('message-2', 'opus', { enqueuedAt: 101 }); + await store.enqueue([first]); + await store.enqueue([second]); + await store.claim('thread-1', second.id, 'other', 150); + + const result = await store.claimPrefix('thread-1', [first.id, second.id], 'batch', 200, undefined, 199); + assert.equal(result.outcome, 'state_changed'); + assert.equal((await store.get('thread-1', first.id)).status, 'queued'); + }); +}); diff --git a/packages/api/test/queue-liveness-diagnostics.test.js b/packages/api/test/queue-liveness-diagnostics.test.js index 870bbe1de1..34ed226741 100644 --- a/packages/api/test/queue-liveness-diagnostics.test.js +++ b/packages/api/test/queue-liveness-diagnostics.test.js @@ -5,8 +5,9 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -const { CONTINUATION_DIAGNOSTIC_MESSAGE, classifyContinuationOutcome, describeContinuationOutcome, mayCommitPause } = - await import('../dist/domains/cats/services/agents/invocation/queue-liveness-diagnostics.js'); +const { CONTINUATION_DIAGNOSTIC_MESSAGE, classifyContinuationOutcome, describeContinuationOutcome } = await import( + '../dist/domains/cats/services/agents/invocation/queue-liveness-diagnostics.js' +); const base = { threadId: 't1', catId: 'opus', deferredForBusySlot: 0, hasDispatchableQueued: true }; @@ -49,10 +50,4 @@ describe('queue liveness diagnostics', () => { assert.equal(classifyContinuationOutcome(0), 'no_dispatchable_candidate'); assert.equal(classifyContinuationOutcome(3), 'all_candidate_slots_busy'); }); - - it('refuses to commit a pause once a replacement owner has taken the slot', () => { - assert.equal(mayCommitPause({ supersededByReplacement: true, stillHasDispatchableQueued: true }), false); - assert.equal(mayCommitPause({ supersededByReplacement: false, stillHasDispatchableQueued: false }), false); - assert.equal(mayCommitPause({ supersededByReplacement: false, stillHasDispatchableQueued: true }), true); - }); }); diff --git a/packages/api/test/queue-liveness-incidents.test.js b/packages/api/test/queue-liveness-incidents.test.js deleted file mode 100644 index decb6c74a1..0000000000 --- a/packages/api/test/queue-liveness-incidents.test.js +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Queue liveness incident regressions: a queue that holds entries must never - * look idle, and a continuation that starts nothing must say why. - * - * These drive the real QueueProcessor branches rather than the formatter, so a - * mis-wired label or a dropped entryId fails here. - */ -import assert from 'node:assert/strict'; -import { describe, it, mock } from 'node:test'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - -function stubDeps() { - return { - queue: new InvocationQueue(), - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ outcome: 'created', invocationId: 'inv-stub' })), - update: mock.fn(async () => {}), - }, - messageStore: { getById: mock.fn(() => null), append: mock.fn(() => ({ id: 'm' })) }, - socketManager: { emitToUser: mock.fn(), broadcastToRoom: mock.fn(), broadcastAgentMessage: mock.fn() }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - invokeSingleCat: mock.fn(async () => {}), - }; -} - -function enqueueUserEntry(deps, content = 'queued user work') { - const enqueued = deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - content, - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); - assert.ok(enqueued.entry); - return enqueued.entry; -} - -function continuationDiagnostic(deps) { - return deps.log.info.mock.calls.find((call) => - String(call.arguments[1] ?? '').includes('continuation started nothing'), - ); -} - -describe('user-cancel requeue stays visible', () => { - /** - * A user cancel puts the primary entry back in Queue and then returns - * without restarting it — deliberately, so an interrupt is not silently - * undone. But it also skipped the pause bookkeeping the failure path does, - * so the slot reported idle while the entry sat there. Queue looked empty, - * the message never moved, and the only recovery was a manual steer. - */ - it('marks the slot paused instead of returning silently', async () => { - const deps = stubDeps(); - const processor = new QueueProcessor(deps); - deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - content: 'work the user interrupted', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); - - await processor.onInvocationComplete('t1', 'opus', 'canceled_by_user', 'inv-canceled', ['opus'], true); - - assert.equal( - processor.isPaused('t1', 'opus'), - true, - 'a requeued entry the user interrupted must be visibly parked, not silently idle', - ); - assert.equal(deps.queue.list('t1', 'u1').length, 1, 'the entry itself is still there'); - }); - - it('explains a stalled continuation even once the queued user message is stale', async () => { - // `hasQueuedForThread` reports false for non-agent entries past the stale - // threshold — exactly the "sitting for minutes" case this log exists for. - // Kept alongside the outcome assertions, not instead of them. - const deps = stubDeps(); - const processor = new QueueProcessor(deps); - const enqueued = deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - content: 'user message that has been waiting a long time', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); - assert.ok(enqueued.entry); - deps.queue.list('t1', 'u1')[0].createdAt = Date.now() - 600_000; - assert.equal(deps.queue.hasQueuedForThread('t1'), false, 'precondition: the old gate has already gone quiet'); - assert.equal(deps.queue.hasDispatchableQueuedForThread('t1'), true); - deps.invocationTracker.has = mock.fn(() => true); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-done', ['opus']); - - const diagnostic = deps.log.info.mock.calls.find((call) => - String(call.arguments[1] ?? '').includes('continuation started nothing'), - ); - assert.ok(diagnostic, 'a stalled queue must still say why nothing started'); - assert.equal(diagnostic.arguments[0].outcome, 'all_candidate_slots_busy'); - }); - - it('names the exact reason a continuation started nothing', async () => { - const deps = stubDeps(); - const processor = new QueueProcessor(deps); - deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - content: 'queued behind a busy target', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); - deps.invocationTracker.has = mock.fn(() => true); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-done', ['opus']); - - const diagnostic = deps.log.info.mock.calls.find((call) => - String(call.arguments[1] ?? '').includes('continuation started nothing'), - ); - assert.ok(diagnostic, 'a stalled continuation must be explained'); - // The label must be readable, not merely present: three outcomes collapsed - // into one value would still satisfy a message-only assertion. - assert.equal(diagnostic.arguments[0].outcome, 'all_candidate_slots_busy'); - assert.ok(diagnostic.arguments[0].deferredForBusySlot >= 1); - }); - - it('stays silent when the thread has nothing queued', async () => { - const deps = stubDeps(); - const processor = new QueueProcessor(deps); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-done', ['opus']); - - assert.equal( - deps.log.info.mock.calls.some((call) => String(call.arguments[1] ?? '').includes('continuation started nothing')), - false, - 'an empty queue needs no excuse', - ); - }); - - it('does not auto-restart the entry the user just interrupted', async () => { - const deps = stubDeps(); - const processor = new QueueProcessor(deps); - deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - content: 'work the user interrupted', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - priority: 'normal', - }); - - await processor.onInvocationComplete('t1', 'opus', 'canceled_by_user', 'inv-canceled', ['opus'], true); - - const entry = deps.queue.list('t1', 'u1')[0]; - assert.equal(entry.status, 'queued', 'an interrupt must not be quietly undone by a restart'); - }); - - it('names no_dispatchable_candidate when the scan finds nothing for this cat', async () => { - // The thread still holds dispatchable work, but none of it targets this cat, - // so the scan legitimately starts nothing and must say which of the three - // reasons applied. - const deps = stubDeps(); - const processor = new QueueProcessor(deps); - enqueueUserEntry(deps, 'work for a different cat').targetCats = ['codex']; - deps.queue.list('t1', 'u1')[0].targetCats = ['codex']; - - const result = await processor.tryExecuteNextAcrossUsers('t1', 'opus', { onlyTargetCat: true }); - - assert.equal(result.started, false); - const diagnostic = continuationDiagnostic(deps); - assert.ok(diagnostic, 'a scan that starts nothing while work is queued must explain itself'); - assert.equal(diagnostic.arguments[0].outcome, 'no_dispatchable_candidate'); - assert.equal(diagnostic.arguments[0].deferredForBusySlot, 0); - assert.equal(diagnostic.arguments[0].entryId, undefined); - }); - - it('names start_rejected with the exact entry that would not start', async () => { - const deps = stubDeps(); - deps.queueCustodyCoordinator = { - persistEntry: mock.fn(async () => { - throw new Error('reservation persistence rejected'); - }), - }; - const processor = new QueueProcessor(deps); - const entry = enqueueUserEntry(deps, 'work whose reservation cannot persist'); - - const result = await processor.tryExecuteNextAcrossUsers('t1', 'opus', { onlyTargetCat: true }); - - assert.equal(result.started, false); - assert.equal(deps.queue.list('t1', 'u1')[0].status, 'queued', 'a rejected start must roll the entry back'); - const diagnostic = continuationDiagnostic(deps); - assert.ok(diagnostic, 'a rejected start must be explained'); - assert.equal(diagnostic.arguments[0].outcome, 'start_rejected'); - assert.equal(diagnostic.arguments[0].entryId, entry.id, 'the exact entry must be named'); - }); -}); diff --git a/packages/api/test/queue-message-admission.test.js b/packages/api/test/queue-message-admission.test.js new file mode 100644 index 0000000000..e7405adadf --- /dev/null +++ b/packages/api/test/queue-message-admission.test.js @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); +const { InMemoryQueueLedgerStore } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js' +); +const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + +function message(idempotencyKey, content = idempotencyKey) { + return { + from: { kind: 'user', userId: 'owner-1' }, + userId: 'owner-1', + content, + mentions: ['opus', 'codex'], + timestamp: 100, + threadId: 'thread-admission', + idempotencyKey, + deliveryStatus: 'queued', + }; +} + +function queueInput(idempotencyKey, content = idempotencyKey) { + return { + from: { kind: 'user', userId: 'owner-1' }, + threadId: 'thread-admission', + userId: 'owner-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + idempotencyKey, + content, + targetCats: ['opus', 'codex'], + intent: 'execute', + }; +} + +describe('ADR-043 atomic memory message + Queue admission', () => { + it('binds the single source row to the exact stored message and replays without duplication', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const queue = new InvocationQueue(ledger); + const messages = new MessageStore(); + + const first = await queue.appendAndEnqueueDurable(messages, message('request-1'), queueInput('request-1')); + assert.equal(first.outcome, 'enqueued'); + assert.equal(first.deduped, false); + assert.equal(first.entries.length, 1); + assert.ok(first.entries.every((entry) => entry.payload.messageId === first.message.id)); + assert.deepEqual( + (await ledger.list('thread-admission')).map((entry) => [entry.payload.sourceRecordId, entry.payload.messageId]), + [[first.message.id, first.message.id]], + ); + + const replay = await queue.appendAndEnqueueDurable(messages, message('request-1'), queueInput('request-1')); + assert.equal(replay.outcome, 'enqueued'); + assert.equal(replay.deduped, true); + assert.equal(replay.message.id, first.message.id); + assert.equal((await ledger.list('thread-admission')).length, 1); + }); + + it('rejects capacity before persisting either the message or any fan-out row', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const queue = new InvocationQueue(ledger); + const messages = new MessageStore(); + + for (let index = 0; index < 5; index += 1) { + const id = `fill-${index}`; + assert.equal((await queue.appendAndEnqueueDurable(messages, message(id), queueInput(id))).outcome, 'enqueued'); + } + const rejected = await queue.appendAndEnqueueDurable( + messages, + message('over-capacity'), + queueInput('over-capacity'), + ); + assert.deepEqual(rejected, { outcome: 'full' }); + assert.equal(messages.getByIdempotencyKey('owner-1', 'thread-admission', 'over-capacity'), null); + assert.equal((await ledger.list('thread-admission')).length, 5); + }); +}); diff --git a/packages/api/test/queue-processor-delivered-at.test.js b/packages/api/test/queue-processor-delivered-at.test.js deleted file mode 100644 index cae8f0ce53..0000000000 --- a/packages/api/test/queue-processor-delivered-at.test.js +++ /dev/null @@ -1,240 +0,0 @@ -/** - * F098 Phase D: QueueProcessor calls markDelivered on dequeued messages - * - * When a queue entry starts executing, all associated message IDs - * (primary + merged) should get deliveredAt = now. - */ - -import assert from 'node:assert/strict'; -import { beforeEach, describe, it, mock } from 'node:test'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - -function stubDeps(overrides = {}) { - return { - queue: new InvocationQueue(), - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'created', - invocationId: 'inv-stub', - })), - update: mock.fn(async () => {}), - }, - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - messageStore: { - append: mock.fn(async () => ({ id: 'msg-stub' })), - getById: mock.fn(async () => null), - markDelivered: mock.fn(async () => ({ - id: 'msg-1', - threadId: 't1', - content: 'delivered', - catId: null, - timestamp: Date.now(), - mentions: [], - userId: 'u1', - deliveryStatus: 'delivered', - deliveredAt: Date.now(), - deliveryTransitioned: true, - })), - }, - log: { - info: mock.fn(), - warn: mock.fn(), - error: mock.fn(), - }, - ...overrides, - }; -} - -describe('QueueProcessor deliveredAt backfill', () => { - let deps; - let processor; - - beforeEach(() => { - deps = stubDeps(); - processor = new QueueProcessor(deps); - }); - - it('calls markDelivered for primary messageId when executing queue entry', async () => { - // Enqueue + backfill messageId - const result = deps.queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'hello', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - deps.queue.backfillMessageId('t1', 'u1', result.entry.id, 'msg-1'); - - // Trigger execution - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - - // Wait for async fire-and-forget - await new Promise((r) => setTimeout(r, 100)); - - // markDelivered should have been called with the primary messageId - const calls = deps.messageStore.markDelivered.mock.calls; - assert.ok(calls.length >= 1, `markDelivered should be called at least once, got ${calls.length}`); - - const msgIds = calls.map((c) => c.arguments[0]); - assert.ok(msgIds.includes('msg-1'), `should mark msg-1 as delivered, got: ${msgIds}`); - }); - - it('calls markDelivered for entry messageId (F175: merge removed)', async () => { - const result = deps.queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'first', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - deps.queue.backfillMessageId('t1', 'u1', result.entry.id, 'msg-1'); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 100)); - - const calls = deps.messageStore.markDelivered.mock.calls; - const msgIds = calls.map((c) => c.arguments[0]); - - assert.ok(msgIds.includes('msg-1'), 'should mark msg-1 as delivered'); - }); - - it('passes a reasonable timestamp (close to now)', async () => { - const result = deps.queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'hello', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - deps.queue.backfillMessageId('t1', 'u1', result.entry.id, 'msg-1'); - - const before = Date.now(); - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 100)); - const after = Date.now(); - - const calls = deps.messageStore.markDelivered.mock.calls; - assert.ok(calls.length >= 1); - - const deliveredAt = calls[0].arguments[1]; - assert.ok(deliveredAt >= before, `deliveredAt (${deliveredAt}) should be >= before (${before})`); - assert.ok(deliveredAt <= after, `deliveredAt (${deliveredAt}) should be <= after (${after})`); - }); - - it('does not emit messages_delivered for IDs where markDelivered fails (cloud P2)', async () => { - // markDelivered throws for all calls (simulates Redis down) - deps.messageStore.markDelivered = mock.fn(async () => { - throw new Error('redis down'); - }); - - const result = deps.queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'hello', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - deps.queue.backfillMessageId('t1', 'u1', result.entry.id, 'msg-1'); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 100)); - - // messages_delivered should NOT have been emitted (no IDs persisted) - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const deliveredEvents = emitCalls.filter((c) => c.arguments[1] === 'messages_delivered'); - assert.equal( - deliveredEvents.length, - 0, - `should not emit messages_delivered when persistence fails, got ${deliveredEvents.length}`, - ); - }); - - it('does not emit messages_delivered when markDelivered returns null (message not found, cloud P2-R2)', async () => { - // markDelivered returns null (message expired/deleted/not found) - deps.messageStore.markDelivered = mock.fn(async () => null); - - const result = deps.queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'hello', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - deps.queue.backfillMessageId('t1', 'u1', result.entry.id, 'msg-missing'); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 100)); - - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const deliveredEvents = emitCalls.filter((c) => c.arguments[1] === 'messages_delivered'); - assert.equal( - deliveredEvents.length, - 0, - `should not emit messages_delivered for null results, got ${deliveredEvents.length}`, - ); - }); - - it('does not emit messages_delivered when markDelivered returns a non-transitioned visible message (cloud P2-R5)', async () => { - deps.messageStore.markDelivered = mock.fn(async (id) => ({ - id, - threadId: 't1', - content: 'already visible trigger', - catId: 'codex', - timestamp: Date.now(), - mentions: ['opus'], - userId: 'u1', - deliveryTransitioned: false, - })); - - const result = deps.queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: 'already visible trigger', - source: 'connector', - targetCats: ['opus'], - intent: 'execute', - }); - deps.queue.backfillMessageId('t1', 'u1', result.entry.id, 'msg-visible'); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 100)); - - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const deliveredEvents = emitCalls.filter((c) => c.arguments[1] === 'messages_delivered'); - assert.equal( - deliveredEvents.length, - 0, - `should not emit messages_delivered for non-transitioned messages, got ${deliveredEvents.length}`, - ); - }); -}); diff --git a/packages/api/test/queue-processor-pause-epoch.test.js b/packages/api/test/queue-processor-pause-epoch.test.js deleted file mode 100644 index 388c284870..0000000000 --- a/packages/api/test/queue-processor-pause-epoch.test.js +++ /dev/null @@ -1,1066 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it, mock } from 'node:test'; - -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); -const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { QueuedMessageCustodyCoordinator } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' -); -const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); -const { InMemoryFreshnessClosureStore } = await import( - '../dist/domains/cats/services/freshness/closure/FreshnessClosureStore.js' -); -const SLOT_KEY = JSON.stringify(['thread-1', 'opus']); -const CODEX_SLOT_KEY = JSON.stringify(['thread-1', 'codex']); - -function deferred() { - /** @type {(value: { outcome: string; invocationId: string }) => void} */ - let resolve; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} - -function asyncGate() { - /** @type {() => void} */ - let resolve; - const promise = new Promise((r) => { - resolve = () => r(undefined); - }); - return { promise, resolve }; -} - -function depsWithQueuedThread(invocationTracker = { has: mock.fn(() => false) }) { - return { - queue: { - hasQueuedForThread: mock.fn(() => true), - hasDispatchableQueuedForThread: mock.fn(() => true), - listUsersForThread: mock.fn(() => []), - list: mock.fn(() => []), - markQueuedFailedForCatAcrossUsers: mock.fn(() => []), - fallbackAuthorIntentsForParentAcrossUsers: mock.fn(() => []), - }, - invocationTracker, - invocationRecordStore: { - create: mock.fn(async () => ({ outcome: 'created', invocationId: 'inv-stub' })), - update: mock.fn(async () => {}), - }, - router: { - routeExecution: mock.fn(async function* () {}), - ackCollectedCursors: mock.fn(async () => {}), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - messageStore: { - getById: mock.fn(async () => null), - }, - log: { - info: mock.fn(), - warn: mock.fn(), - error: mock.fn(), - }, - }; -} - -describe('QueueProcessor pause epoch', () => { - it('manual clear advances the epoch before a later refail', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const deps = depsWithQueuedThread(); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - await processor.onInvocationComplete('thread-1', 'opus', 'failed'); - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(SLOT_KEY), 1); - - processor.clearPause('thread-1', 'opus'); - assert.equal( - /** @type {any} */ (processor).pauseEpoch.get(SLOT_KEY), - 2, - 'clearPause must advance the generation so the old recovery timer can never regain ownership', - ); - assert.equal(/** @type {any} */ (processor).pausedSlots.has(SLOT_KEY), false); - - await processor.onInvocationComplete('thread-1', 'opus', 'failed'); - - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(SLOT_KEY), 3); - assert.equal( - /** @type {any} */ (processor).pausedSlots.has(SLOT_KEY), - true, - 'newer pause should remain active until its own recovery timer', - ); - }); - - it('releases an old zombie owner before entering failed recovery', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const zombieController = tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'inv-zombie'); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - - assert.deepEqual(result, { recoveredCatIds: ['opus'], replacementCatIds: [], ownerStates: { opus: 'released' } }); - assert.equal(tracker.has('thread-1', 'opus'), false); - assert.equal(zombieController.signal.aborted, false, 'terminal recovery retires ownership without aborting'); - assert.equal(processor.isPaused('thread-1', 'opus'), true); - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(SLOT_KEY), 1); - }); - - it('releases an exact old processing reservation instead of treating every slot as a replacement', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - /** @type {any} */ (processor).processingSlots.set(SLOT_KEY, { - startedAt: Date.now(), - entryId: 'entry-zombie', - invocationId: 'inv-zombie', - }); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - - assert.deepEqual(result, { recoveredCatIds: ['opus'], replacementCatIds: [], ownerStates: { opus: 'released' } }); - assert.equal(/** @type {any} */ (processor).processingSlots.has(SLOT_KEY), false); - assert.equal(processor.isPaused('thread-1', 'opus'), true); - }); - - it('retires an exact old reservation while preserving a replacement tracker owner', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const replacement = tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'inv-replacement'); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - /** @type {any} */ (processor).processingSlots.set(SLOT_KEY, { - startedAt: Date.now(), - entryId: 'entry-zombie', - invocationId: 'inv-zombie', - }); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - - assert.deepEqual(result, { - recoveredCatIds: [], - replacementCatIds: ['opus'], - ownerStates: { opus: 'replacement' }, - }); - assert.equal( - /** @type {any} */ (processor).processingSlots.has(SLOT_KEY), - false, - 'the known-old reservation must not survive behind a replacement tracker', - ); - assert.equal(tracker.has('thread-1', 'opus'), true); - assert.equal(replacement.signal.aborted, false); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - assert.equal(/** @type {any} */ (processor).pauseEpoch.has(SLOT_KEY), false); - }); - - it('recovers a zombie with no tracker owner and still advances pause epoch', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - - assert.deepEqual(result, { recoveredCatIds: ['opus'], replacementCatIds: [], ownerStates: { opus: 'absent' } }); - assert.equal(processor.isPaused('thread-1', 'opus'), true); - assert.equal(/** @type {any} */ (processor).pauseEpoch.get(SLOT_KEY), 1); - }); - - it('rejects an old zombie terminal when a replacement owns the slot without touching pause epoch', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const replacement = tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'inv-replacement'); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - - assert.deepEqual(result, { - recoveredCatIds: [], - replacementCatIds: ['opus'], - ownerStates: { opus: 'replacement' }, - }); - assert.equal(tracker.has('thread-1', 'opus'), true); - assert.equal(replacement.signal.aborted, false); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - assert.equal( - /** @type {any} */ (processor).pauseEpoch.has(SLOT_KEY), - false, - 'a late old terminal must not poison the replacement epoch', - ); - }); - - it('rejects an old zombie terminal while a replacement owns the pre-start processing reservation', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.invocationRecordStore.create = mock.fn(() => new Promise(() => {})); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'replacement starts first', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'keep queued work visible for pause detection', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - - const started = await processor.processNext('thread-1', 'user-1'); - assert.equal(started.started, true); - assert.equal(tracker.has('thread-1', 'opus'), false, 'record creation is blocked before tracker.startAll'); - assert.equal(/** @type {any} */ (processor).processingSlots.has(SLOT_KEY), true); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - - assert.deepEqual(result, { - recoveredCatIds: [], - replacementCatIds: ['opus'], - ownerStates: { opus: 'replacement' }, - }); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - assert.equal( - /** @type {any} */ (processor).pauseEpoch.has(SLOT_KEY), - false, - 'an old terminal must not advance the epoch of a pre-start replacement', - ); - }); - - it('rejects an old zombie terminal after a replacement reservation binds its invocation id', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - /** @type {any} */ (processor).processingSlots.set(SLOT_KEY, { - startedAt: Date.now(), - entryId: 'entry-replacement', - invocationId: 'inv-replacement', - }); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - - assert.deepEqual(result, { - recoveredCatIds: [], - replacementCatIds: ['opus'], - ownerStates: { opus: 'replacement' }, - }); - assert.equal(/** @type {any} */ (processor).processingSlots.has(SLOT_KEY), true); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - assert.equal(/** @type {any} */ (processor).pauseEpoch.has(SLOT_KEY), false); - }); - - it('retires every exact-old owner projection for a multi-cat terminal parent', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const batch = tracker.startAll('thread-1', ['opus', 'codex'], 'user-1', 'inv-zombie'); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - /** @type {any} */ (processor).processingSlots.set(SLOT_KEY, { - startedAt: Date.now(), - entryId: 'entry-opus-zombie', - invocationId: 'inv-zombie', - }); - /** @type {any} */ (processor).processingSlots.set(CODEX_SLOT_KEY, { - startedAt: Date.now(), - entryId: 'entry-codex-zombie', - invocationId: 'inv-zombie', - }); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus', 'codex'], 'inv-zombie'); - - assert.deepEqual(result, { - recoveredCatIds: ['opus', 'codex'], - replacementCatIds: [], - ownerStates: { opus: 'released', codex: 'released' }, - }); - assert.equal(tracker.has('thread-1', 'opus'), false); - assert.equal(tracker.has('thread-1', 'codex'), false); - assert.equal(/** @type {any} */ (processor).processingSlots.has(SLOT_KEY), false); - assert.equal(/** @type {any} */ (processor).processingSlots.has(CODEX_SLOT_KEY), false); - assert.equal(batch.signal.aborted, false, 'terminal retirement does not abort the old batch gate'); - assert.equal(processor.isPaused('thread-1', 'opus'), true); - assert.equal(processor.isPaused('thread-1', 'codex'), true); - }); - - it('recovers exact-old siblings while preserving per-cat replacements', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - tracker.startAll('thread-1', ['opus', 'codex'], 'user-1', 'inv-zombie'); - const replacement = tracker.start('thread-1', 'codex', 'user-1', ['codex'], 'inv-replacement'); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - /** @type {any} */ (processor).processingSlots.set(SLOT_KEY, { - startedAt: Date.now(), - entryId: 'entry-opus-zombie', - invocationId: 'inv-zombie', - }); - const replacementReservation = { - startedAt: Date.now(), - entryId: 'entry-codex-replacement', - invocationId: 'inv-replacement', - }; - /** @type {any} */ (processor).processingSlots.set(CODEX_SLOT_KEY, replacementReservation); - - const result = await processor.onReconciledZombieComplete('thread-1', ['opus', 'codex'], 'inv-zombie'); - - assert.deepEqual(result, { - recoveredCatIds: ['opus'], - replacementCatIds: ['codex'], - ownerStates: { opus: 'released', codex: 'replacement' }, - }); - assert.equal(tracker.has('thread-1', 'opus'), false); - assert.equal(tracker.has('thread-1', 'codex'), true); - assert.equal(replacement.signal.aborted, false); - assert.equal(/** @type {any} */ (processor).processingSlots.has(SLOT_KEY), false); - assert.equal(/** @type {any} */ (processor).processingSlots.get(CODEX_SLOT_KEY), replacementReservation); - assert.equal(processor.isPaused('thread-1', 'opus'), true); - assert.equal(processor.isPaused('thread-1', 'codex'), false); - assert.equal(/** @type {any} */ (processor).pauseEpoch.has(CODEX_SLOT_KEY), false); - }); - - it('discards an old failed-terminal pause when a replacement acquires during queue preview I/O', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const previewGate = asyncGate(); - const queued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'retained zombie read evidence', - messageId: 'msg-retained', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - assert.ok(queued.entry); - queue.markQueuedSeen('thread-1', 'user-1', queued.entry.id, 'opus', 'inv-zombie'); - - tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'inv-zombie'); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.messageStore.getById = mock.fn(async () => { - await previewGate.promise; - return null; - }); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - const recovery = processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(deps.messageStore.getById.mock.calls.length, 1, 'recovery is blocked in real queue-preview I/O'); - - const replacement = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - previewGate.resolve(); - await recovery; - - assert.equal(tracker.has('thread-1', 'opus'), true); - assert.equal(replacement.signal.aborted, false); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - assert.equal(/** @type {any} */ (processor).pauseEpoch.has(SLOT_KEY), false); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some((call) => call.arguments[1] === 'queue_paused'), - false, - 'the discarded old terminal must not publish a paused replacement projection', - ); - }); - - it('discards an old user-cancel pause when a replacement acquires during queue preview I/O', async (t) => { - // Same shape as the failed-terminal contract above. The user-cancel park was - // added later and mirrored that path's bookkeeping without mirroring its - // supersession fence, so a replacement owner could be parked by a turn that - // had already been cancelled. - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const previewGate = asyncGate(); - const queued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'work the user interrupted', - messageId: 'msg-user-cancel', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - assert.ok(queued.entry); - queue.markQueuedSeen('thread-1', 'user-1', queued.entry.id, 'opus', 'inv-cancelled'); - - tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'inv-cancelled'); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.messageStore.getById = mock.fn(async () => { - await previewGate.promise; - return null; - }); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - const cancelled = processor.onInvocationComplete( - 'thread-1', - 'opus', - 'canceled_by_user', - 'inv-cancelled', - ['opus'], - true, - ); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(deps.messageStore.getById.mock.calls.length, 1, 'the park is blocked in real queue-preview I/O'); - - const replacement = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - previewGate.resolve(); - await cancelled; - - assert.equal(tracker.has('thread-1', 'opus'), true); - assert.equal(replacement.signal.aborted, false); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - assert.equal(/** @type {any} */ (processor).pauseEpoch.has(SLOT_KEY), false); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some((call) => call.arguments[1] === 'queue_paused'), - false, - 'a slot that moved on must not publish a paused projection', - ); - }); - - it('fences a replacement sibling that arrives while another sibling awaits failed recovery I/O', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const previewGate = asyncGate(); - const queued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'multi-cat retained evidence', - messageId: 'msg-parent', - source: 'user', - targetCats: ['opus', 'codex'], - intent: 'execute', - }); - assert.ok(queued.entry); - queue.markQueuedSeen('thread-1', 'user-1', queued.entry.id, 'opus', 'inv-zombie'); - queue.markQueuedSeen('thread-1', 'user-1', queued.entry.id, 'codex', 'inv-zombie'); - - tracker.startAll('thread-1', ['opus', 'codex'], 'user-1', 'inv-zombie'); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.messageStore.getById = mock.fn(async () => { - await previewGate.promise; - return null; - }); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - const recovery = processor.onReconciledZombieComplete('thread-1', ['opus', 'codex'], 'inv-zombie'); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(deps.messageStore.getById.mock.calls.length, 1); - - const replacement = await processor.acquireExternalExecution('thread-1', ['codex'], 'user-1', { - mode: 'replacement', - executionId: 'inv-codex-replacement', - }); - assert.ok(replacement); - previewGate.resolve(); - await recovery; - - assert.equal(processor.isPaused('thread-1', 'opus'), true, 'the unreplaced sibling still recovers'); - assert.equal(processor.isPaused('thread-1', 'codex'), false); - assert.equal(/** @type {any} */ (processor).pauseEpoch.has(CODEX_SLOT_KEY), false); - assert.equal(tracker.has('thread-1', 'codex'), true); - assert.equal(replacement.signal.aborted, false); - }); - - it('replacement acquisition clears an already-committed old pause', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'inv-zombie'); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - - await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - assert.equal(processor.isPaused('thread-1', 'opus'), true); - - const replacement = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - - assert.ok(replacement); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - }); - - it('old auto-recovery timer performs no slot-keyed work after replacement acquisition', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const tracker = new InvocationTracker(); - tracker.start('thread-1', 'opus', 'user-1', ['opus'], 'inv-zombie'); - const deps = depsWithQueuedThread(tracker); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - await processor.onReconciledZombieComplete('thread-1', ['opus'], 'inv-zombie'); - const replacement = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - processor.clearPause('thread-1', 'opus'); - - t.mock.timers.tick(10_000); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal( - deps.log.info.mock.calls.some( - (call) => call.arguments[1] === '[QueueProcessor] Auto-recovering paused slot after timeout (#595)', - ), - false, - ); - assert.equal(tracker.has('thread-1', 'opus'), true); - assert.equal(replacement.signal.aborted, false); - }); - - it('does not let an old execution cleanup delete a replacement processing reservation', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const oldCreate = deferred(); - const replacementCreate = deferred(); - let createCount = 0; - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.invocationRecordStore.create = mock.fn(() => { - createCount += 1; - return createCount === 1 ? oldCreate.promise : replacementCreate.promise; - }); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - for (const content of ['old execution', 'replacement execution']) { - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content, - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - } - - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - processor.releaseSlot('thread-1', 'opus'); - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - - const replacementReservation = /** @type {any} */ (processor).processingSlots.get(SLOT_KEY); - oldCreate.resolve({ outcome: 'created', invocationId: 'inv-old' }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal( - /** @type {any} */ (processor).processingSlots.get(SLOT_KEY), - replacementReservation, - 'cleanup must compare the reservation owner instead of deleting by slot key', - ); - assert.equal(processor.isPaused('thread-1', 'opus'), false); - }); - - it('invalidates a blocked queue reservation before a direct replacement starts', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const oldCreate = deferred(); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.invocationRecordStore.create = mock.fn(() => oldCreate.promise); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'old queued execution', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - assert.equal(/** @type {any} */ (processor).processingSlots.has(SLOT_KEY), true); - - const replacement = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - assert.equal(/** @type {any} */ (processor).processingSlots.has(SLOT_KEY), false); - assert.equal( - queue.list('thread-1', 'user-1').some((entry) => entry.status === 'processing'), - false, - 'replacement acquisition must tombstone the retired processing row before a blocked create can return', - ); - - oldCreate.resolve({ outcome: 'created', invocationId: 'inv-old' }); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(replacement.signal.aborted, false, 'late old start must not abort the direct replacement'); - assert.equal(tracker.has('thread-1', 'opus'), true); - const canceledUpdate = deps.invocationRecordStore.update.mock.calls.find( - (call) => - call.arguments[0] === 'inv-old' && - call.arguments[1]?.status === 'canceled' && - call.arguments[1]?.error === 'queue_processing_reservation_replaced', - ); - assert.ok(canceledUpdate, 'late-created old record cancels before tracker registration'); - }); - - it('terminalizes a blocked freshness supplement when replacement tombstones its carrier', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const supplementStore = new InMemoryFreshnessClosureStore(); - const offered = await supplementStore.offerSupplement({ - lineageId: 'msg-original', - originalMessageId: 'msg-original', - userId: 'user-1', - threadId: 'thread-1', - catId: 'opus', - requiredMessageIds: ['msg-update'], - requiredFrontierMessageId: 'msg-update', - now: 100, - }); - const oldCreate = deferred(); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.freshnessClosureStore = supplementStore; - deps.invocationRecordStore.create = mock.fn(() => oldCreate.promise); - deps.messageStore.markCanceled = mock.fn(async () => ({ deliveryTransitioned: true })); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - const enqueued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'blocked supplement carrier', - messageId: 'msg-primary', - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, - }); - assert.ok(enqueued.entry); - queue.backfillMessageId('thread-1', 'user-1', enqueued.entry.id, 'msg-merged'); - - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - const replacement = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - assert.equal(queue.list('thread-1', 'user-1').length, 0, 'replacement tombstones the exact carrier'); - - for (let turn = 0; turn < 3; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - - const terminal = await supplementStore.getSupplement(offered.supplement.id); - assert.equal(terminal.status, 'failed'); - assert.equal(terminal.failureReason, 'user_cancel'); - assert.deepEqual( - deps.messageStore.markCanceled.mock.calls.map((call) => call.arguments[0]).sort(), - ['msg-merged', 'msg-primary'], - 'the removed carrier must cancel its primary and merged messages exactly once', - ); - assert.deepEqual( - deps.socketManager.emitToUser.mock.calls - .filter((call) => call.arguments[1] === 'message_deleted') - .map((call) => call.arguments[2]) - .sort((a, b) => a.messageId.localeCompare(b.messageId)), - [ - { messageId: 'msg-merged', threadId: 'thread-1', deletedBy: 'user-1' }, - { messageId: 'msg-primary', threadId: 'thread-1', deletedBy: 'user-1' }, - ], - 'the removed carrier must emit one deletion event for every canceled message', - ); - }); - - it('cancels honest queued custody when replacement tombstones its active carrier', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const messageStore = new MessageStore(); - const routeEntered = asyncGate(); - const routeRelease = asyncGate(); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.messageStore = messageStore; - deps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - deps.router.routeExecution = mock.fn(async function* () { - routeEntered.resolve(); - await routeRelease.promise; - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - const message = messageStore.append({ - threadId: 'thread-1', - userId: 'user-1', - catId: null, - content: 'already read by active carrier', - mentions: [], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); - - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: message.content, - messageId: message.id, - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - await routeEntered.promise; - assert.equal(messageStore.getById(message.id)?.deliveryStatus, 'queued'); - - const replacement = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - for (let turn = 0; turn < 3; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - - assert.equal( - messageStore.getById(message.id)?.deliveryStatus, - 'canceled', - 'explicit replacement cancellation must withdraw rather than publish the queued source', - ); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some( - (call) => call.arguments[1] === 'message_deleted' && call.arguments[2]?.messageId === message.id, - ), - true, - ); - - routeRelease.resolve(); - for (let turn = 0; turn < 3; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - const terminal = messageStore.getById(message.id); - assert.equal(terminal?.deliveryStatus, 'canceled'); - assert.equal(terminal?.queueCustody, undefined); - }); - - it('preserves a secondary replacement and defers the still-owned multi-cat carrier after record creation', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const oldCreate = deferred(); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.invocationRecordStore.create = mock.fn(() => oldCreate.promise); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'old multi-cat queued execution', - source: 'user', - targetCats: ['opus', 'codex'], - intent: 'execute', - }); - - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - const replacement = await processor.acquireExternalExecution('thread-1', ['codex'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - const replacementSlotController = tracker.getController('thread-1', 'codex'); - assert.ok(replacementSlotController); - - oldCreate.resolve({ outcome: 'created', invocationId: 'inv-old' }); - for (let turn = 0; turn < 5; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - - assert.equal( - replacementSlotController.signal.aborted, - false, - 'old multi-cat start must preserve the secondary replacement', - ); - assert.equal(tracker.classifyExecutionId('thread-1', 'codex', 'inv-replacement'), 'matching'); - const canceledUpdate = deps.invocationRecordStore.update.mock.calls.find( - (call) => - call.arguments[0] === 'inv-old' && - call.arguments[1]?.status === 'canceled' && - call.arguments[1]?.error === 'queue_target_busy', - ); - assert.ok(canceledUpdate, 'the old attempt stops before mutating any target tracker'); - assert.equal(queue.list('thread-1', 'user-1')[0]?.status, 'queued', 'its source still owns pending work'); - assert.deepEqual(queue.list('thread-1', 'user-1')[0]?.queuedFailedByCatIds ?? [], []); - }); - - it('rejects replacement acquisition when a processing reservation belongs to another user', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const supplementStore = new InMemoryFreshnessClosureStore(); - const offered = await supplementStore.offerSupplement({ - lineageId: 'msg-foreign-original', - originalMessageId: 'msg-foreign-original', - userId: 'user-b', - threadId: 'thread-1', - catId: 'opus', - requiredMessageIds: ['msg-foreign-update'], - requiredFrontierMessageId: 'msg-foreign-update', - now: 100, - }); - const oldCreate = deferred(); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.freshnessClosureStore = supplementStore; - deps.invocationRecordStore.create = mock.fn(() => oldCreate.promise); - deps.messageStore.markCanceled = mock.fn(async () => null); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - const enqueued = queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-b', - content: 'foreign supplement carrier', - messageId: 'msg-foreign-primary', - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, - }); - assert.ok(enqueued.entry); - queue.backfillMessageId('thread-1', 'user-b', enqueued.entry.id, 'msg-foreign-merged'); - - assert.equal((await processor.processNext('thread-1', 'user-b')).started, true); - const foreignReservation = /** @type {any} */ (processor).processingSlots.get(SLOT_KEY); - assert.ok(foreignReservation); - - const attempted = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-user-a', - }); - - assert.equal(attempted, null); - assert.equal( - /** @type {any} */ (processor).processingSlots.get(SLOT_KEY), - foreignReservation, - 'rejected acquisition must not partially release foreign custody', - ); - assert.equal(tracker.has('thread-1', 'opus'), false); - assert.equal(queue.list('thread-1', 'user-b')[0]?.status, 'processing'); - assert.equal((await supplementStore.getSupplement(offered.supplement.id)).status, 'pending'); - assert.equal(deps.messageStore.markCanceled.mock.callCount(), 0); - assert.equal( - deps.socketManager.emitToUser.mock.calls.filter((call) => call.arguments[1] === 'message_deleted').length, - 0, - 'rejected acquisition must not emit message deletion events', - ); - - oldCreate.resolve({ outcome: 'created', invocationId: 'inv-user-b' }); - for (let turn = 0; turn < 3; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - }); - - it('rejects replacement acquisition when a tracker owner belongs to another user', async () => { - const tracker = new InvocationTracker(); - const foreign = tracker.startAll('thread-1', ['opus'], 'user-b', 'inv-user-b'); - const processor = new QueueProcessor(/** @type {any} */ (depsWithQueuedThread(tracker))); - - const attempted = await processor.acquireExternalExecution('thread-1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-user-a', - }); - - assert.equal(attempted, null); - assert.equal(foreign.signal.aborted, false); - assert.equal(tracker.getUserId('thread-1', 'opus'), 'user-b'); - assert.equal(tracker.classifyExecutionId('thread-1', 'opus', 'inv-user-b'), 'matching'); - }); - - it('rechecks a bound reservation after supplement preflight before tracker start', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const supplementStore = new InMemoryFreshnessClosureStore(); - const offered = await supplementStore.offerSupplement({ - lineageId: 'msg-original', - originalMessageId: 'msg-original', - userId: 'user-1', - threadId: 'thread-1', - catId: 'opus', - requiredMessageIds: ['msg-update'], - requiredFrontierMessageId: 'msg-update', - now: 100, - }); - const preflightEntered = asyncGate(); - const resumePreflight = asyncGate(); - const getSupplement = supplementStore.getSupplement.bind(supplementStore); - supplementStore.getSupplement = mock.fn(async (supplementId) => { - preflightEntered.resolve(); - await resumePreflight.promise; - return getSupplement(supplementId); - }); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.freshnessClosureStore = supplementStore; - deps.messageStore.getById = mock.fn(async (id) => ({ - id, - threadId: 'thread-1', - userId: 'user-1', - catId: id === 'msg-original' ? 'opus' : null, - content: id === 'msg-original' ? 'published answer' : 'late update', - mentions: [], - timestamp: 100, - })); - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'supplement carrier', - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - targetCats: ['opus'], - intent: 'execute', - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, - }); - - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - await preflightEntered.promise; - assert.equal(/** @type {any} */ (processor).processingSlots.get(SLOT_KEY)?.invocationId, 'inv-stub'); - - const replacementPromise = processor.acquireExternalExecution('thread-1', ['opus'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - // Replacement now owns a durable retirement barrier. Let the old - // supplement read and the terminalizer resume; the provider cannot publish - // until terminal truth commits. - resumePreflight.resolve(); - const replacement = await replacementPromise; - assert.ok(replacement); - const replacementSlotController = tracker.getController('thread-1', 'opus'); - assert.ok(replacementSlotController); - - for (let turn = 0; turn < 5; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - - assert.equal(replacementSlotController.signal.aborted, false, 'late queued start must not abort the replacement'); - assert.equal(tracker.classifyExecutionId('thread-1', 'opus', 'inv-replacement'), 'matching'); - const canceledUpdate = deps.invocationRecordStore.update.mock.calls.find( - (call) => - call.arguments[0] === 'inv-stub' && - call.arguments[1]?.status === 'canceled' && - call.arguments[1]?.error === 'queue_processing_reservation_replaced', - ); - assert.ok(canceledUpdate, 'superseded carrier cancels before tracker registration'); - }); - - it('rechecks every target after action preflight and retains an unstarted carrier behind a busy sibling', async () => { - const tracker = new InvocationTracker(); - const queue = new InvocationQueue(); - const preflightEntered = asyncGate(); - const resumePreflight = asyncGate(); - const deps = depsWithQueuedThread(tracker); - deps.queue = queue; - deps.actionSuccessorLeaseStore = { - preflight: mock.fn(async () => { - preflightEntered.resolve(); - await resumePreflight.promise; - return { ok: true, reason: 'active' }; - }), - preflightOutput: mock.fn(async () => ({ ok: true, reason: 'active' })), - commitOutcome: mock.fn(async () => ({ outcome: 'recorded', lease: { status: 'active' } })), - }; - const processor = new QueueProcessor(/** @type {any} */ (deps)); - - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 'thread-1', - userId: 'user-1', - content: 'multi-cat action carrier', - source: 'agent', - autoExecute: true, - targetCats: ['opus', 'codex'], - intent: 'execute', - actionSuccessorFence: { - leaseId: 'lease-1', - generation: 1, - dispatchId: 'multi-mention:req-1', - }, - }); - - assert.equal((await processor.processNext('thread-1', 'user-1')).started, true); - await preflightEntered.promise; - - const replacement = await processor.acquireExternalExecution('thread-1', ['codex'], 'user-1', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - const replacementSlotController = tracker.getController('thread-1', 'codex'); - assert.ok(replacementSlotController); - - resumePreflight.resolve(); - for (let turn = 0; turn < 5; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - - assert.equal( - replacementSlotController.signal.aborted, - false, - 'late multi-cat carrier must preserve secondary replacement', - ); - assert.equal(tracker.classifyExecutionId('thread-1', 'codex', 'inv-replacement'), 'matching'); - const canceledUpdate = deps.invocationRecordStore.update.mock.calls.find( - (call) => - call.arguments[0] === 'inv-stub' && - call.arguments[1]?.status === 'canceled' && - call.arguments[1]?.error === 'queue_target_busy', - ); - assert.ok( - canceledUpdate, - `busy multi-cat attempt stops before tracker registration; updates=${JSON.stringify( - deps.invocationRecordStore.update.mock.calls.map((call) => call.arguments), - )}`, - ); - assert.equal(queue.list('thread-1', 'user-1')[0]?.status, 'queued'); - assert.deepEqual(queue.list('thread-1', 'user-1')[0]?.queuedFailedByCatIds ?? [], []); - }); -}); diff --git a/packages/api/test/queue-processor-zombie.test.js b/packages/api/test/queue-processor-zombie.test.js index 19c02a22e5..41d5b8a6b2 100644 --- a/packages/api/test/queue-processor-zombie.test.js +++ b/packages/api/test/queue-processor-zombie.test.js @@ -5,9 +5,11 @@ import assert from 'node:assert/strict'; import { describe, it, mock } from 'node:test'; +import { adaptInvocationQueue } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); +const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const SHORT_TTL = 1000; const T0 = 100_000; @@ -25,7 +27,7 @@ function reservation(startedAt, entryId = 'entry-test', invocationId, trackerSta function stubDeps(overrides = {}) { return { - queue: new InvocationQueue(), + queue: adaptInvocationQueue(new InvocationQueue()), invocationTracker: { start: mock.fn(() => new AbortController()), startAll: mock.fn(() => new AbortController()), @@ -58,19 +60,24 @@ function stubDeps(overrides = {}) { }; } -function enqueueProcessing(deps, content = 'recover me') { +async function enqueueClaimed(deps, content = 'recover me', commitProcessing = false) { const entry = deps.queue.enqueue({ + kind: 'private_input', ownerAuthProvenance: 'unknown', threadId: 't1', userId: 'u1', content, - source: 'user', + source: 'agent', targetCats: ['opus'], intent: 'execute', }).entry; assert.ok(entry); - assert.ok(deps.queue.markProcessingById('t1', entry.id)); - return entry; + assert.ok(await deps.queue.markProcessingByIdDurable('t1', entry.id, 'opus')); + if (commitProcessing) { + assert.equal(await deps.queue.commitClaimedProcessing('t1', [entry.id]), true); + return entry; + } + return deps.queue.getEntrySnapshot('t1', 'u1', entry.id); } describe('QueueProcessor explicit stale-owner recovery (F118)', () => { @@ -83,61 +90,150 @@ describe('QueueProcessor explicit stale-owner recovery (F118)', () => { t.mock.timers.tick(SHORT_TTL + 1); assert.equal(processor.isThreadBusy('t1'), true); - assert.equal(processor.hasActiveExecution('t1'), true); assert.equal(processor.isCatBusy('t1', 'opus'), true); - await processor.tryAutoExecute('t1'); + await processor.requestDrain('t1'); assert.equal(/** @type {any} */ (processor).processingSlots.has(key), true); }); it('explicitly reaps and requeues the exact stale pre-provider reservation', async (t) => { t.mock.timers.enable({ apis: ['Date'], now: T0 }); - const persisted = []; - const deps = stubDeps({ - queueCustodyCoordinator: { - persistEntry: mock.fn(async (entry) => persisted.push(structuredClone(entry))), - }, - }); + const deps = stubDeps(); const processor = new QueueProcessor(deps, { processingSlotTtlMs: SHORT_TTL }); - const entry = enqueueProcessing(deps); + const entry = await enqueueClaimed(deps); /** @type {any} */ (processor).processingSlots.set(slotKey('t1', 'opus'), reservation(T0, entry.id)); t.mock.timers.tick(SHORT_TTL + 1); - assert.equal(processor.reapStalePrestartReservations(), 1); + assert.equal(await processor.reapStalePrestartReservations(), 1); assert.equal(/** @type {any} */ (processor).processingSlots.has(slotKey('t1', 'opus')), false); assert.equal(deps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.status, 'queued'); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(persisted.length, 1); - assert.equal(persisted[0].status, 'queued'); }); - it('treats a bound reservation as pre-provider until tracker installation is proven', (t) => { + it('terminalizes a stale processing reservation as prestart_timeout instead of leaving it orphaned', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: T0 }); + const messageStore = new MessageStore(); + const deps = stubDeps({ messageStore }); + const processor = new QueueProcessor(deps, { processingSlotTtlMs: SHORT_TTL }); + const admitted = await deps.queue.appendAndEnqueueDurable( + messageStore, + { + from: { kind: 'user', userId: 'u1' }, + userId: 'u1', + threadId: 't1', + content: 'start slowly', + mentions: ['opus'], + timestamp: T0, + deliveryStatus: 'queued', + }, + { + from: { kind: 'user', userId: 'u1' }, + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'start slowly', + targetCats: ['opus'], + intent: 'execute', + }, + ); + const entry = admitted.entries[0]; + assert.ok(await deps.queue.markProcessingByIdDurable('t1', entry.id, 'opus')); + assert.equal(await deps.queue.commitClaimedProcessing('t1', [entry.id]), true); + /** @type {any} */ (processor).processingSlots.set(slotKey('t1', 'opus'), reservation(T0, entry.id)); + t.mock.timers.tick(SHORT_TTL + 1); + + assert.equal(await processor.reapStalePrestartReservations(), 1); + assert.equal(/** @type {any} */ (processor).processingSlots.has(slotKey('t1', 'opus')), false); + assert.equal(deps.queue.getEntrySnapshot('t1', 'u1', entry.id), null); + assert.equal( + await deps.queue.ledgerStore.get('t1', entry.id), + null, + 'Queue releases processing/terminal rows; History owns the failure truth', + ); + const source = messageStore.getById(admitted.message.id); + const failure = messageStore + .getRecent(10, 'system') + .find((message) => message.lifecycle?.kind === 'delivery_failure'); + assert.equal(failure.lifecycle.reason, 'prestart_timeout'); + assert.equal(source.lifecycle.dispatchRefs[0].phase, 'settled'); + assert.equal(source.lifecycle.dispatchRefs[0].statusMessageId, failure.id); + }); + + it('fails a public pre-start source with delivery_failure instead of silently canceling it', async () => { + const messageStore = new MessageStore(); + const deps = stubDeps({ messageStore }); + const processor = new QueueProcessor(deps, { processingSlotTtlMs: SHORT_TTL }); + const admitted = await deps.queue.appendAndEnqueueDurable( + messageStore, + { + from: { kind: 'user', userId: 'u1' }, + userId: 'u1', + threadId: 't1', + content: 'please run', + mentions: ['opus'], + timestamp: T0, + deliveryStatus: 'queued', + }, + { + from: { kind: 'user', userId: 'u1' }, + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: 'please run', + targetCats: ['opus'], + intent: 'execute', + }, + ); + const entry = admitted.entries[0]; + assert.ok(entry); + assert.ok(await deps.queue.markProcessingByIdDurable('t1', entry.id, 'opus')); + /** @type {any} */ (processor).processingSlots.set(slotKey('t1', 'opus'), reservation(T0, entry.id)); + + assert.equal( + await processor.failPrestartProcessingGroup('t1', 'opus', 'u1', 'control_plane_unavailable'), + 'retired', + ); + assert.equal(deps.queue.getEntrySnapshot('t1', 'u1', entry.id), null); + assert.equal(await deps.queue.ledgerStore.get('t1', entry.id), null); + const source = messageStore.getById(admitted.message.id); + assert.equal(source.deliveryStatus, 'delivered'); + assert.equal(source.lifecycle.dispatchRefs[0].phase, 'settled'); + const failure = messageStore + .getRecent(10, 'system') + .find((message) => message.lifecycle?.kind === 'delivery_failure'); + assert.equal(failure.lifecycle.reason, 'control_plane_unavailable'); + assert.equal(source.lifecycle.dispatchRefs[0].statusMessageId, failure.id); + }); + + it('treats a bound reservation as pre-provider until tracker installation is proven', async (t) => { t.mock.timers.enable({ apis: ['Date'], now: T0 }); const deps = stubDeps(); const processor = new QueueProcessor(deps, { processingSlotTtlMs: SHORT_TTL }); - const entry = enqueueProcessing(deps); + const entry = await enqueueClaimed(deps); /** @type {any} */ (processor).processingSlots.set( slotKey('t1', 'opus'), reservation(T0, entry.id, 'exec-bound-before-start'), ); t.mock.timers.tick(SHORT_TTL + 1); - assert.equal(processor.reapStalePrestartReservations(), 1); + assert.equal(await processor.reapStalePrestartReservations(), 1); assert.equal(deps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.status, 'queued'); }); - it('never reaps a started provider reservation without lifecycle reconciliation', (t) => { + it('never reaps a started provider reservation without lifecycle reconciliation', async (t) => { t.mock.timers.enable({ apis: ['Date'], now: T0 }); const deps = stubDeps(); const processor = new QueueProcessor(deps, { processingSlotTtlMs: SHORT_TTL }); - const entry = enqueueProcessing(deps); + const entry = await enqueueClaimed(deps, 'recover me', true); /** @type {any} */ (processor).processingSlots.set( slotKey('t1', 'opus'), reservation(T0, entry.id, 'exec-provider', true), ); t.mock.timers.tick(SHORT_TTL + 1); - assert.equal(processor.reapStalePrestartReservations(), 0); - assert.equal(deps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.status, 'processing'); + assert.equal(await processor.reapStalePrestartReservations(), 0); + assert.equal(deps.queue.getEntrySnapshot('t1', 'u1', entry.id), null); + assert.equal(await deps.queue.ledgerStore.get('t1', entry.id), null); assert.deepEqual(processor.listStaleProcessingLeases(), [ { threadId: 't1', diff --git a/packages/api/test/queue-processor.test.js b/packages/api/test/queue-processor.test.js index 938bfcd568..fe23abc741 100644 --- a/packages/api/test/queue-processor.test.js +++ b/packages/api/test/queue-processor.test.js @@ -1,8556 +1,1310 @@ import assert from 'node:assert/strict'; -import { beforeEach, describe, it, mock } from 'node:test'; +import { describe, it, mock } from 'node:test'; +import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); -const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); -const { - QueuedMessageCustodyCoordinator, - createInitialCrossThreadQueuedMessageCustody, - createInitialQueuedMessageCustody, -} = await import('../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'); -const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); -const { projectQueueReceipt } = await import('../dist/domains/cats/services/stores/ports/queued-message-receipt.js'); -const { createA2ADispositionHarness } = await import('./helpers/a2a-dispatch-disposition-harness.js'); -const queuedTelemetry = await import('../dist/domains/cats/services/freshness/freshness-queue-telemetry.js'); -const { InMemoryFreshnessClosureStore } = await import( - '../dist/domains/cats/services/freshness/closure/FreshnessClosureStore.js' +const { CallerDispatchObservationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/CallerDispatchObservationRegistry.js' ); -const { emitQueueUpdated } = await import('../dist/utils/queue-enrichment.js'); -const { buildDispatchHandledContinuationCapsule, completeCapsuleForSeal, buildCapsuleFromRouteState } = await import( - '../dist/domains/cats/services/agents/invocation/CollaborationContinuityCapsule.js' +const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); +const { MessageStore, settleLifecycleResponseInputs } = await import( + '../dist/domains/cats/services/stores/ports/MessageStore.js' ); -/** Build a stub deps object for QueueProcessor */ -function stubDeps(overrides = {}) { +let sourceSequence = 0; + +function waitFor(predicate, timeoutMs = 3_000) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const poll = () => { + if (predicate()) return resolve(); + if (Date.now() - startedAt >= timeoutMs) return reject(new Error('timed out waiting for Queue transition')); + setTimeout(poll, 5); + }; + poll(); + }); +} + +function createInvocationRecordStore() { + const records = new Map(); + let invocationSequence = 0; return { - queue: new InvocationQueue(), - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'created', - invocationId: 'inv-stub', - })), - update: mock.fn(async () => {}), - }, - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - messageStore: { - append: mock.fn(async () => ({ id: 'msg-stub' })), - getByIdempotencyKey: mock.fn(async () => null), - getById: mock.fn(async () => null), - // Whole-message selection resolves the canonical bubble group from the thread timeline. - getByThreadAfter: mock.fn(async () => []), - markDelivered: mock.fn(async (id) => ({ - id, - threadId: 't1', - content: `delivered:${id}`, - catId: null, - timestamp: Date.now(), - mentions: [], - userId: 'u1', - deliveryStatus: 'delivered', - deliveredAt: Date.now(), - deliveryTransitioned: true, - })), - }, - log: { - info: mock.fn(), - warn: mock.fn(), - error: mock.fn(), - }, - ...overrides, + records, + create: mock.fn(async (input) => { + const invocationId = `inv-${++invocationSequence}`; + const now = Date.now(); + records.set(invocationId, { + id: invocationId, + ...input, + userMessageId: null, + status: 'queued', + createdAt: now, + updatedAt: now, + }); + return { outcome: 'created', invocationId }; + }), + get: mock.fn(async (invocationId) => records.get(invocationId) ?? null), + update: mock.fn(async (invocationId, patch) => { + const current = records.get(invocationId); + if (!current) return null; + if (patch.expectedStatus && current.status !== patch.expectedStatus) return null; + const { expectedStatus: _expectedStatus, ...changes } = patch; + const next = { ...current, ...changes, updatedAt: Date.now() }; + records.set(invocationId, next); + return next; + }), }; } -/** Helper: enqueue an entry and return it */ -function enqueueEntry(queue, overrides = {}) { - const result = queue.enqueue({ - threadId: 't1', - userId: 'u1', - content: 'hello', - source: 'user', - ownerAuthProvenance: 'unknown', - targetCats: ['opus'], - intent: 'execute', - ...overrides, - }); - return result.entry; +function createHarness({ + routeExecution, + tracker = new InvocationTracker(), + callerDispatchObservationRegistry = new CallerDispatchObservationRegistry(), + processorOptions, +} = {}) { + const queue = new InvocationQueue(); + const messageStore = new MessageStore(); + const invocationRecordStore = createInvocationRecordStore(); + const routeCalls = []; + const router = { + resolveExplicitTargets: mock.fn(async (targetCats) => [...targetCats]), + resolveConversationTargetsAtAdmission: mock.fn(async (targetCats) => + targetCats.length > 0 ? [...targetCats] : ['opus'], + ), + routeExecution: mock.fn( + routeExecution ?? + async function* (...args) { + routeCalls.push(args); + const [userId, , threadId, , targetCats, , options] = args; + const startedAt = Date.now(); + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: `turn-default-${routeCalls.length}`, + parentInvocationId: options.parentInvocationId, + startedAt, + }); + yield { type: 'done', catId: args[4][0], isFinal: true, timestamp: Date.now() }; + }, + ), + ackCollectedCursors: mock.fn(async () => {}), + }; + const socketManager = { + broadcastAgentMessage: mock.fn(), + broadcastToRoom: mock.fn(), + emitToUser: mock.fn(), + }; + const deps = { + queue, + invocationTracker: tracker, + invocationRecordStore, + router, + socketManager, + messageStore, + callerDispatchObservationRegistry, + log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, + }; + return { ...deps, processor: new QueueProcessor(deps, processorOptions), routeCalls }; } -function enqueueCustodiedEntry(queue, messageStore, overrides = {}) { - const { messageSource, messageUserId, ...entryOverrides } = overrides; - const entry = enqueueEntry(queue, entryOverrides); - const message = messageStore.append({ - userId: messageUserId ?? entry.userId, - catId: null, - content: entry.content, - mentions: entry.targetCats, - timestamp: entry.createdAt, - threadId: entry.threadId, - deliveryStatus: 'queued', - queueCustody: createInitialQueuedMessageCustody(entry), - ...(messageSource ? { source: messageSource } : {}), +async function startLifecycle(args, invocationId) { + const [userId, , threadId, , targetCats, , options] = args; + return options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), }); - queue.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - return { entry, message }; } -async function waitFor(predicate, timeoutMs = 5000, intervalMs = 10) { - const start = Date.now(); - // biome-ignore lint: polling helper - while (true) { - if (predicate()) return; - if (Date.now() - start > timeoutMs) throw new Error('timeout'); - await new Promise((r) => setTimeout(r, intervalMs)); - } +function errorLog(harness) { + return harness.log.error.mock.calls.map((call) => + call.arguments.map((argument) => { + if (argument?.err instanceof Error) { + return { ...argument, err: { message: argument.err.message, stack: argument.err.stack } }; + } + return argument; + }), + ); } -describe('QueueProcessor', () => { - it('F293 preserves the durable queue source separately from replay provenance', async () => { - for (const source of ['user', 'connector', 'agent']) { - const deps = stubDeps(); - const processor = new QueueProcessor(deps); - enqueueEntry(deps.queue, { source, ownerAuthProvenance: 'strict' }); - await processor.processNext('t1', 'u1'); - await waitFor(() => deps.router.routeExecution.mock.calls.length > 0); - const options = deps.router.routeExecution.mock.calls[0].arguments[6]; - assert.equal(options.humanDispositionInvocationOrigin, 'queue_replay'); - assert.equal(options.routingQueueSource, source); - } +async function admitMessage(harness, overrides = {}) { + sourceSequence += 1; + const queueInput = canonicalTestQueueInput({ + threadId: 'thread-1', + userId: 'user-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + sourceId: `queue-processor-${sourceSequence}`, + content: `body-${sourceSequence}`, + targetCats: ['opus'], + intent: 'execute', + ...overrides, }); + const messageInput = canonicalTestMessageInput({ + threadId: queueInput.threadId, + userId: queueInput.userId, + catId: null, + from: queueInput.from, + content: queueInput.content, + mentions: queueInput.targetCats, + timestamp: Date.now(), + deliveryStatus: 'queued', + }); + const result = await harness.queue.appendAndEnqueueDurable(harness.messageStore, messageInput, queueInput); + assert.equal(result.outcome, 'enqueued'); + assert.ok(result.entry); + return result; +} - let deps; - let processor; - - beforeEach(() => { - queuedTelemetry.resetFreshnessQueueTelemetryForTest(); - deps = stubDeps(); - processor = new QueueProcessor(deps); +function bindActiveRun( + harness, + { catId = 'opus', invocationId = 'turn-active', parentInvocationId = 'parent-active' } = {}, +) { + const startedAt = Date.now(); + harness.invocationTracker.start('thread-1', catId, 'user-1', [catId], parentInvocationId); + const response = harness.messageStore.append({ + from: { kind: 'agent', catId }, + userId: 'user-1', + content: '', + mentions: [], + origin: 'stream', + timestamp: startedAt, + threadId: 'thread-1', + lifecycle: { + kind: 'response', + orderKey: `${startedAt}:${invocationId}`, + invocationId, + targetId: catId, + inputEntryIds: [], + inputMessageIds: [], + status: 'processing', + startedAt, + }, }); + assert.equal( + harness.invocationTracker.bindLifecycleActiveRun( + { + threadId: 'thread-1', + targetId: catId, + invocationId, + responseMessageId: response.id, + inputEntryIds: [], + inputMessageIds: [], + privateInputEntryIds: [], + startedAt, + }, + parentInvocationId, + ), + true, + ); + return { invocationId, response, startedAt }; +} - it('F294 re-resolves a queued Bundle from current source truth immediately before routeExecution', async () => { - const targetMessage = { - id: 'bundle-q1', - threadId: 't1', - userId: 'u1', - catId: null, - content: '转发了 1 条消息 · 来自「Source Thread」', - mentions: ['opus'], - timestamp: 100, - deliveryStatus: 'queued', - extra: { - messageBundle: { - v: 1, - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-q1' }], - }, - }, - }; - const sourceMessage = { - id: 'source-q1', - threadId: 'source-thread', - userId: 'u1', - catId: 'opus', - content: 'current source truth at dequeue', - mentions: [], - timestamp: 90, - }; - const queueDeps = stubDeps({ - threadStore: { - get: mock.fn(async (threadId) => - threadId === 'source-thread' - ? { id: threadId, title: 'Source Thread', createdBy: 'u1' } - : { id: threadId, title: 'Target Thread', createdBy: 'u1' }, - ), - setPendingContinuation: mock.fn(async () => {}), - consumePendingContinuation: mock.fn(async () => null), - }, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => - id === targetMessage.id ? targetMessage : id === sourceMessage.id ? sourceMessage : null, - ), - // Whole-message re-resolution reads the canonical bubble group from the thread timeline. - getByThreadAfter: mock.fn(async (threadId) => - [targetMessage, sourceMessage].filter((message) => message.threadId === threadId), - ), - }, - }); - const queueProcessor = new QueueProcessor(queueDeps); - const entry = enqueueEntry(queueDeps.queue, { - content: targetMessage.content, - idempotencyKey: 'bundle-q1-key', - }); - targetMessage.queueCustody = createInitialQueuedMessageCustody(entry); - queueDeps.queue.backfillMessageId('t1', 'u1', entry.id, targetMessage.id); +describe('QueueProcessor over the source-row pending Queue', () => { + it('reconciles a stale Queue target that already has a durable response without reentering the provider', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness, { targetCats: ['opus', 'codex'] }); + const source = await harness.messageStore.getById(admitted.message.id); + const dispatched = await harness.messageStore.advanceLifecycleInputDispatch(source.id, { + orderKey: source.lifecycle.orderKey, + from: source.from, + targetId: 'opus', + phase: 'dispatched', + statusMessageId: 'response-already-durable', + dispatchedAt: source.timestamp + 1, + }); + assert.equal(dispatched.kind, 'applied'); + const settled = await harness.messageStore.advanceLifecycleInputDispatch(source.id, { + orderKey: source.lifecycle.orderKey, + from: source.from, + targetId: 'opus', + phase: 'settled', + statusMessageId: 'response-already-durable', + }); + assert.equal(settled.kind, 'applied'); + + const started = await harness.processor.processNext('thread-1', 'user-1'); + + assert.equal(started.started, false); + assert.equal(harness.router.routeExecution.mock.calls.length, 0); + const remaining = await harness.queue.getDurableEntry('thread-1', admitted.entry.id); + assert.equal(remaining.status, 'queued'); + assert.deepEqual(remaining.targets, ['codex']); + }); + + it('reconciles dispatchRefs when a prior response is itself the source of successor work', async () => { + const harness = createHarness(); + const source = await harness.messageStore.append({ + from: { kind: 'agent', catId: 'caller' }, + userId: 'user-1', + content: 'successor source', + mentions: ['opus', 'codex'], + origin: 'stream', + timestamp: 10, + threadId: 'thread-1', + lifecycle: { + kind: 'response', + orderKey: '10:caller-response', + invocationId: 'caller-response', + targetId: 'caller', + inputEntryIds: ['caller-entry'], + inputMessageIds: ['caller-input'], + status: 'completed', + startedAt: 9, + completedAt: 10, + dispatchRefs: [{ targetId: 'opus', phase: 'settled', statusMessageId: 'opus-response', dispatchedAt: 10 }], + }, + }); + const queued = await harness.queue.enqueueDurable( + canonicalTestQueueInput({ + threadId: 'thread-1', + userId: 'user-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + sourceId: source.id, + messageId: source.id, + content: source.content, + from: source.from, + targetCats: ['opus', 'codex'], + intent: 'execute', + }), + ); - const result = await queueProcessor.executeEntry(queueDeps.queue.markProcessing('t1', 'u1')); + const started = await harness.processor.processNext('thread-1', 'user-1'); - assert.equal(result.status, 'succeeded'); - assert.equal(queueDeps.router.routeExecution.mock.calls.length, 1); - const routeCall = queueDeps.router.routeExecution.mock.calls[0].arguments; - assert.match(routeCall[1], /current source truth at dequeue/); - assert.match(routeCall[1], /Bundle ID: bundle-q1/); - assert.equal(routeCall[1].includes(targetMessage.content), false, 'safe summary is not the cat prompt fallback'); - assert.deepEqual(routeCall[6].persistedPromptMessageIds, [targetMessage.id]); - assert.deepEqual(routeCall[6].persistedPromptMessages, [ - { - messageId: targetMessage.id, - content: routeCall[1], - forceExplicitProjection: true, - }, - ]); + assert.equal(started.started, false); + assert.equal(harness.router.routeExecution.mock.calls.length, 0); + const remaining = await harness.queue.getDurableEntry('thread-1', queued.entry.id); + assert.equal(remaining.status, 'queued'); + assert.deepEqual(remaining.targets, ['codex']); }); - it('F294 fails a queued Bundle without invoking a cat when every source has become unavailable', async () => { - const targetMessage = { - id: 'bundle-q2', - threadId: 't1', - userId: 'u1', - catId: null, - content: '转发了 1 条消息 · 来自「Source Thread」', - mentions: ['opus'], - timestamp: 100, - deliveryStatus: 'queued', - extra: { - messageBundle: { - v: 1, - sourceThreadId: 'source-thread', - items: [{ kind: 'message', messageId: 'source-q2' }], - }, - }, - }; - const recalledSource = { - id: 'source-q2', - threadId: 'source-thread', - userId: 'u1', - catId: null, - content: 'recalled private source must never reach the cat', - mentions: [], - timestamp: 90, - deliveryStatus: 'canceled', - _tombstone: true, - recall: { exposure: 'unseen' }, - }; - const queueDeps = stubDeps({ - threadStore: { - get: mock.fn(async (threadId) => ({ id: threadId, title: 'Source Thread', createdBy: 'u1' })), - setPendingContinuation: mock.fn(async () => {}), - consumePendingContinuation: mock.fn(async () => null), - }, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => - id === targetMessage.id ? targetMessage : id === recalledSource.id ? recalledSource : null, - ), - }, - }); - const queueProcessor = new QueueProcessor(queueDeps); - const entry = enqueueEntry(queueDeps.queue, { - content: targetMessage.content, - idempotencyKey: 'bundle-q2-key', - }); - targetMessage.queueCustody = createInitialQueuedMessageCustody(entry); - queueDeps.queue.backfillMessageId('t1', 'u1', entry.id, targetMessage.id); + it('claims and removes admitted work from Queue without leaving a terminal tombstone', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness); - const result = await queueProcessor.executeEntry(queueDeps.queue.markProcessing('t1', 'u1')); + const started = await harness.processor.processNext('thread-1', 'user-1'); + assert.equal(started.started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id) === null); - assert.equal(result.status, 'failed'); - assert.equal(queueDeps.router.routeExecution.mock.calls.length, 0); assert.equal( - queueDeps.invocationRecordStore.update.mock.calls.some( - (call) => - call.arguments[1]?.status === 'failed' && - call.arguments[1]?.error === 'Message Bundle prompt unavailable: all_unavailable', - ), - true, - 'the queue must fail for live source invalidation, not an unrelated fixture error', + await harness.queue.getDurableEntry('thread-1', admitted.entry.id), + null, + JSON.stringify(errorLog(harness)), ); - assert.equal( - queueDeps.router.routeExecution.mock.calls.some((call) => call.arguments[1]?.includes(recalledSource.content)), - false, + assert.equal((await harness.messageStore.getById(admitted.message.id)).deliveryStatus, 'delivered'); + const processingProjection = harness.socketManager.emitToUser.mock.calls.find( + (call) => call.arguments[1] === 'queue_updated' && call.arguments[2]?.action === 'processing', + ); + assert.ok(processingProjection, 'provider admission must publish a processing projection'); + assert.deepEqual( + processingProjection.arguments[2].queue, + [], + 'History admission and Queue retirement must reach the browser in the same projection', ); }); - it('ADR-042 claims an exact supplement, rebuilds its prompt, and enforces read-only routing', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original', - originalMessageId: 'msg-original', - userId: 'u1', - threadId: 't1', - catId: 'opus', - requiredMessageIds: ['msg-update'], - requiredFrontierMessageId: 'msg-update', - replayUnsafeToolNames: ['mcp__cat-cafe__cat_cafe_hold_ball'], - now: 100, - }); - const routeCalls = []; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - deliveryCursorStore: { ackSeenCursor: mock.fn(async () => {}) }, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: id === 'msg-original' ? 'opus' : null, - content: id === 'msg-original' ? 'published answer' : 'late correction', - mentions: [], - timestamp: 100, - })), - }, - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push(args); - await closureStore.commitSupplement(offered.supplement.id, { - invocationId: 'inv-stub', - messageId: 'msg-supplement', - now: 200, - }); - args[6].persistenceContext.outputCommitDecisions = { - opus: { - kind: 'committed_fresh', - messageId: 'msg-supplement', - freshnessSupplementId: offered.supplement.id, - }, - }; - yield { type: 'text', catId: 'opus', content: 'concise supplement', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), + it('starts every idle target of one source before either target completes', async () => { + let releaseInvocations; + const release = new Promise((resolve) => { + releaseInvocations = resolve; + }); + const startedTargets = []; + const harness = createHarness({ + routeExecution: async function* (...args) { + const [userId, , threadId, , targetCats, , options] = args; + assert.deepEqual(targetCats, ['opus', 'codex']); + assert.equal(options.targetDispatchMode, 'parallel'); + await Promise.all( + targetCats.map(async (catId) => { + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId, + invocationId: `turn-parallel-${catId}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); + startedTargets.push(catId); + }), + ); + await release; + for (const catId of targetCats) { + yield { type: 'done', catId, isFinal: true, timestamp: Date.now() }; + } }, }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, - }); + const admitted = await admitMessage(harness, { targetCats: ['opus', 'codex'] }); + + await harness.processor.requestDrain('thread-1'); + try { + await waitFor(() => startedTargets.length === 2, 250).catch((error) => { + error.message += `: ${JSON.stringify(errorLog(harness))}`; + throw error; + }); + assert.deepEqual(new Set(startedTargets), new Set(['opus', 'codex'])); + assert.deepEqual( + harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), + null, + 'both target claims should retire before either provider completes', + ); + } finally { + releaseInvocations(); + } + }); - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); + it('keeps an exact target set queued when one sibling is busy', async () => { + const harness = createHarness(); + bindActiveRun(harness, { catId: 'opus', invocationId: 'turn-busy-opus' }); + const admitted = await admitMessage(harness, { targetCats: ['opus', 'codex'] }); + const before = structuredClone(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id)); - assert.equal(result.status, 'succeeded'); - assert.equal(routeCalls.length, 1); - assert.match(routeCalls[0][1], /published answer/); - assert.match(routeCalls[0][1], /late correction/); - assert.equal(routeCalls[0][6].freshnessSupplementId, offered.supplement.id); - assert.deepEqual(routeCalls[0][6].toolExecutionPolicy, { - mode: 'read_only', - replayDeniedToolNames: ['mcp__cat-cafe__cat_cafe_hold_ball'], - }); - assert.equal((await closureStore.getSupplement(offered.supplement.id)).status, 'committed'); + const started = await harness.processor.processNext('thread-1', 'user-1'); + + assert.equal(started.started, false); + assert.equal(harness.router.routeExecution.mock.calls.length, 0); + assert.deepEqual(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), before); }); - it('ADR-042 refreshes a pending supplement with current published updates before claim', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const messageStore = new MessageStore(); - const original = await messageStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'opus', - content: 'published answer', - mentions: [], - timestamp: 100, - origin: 'stream', - }); - const firstUpdate = await messageStore.append({ - userId: 'u1', - threadId: 't1', - catId: null, - content: 'first late correction', - mentions: ['opus'], - timestamp: 110, + it('starts the next source only after the prior source completes durable target handoff', async () => { + let releaseFirstAdmission; + const firstAdmission = new Promise((resolve) => { + releaseFirstAdmission = resolve; }); - const offered = await closureStore.offerSupplement({ - lineageId: original.id, - originalMessageId: original.id, - userId: 'u1', - threadId: 't1', - catId: 'opus', - requiredMessageIds: [firstUpdate.id], - requiredFrontierMessageId: firstUpdate.id, - now: 120, - }); - const currentRelevant = await messageStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'sonnet', - content: 'current published review update', - mentions: ['opus'], - timestamp: 130, - origin: 'stream', - deliveryStatus: 'queued', - }); - await messageStore.append({ - userId: 'u1', - threadId: 't1', - catId: null, - content: 'directed away', - mentions: ['fable5'], - timestamp: 140, - }); - await messageStore.append({ - userId: 'u1', - threadId: 't1', - catId: null, - content: 'ordinary queued work', - mentions: ['opus'], - timestamp: 150, - deliveryStatus: 'queued', - }); - await messageStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'opus', - content: 'self output', - mentions: [], - timestamp: 160, - origin: 'stream', + let releaseInvocations; + const release = new Promise((resolve) => { + releaseInvocations = resolve; }); - await messageStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'sonnet', - content: 'same lineage supplement', - mentions: ['opus'], - timestamp: 170, - origin: 'stream', - extra: { - supplement: { - lineageId: original.id, - supplementId: `f254-supplement:${original.id}:1`, - seq: 1, - originalMessageId: original.id, - }, + const routeOrder = []; + const harness = createHarness({ + routeExecution: async function* (...args) { + const [userId, content, threadId, , targetCats, , options] = args; + routeOrder.push({ content, targetCats: [...targetCats] }); + if (content === 'source-one') await firstAdmission; + await Promise.all( + targetCats.map((catId) => + options.onLifecycleInvocationStarted({ + threadId, + userId, + catId, + invocationId: `turn-${content}-${catId}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }), + ), + ); + await release; + for (const catId of targetCats) { + yield { type: 'done', catId, isFinal: true, timestamp: Date.now() }; + } }, }); - const routeCalls = []; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore, - deliveryCursorStore: { ackSeenCursor: mock.fn(async () => {}) }, - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push(args); - await closureStore.commitSupplement(offered.supplement.id, { - invocationId: 'inv-stub', - messageId: 'msg-supplement', - now: 200, - }); - args[6].persistenceContext.outputCommitDecisions = { - opus: { - kind: 'committed_fresh', - messageId: 'msg-supplement', - freshnessSupplementId: offered.supplement.id, - }, - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, + await admitMessage(harness, { content: 'source-one', targetCats: ['opus', 'codex'] }); + await admitMessage(harness, { content: 'source-two', targetCats: ['gemini'] }); + + await harness.processor.requestDrain('thread-1'); + try { + await waitFor(() => routeOrder.length === 1, 250); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.deepEqual(routeOrder, [{ content: 'source-one', targetCats: ['opus', 'codex'] }]); + releaseFirstAdmission(); + await waitFor(() => routeOrder.length === 2, 250); + assert.deepEqual(routeOrder, [ + { content: 'source-one', targetCats: ['opus', 'codex'] }, + { content: 'source-two', targetCats: ['gemini'] }, + ]); + } finally { + releaseFirstAdmission(); + releaseInvocations(); + } + }); + + it('treats a blocked try-drain as a no-op and retries from the active target terminal', async () => { + let releaseFirst; + const firstRun = new Promise((resolve) => { + releaseFirst = resolve; }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + const calls = []; + const harness = createHarness({ + routeExecution: async function* (...args) { + calls.push(args); + await startLifecycle(args, `turn-retry-${calls.length}`); + if (calls.length === 1) await firstRun; + yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; + }, }); + const first = await admitMessage(harness, { content: 'first same-target source' }); + const second = await admitMessage(harness, { content: 'second same-target source' }); + + await harness.processor.requestDrain('thread-1'); + await waitFor(() => calls.length === 1); + await harness.processor.requestDrain('thread-1'); + await harness.processor.requestDrain('thread-1'); + await new Promise((resolve) => setTimeout(resolve, 25)); - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); + assert.equal(calls.length, 1); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', first.entry.id), null); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', second.entry.id)?.status, 'queued'); - assert.equal(result.status, 'succeeded'); - assert.equal(routeCalls.length, 1); - const prompt = routeCalls[0][1]; - assert.match(prompt, /first late correction/); - assert.match(prompt, /current published review update/); - assert.doesNotMatch(prompt, /directed away/); - assert.doesNotMatch(prompt, /ordinary queued work/); - assert.doesNotMatch(prompt, /self output/); - assert.doesNotMatch(prompt, /same lineage supplement/); - const terminal = await closureStore.getSupplement(offered.supplement.id); - assert.deepEqual(terminal.requiredMessageIds, [firstUpdate.id, currentRelevant.id]); - assert.equal(terminal.seq, 1); - assert.equal((await closureStore.listSupplementsByLineage(original.id)).length, 1); + releaseFirst(); + await waitFor(() => calls.length === 2); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', second.entry.id) === null); }); - it('ADR-042 fails closed before model launch when supplement preflight cannot advance', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original-preflight-blocked', - originalMessageId: 'msg-original-preflight-blocked', - userId: 'u1', - threadId: 't1', - catId: 'opus', - requiredMessageIds: ['msg-frontier-stuck'], - requiredFrontierMessageId: 'msg-frontier-stuck', - now: 100, + it('restores a claimed target when routing fails before the response receiver exists', async () => { + let atRouter; + let harness; + harness = createHarness({ + routeExecution: async function* () { + atRouter = { + queue: harness.queue.list('thread-1', 'user-1'), + history: harness.messageStore.getByThread('thread-1', 50, 'user-1'), + }; + throw new Error('injected before lifecycle receiver'); + }, }); - const routeExecution = mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + const admitted = await admitMessage(harness); + + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor( + () => + harness.invocationRecordStore.records.get('inv-1')?.status === 'failed' && + !harness.invocationTracker.has('thread-1'), + ); + + assert.equal(atRouter.queue.length, 1, 'the short claim must still own the target at the router boundary'); + assert.equal(atRouter.queue[0].status, 'claimed'); + assert.equal( + atRouter.history.some((message) => message.lifecycle?.kind === 'response'), + false, + ); + const restored = harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id); + assert.equal(restored.status, 'queued'); + assert.deepEqual(restored.targets, ['opus']); + assert.equal((await harness.messageStore.getById(admitted.message.id)).lifecycle.dispatchRefs.length, 0); + }); + + it('keeps the History receiver when Queue retirement loses its acknowledgement', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness); + const originalRetire = harness.queue.retireClaimedLifecycleTarget.bind(harness.queue); + let injected = false; + harness.queue.retireClaimedLifecycleTarget = mock.fn(async (...args) => { + if (!injected) { + injected = true; + await originalRetire(...args); + throw new Error('injected Queue retirement acknowledgement loss'); + } + return originalRetire(...args); }); - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getByThreadAfter: mock.fn(async () => [ - { - id: 'msg-frontier-stuck', - userId: 'u1', - threadId: 't1', - catId: null, - content: 'non-advancing page', - mentions: ['opus'], - timestamp: 110, - }, - ]), - }, - router: { - routeExecution, - ackCollectedCursors: mock.fn(async () => {}), + + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor( + () => + harness.invocationRecordStore.records.get('inv-1')?.status === 'failed' && + !harness.invocationTracker.has('thread-1'), + ); + + const source = await harness.messageStore.getById(admitted.message.id); + assert.deepEqual( + source.lifecycle.dispatchRefs.map((ref) => [ref.targetId, ref.phase]), + [['opus', 'settled']], + ); + const response = await harness.messageStore.getById(source.lifecycle.dispatchRefs[0].statusMessageId); + assert.equal(response.lifecycle.kind, 'response'); + assert.equal(response.lifecycle.status, 'interrupted'); + assert.equal(response.lifecycle.reason, 'queue_target_retirement_pending'); + assert.equal(await harness.queue.getDurableEntry('thread-1', admitted.entry.id), null); + }); + + it('restores only the target whose parallel admission failed before its dispatchRef committed', async () => { + let harness; + harness = createHarness({ + routeExecution: async function* (...args) { + const [userId, , threadId, , targetCats, , options] = args; + harness.processor.suppressAutoResume(threadId, targetCats[0], [options.parentInvocationId]); + const admissions = await Promise.allSettled( + targetCats.map((catId) => + options.onLifecycleInvocationStarted({ + threadId, + userId, + catId, + invocationId: `turn-partial-admission-${catId}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }), + ), + ); + for (const [index, admission] of admissions.entries()) { + const catId = targetCats[index]; + if (admission.status === 'fulfilled') { + const terminal = await harness.messageStore.commitLifecycleResponseTerminal( + admission.value.responseMessageId, + { + invocationId: `turn-partial-admission-${catId}`, + status: 'completed', + completedAt: Date.now(), + content: `${catId} completed`, + mentions: [], + origin: 'stream', + }, + ); + assert.ok(terminal.kind === 'applied' || terminal.kind === 'replayed'); + await settleLifecycleResponseInputs( + harness.messageStore, + terminal.message, + admission.value.responseMessageId, + ); + yield { type: 'done', catId, isFinal: true, timestamp: Date.now() }; + } else { + yield { + type: 'error', + catId, + error: admission.reason, + errorDisposition: 'terminal', + timestamp: Date.now(), + }; + } + } }, }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + const admitted = await admitMessage(harness, { targetCats: ['opus', 'codex'] }); + const originalAdvance = harness.messageStore.advanceLifecycleInputDispatch.bind(harness.messageStore); + harness.messageStore.advanceLifecycleInputDispatch = mock.fn(async (messageId, patch) => + patch.targetId === 'codex' + ? { kind: 'conflict', reason: 'injected_parallel_admission_conflict' } + : originalAdvance(messageId, patch), + ); + + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor( + () => + !harness.invocationTracker.has('thread-1') && + harness.messageStore + .getByThread('thread-1', 50, 'user-1') + .some( + (message) => + message.lifecycle?.kind === 'response' && + message.lifecycle.targetId === 'opus' && + message.lifecycle.status === 'completed', + ), + ); + assert.equal(harness.invocationRecordStore.records.get('inv-1')?.status, 'succeeded'); + + const source = await harness.messageStore.getById(admitted.message.id); + assert.deepEqual( + source.lifecycle.dispatchRefs.map((ref) => [ref.targetId, ref.phase]), + [['opus', 'settled']], + ); + const remaining = await harness.queue.getDurableEntry('thread-1', admitted.entry.id); + assert.equal(remaining.status, 'queued'); + assert.deepEqual(remaining.targets, ['codex']); + const responses = harness.messageStore + .getByThread('thread-1', 50, 'user-1') + .filter((message) => message.lifecycle?.kind === 'response'); + assert.equal(responses.length, 2); + const responseByTarget = new Map(responses.map((message) => [message.lifecycle.targetId, message])); + assert.equal(responseByTarget.get('opus').lifecycle.status, 'completed'); + assert.equal(responseByTarget.get('codex').lifecycle.status, 'interrupted'); + assert.equal(responseByTarget.get('codex').lifecycle.reason, 'input_dispatch_projection_conflict'); + }); + + it('records child awakening and prompt exposure only on the process-local attempt', async () => { + const childInvocationId = 'turn-receipt-evidence'; + const startedAt = Date.now(); + let harness; + let observedAttempt; + let observedEvidence; + harness = createHarness({ + routeExecution: async function* (...args) { + const [userId, , threadId, messageId, targetCats, , options] = args; + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: childInvocationId, + parentInvocationId: options.parentInvocationId, + startedAt, + }); + await options.onPromptMessagesExposed({ + threadId, + userId, + catId: targetCats[0], + invocationId: childInvocationId, + messageIds: [messageId], + seenAt: startedAt + 1, + }); + observedAttempt = harness.queue.findProcessingByCat(threadId, targetCats[0]); + observedEvidence = harness.queue.getAdmittedAttemptEvidence( + threadId, + observedAttempt.id, + targetCats[0], + userId, + ); + yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: startedAt + 2 }; + }, }); + const admitted = await admitMessage(harness); - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id) === null); - assert.equal(result.status, 'failed'); - assert.equal(routeExecution.mock.calls.length, 0); - assert.equal((await closureStore.getSupplement(offered.supplement.id)).status, 'failed'); + assert.equal(await harness.queue.getDurableEntry('thread-1', admitted.entry.id), null); + assert.equal(observedAttempt.status, 'processing'); + assert.equal(observedEvidence.awakenedInvocationId, childInvocationId); + assert.equal(observedEvidence.awakenedAt, startedAt); + assert.equal(observedEvidence.seenInvocationId, childInvocationId); + assert.equal(observedEvidence.seenAt, startedAt + 1); + assert.equal('bodyExposures' in observedAttempt.delivery, false); }); - it('ADR-042 fails a supplement before model launch when the hard read-only carrier is absent', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original', - originalMessageId: 'msg-original', - userId: 'u1', - threadId: 't1', - catId: 'opus', - requiredMessageIds: ['msg-update'], - requiredFrontierMessageId: 'msg-update', - now: 100, - }); - const customDeps = stubDeps({ freshnessClosureStore: closureStore }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - }); + it('adopts an exactly exposed target into the current response and leaves sibling targets queued', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness, { targetCats: ['opus', 'codex'] }); + const sourceEntry = admitted.entry; - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); + const { invocationId, response, startedAt } = bindActiveRun(harness); - assert.equal(result.status, 'failed'); - assert.equal(customDeps.router.routeExecution.mock.calls.length, 0); - const failed = await closureStore.getSupplement(offered.supplement.id); - assert.equal(failed.status, 'failed'); - assert.equal(failed.failureReason, 'read_only_policy_unavailable'); + const result = await harness.processor.adoptExposedQueuedEntries({ + threadId: 'thread-1', + userId: 'user-1', + catId: 'opus', + invocationId, + entries: [{ entryId: sourceEntry.id, messageId: admitted.message.id }], + seenAt: startedAt + 1, + }); + + assert.deepEqual(result, { outcome: 'adopted', adoptedEntryIds: [sourceEntry.id] }); + const durable = await harness.queue.getDurableEntry('thread-1', sourceEntry.id); + assert.equal(durable.status, 'queued'); + assert.deepEqual(durable.targets, ['codex']); + assert.equal(durable.delivery.seenInvocationId, undefined); + assert.equal('bodyExposures' in durable.delivery, false); + + const source = await harness.messageStore.getById(admitted.message.id); + assert.equal(source.deliveryStatus, 'delivered'); + assert.equal(source.lifecycle.dispatchRefs.length, 1); + assert.equal(source.lifecycle.dispatchRefs[0].targetId, 'opus'); + assert.equal(source.lifecycle.dispatchRefs[0].phase, 'dispatched'); + assert.equal(source.lifecycle.dispatchRefs[0].statusMessageId, response.id); + assert.equal(source.lifecycle.dispatchRefs[0].dispatchedAt, startedAt + 1); + assert.deepEqual((await harness.messageStore.getById(response.id)).lifecycle.inputMessageIds, [ + admitted.message.id, + ]); + assert.deepEqual(harness.invocationTracker.getActiveSlots('thread-1')[0].activeRun.inputEntryIds, [sourceEntry.id]); + assert.deepEqual(harness.invocationTracker.getActiveSlots('thread-1')[0].activeRun.inputMessageIds, [ + admitted.message.id, + ]); }); - it('AC-E18 consumes a policy-failed supplement carrier after its durable terminal and never pauses Queue', async () => { - const { ToolExecutionPolicyUnavailableError } = await import( - '../dist/domains/cats/services/agents/invocation/tool-execution-policy.js' - ); - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original-policy-failure', - originalMessageId: 'msg-original-policy-failure', - userId: 'u1', - threadId: 't1', + it('settles multi-target Append independently when one provider accepts and its sibling rejects', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness, { targetCats: ['opus', 'codex'] }); + const opus = bindActiveRun(harness, { catId: 'opus', - requiredMessageIds: ['msg-update-policy-failure'], - requiredFrontierMessageId: 'msg-update-policy-failure', - now: 100, - }); - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: id === 'msg-original-policy-failure' ? 'opus' : null, - content: id === 'msg-original-policy-failure' ? 'published answer' : 'late update', - mentions: [], - timestamp: 100, - })), - }, - router: { - routeExecution: mock.fn(async function* () { - throw new ToolExecutionPolicyUnavailableError(); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, + invocationId: 'turn-append-opus', + parentInvocationId: 'parent-opus', }); - const customProcessor = new QueueProcessor(customDeps); - let completionArgs; - const completeInvocation = customProcessor.onInvocationComplete.bind(customProcessor); - customProcessor.onInvocationComplete = async (...args) => { - await completeInvocation(...args); - completionArgs = args; - }; - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + const codex = bindActiveRun(harness, { + catId: 'codex', + invocationId: 'turn-append-codex', + parentInvocationId: 'parent-codex', }); + const opusDispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const codexDispatch = mock.fn(async () => ({ accepted: false, reason: 'provider_rejected' })); + assert.ok( + harness.invocationTracker.bindAgentClientActiveRunDispatcher('thread-1', 'opus', { + invocationId: opus.invocationId, + capabilities: { append: true, steer: true }, + handle: { provider: 'anthropic', carrier: 'claude_print_sdk' }, + dispatch: opusDispatch, + }), + ); + assert.ok( + harness.invocationTracker.bindAgentClientActiveRunDispatcher('thread-1', 'codex', { + invocationId: codex.invocationId, + capabilities: { append: true, steer: true }, + handle: { provider: 'openai_codex', carrier: 'codex_app_server' }, + dispatch: codexDispatch, + }), + ); - await customProcessor.tryAutoExecute('t1', { bypassNonAgentGate: true }); - await waitFor(() => completionArgs !== undefined, 1_000); - - assert.equal(completionArgs[2], 'failed'); - assert.equal(Boolean(completionArgs[5]), false, 'durable supplement terminal owns the failed carrier'); - const failed = await closureStore.getSupplement(offered.supplement.id); - assert.equal(failed.status, 'failed'); - assert.equal(failed.failureReason, 'read_only_policy_unavailable'); - assert.equal( - customDeps.queue.list('t1', 'u1').length, - 0, - 'terminal supplement must not remain retryable Queue work', + const expectedRuns = [ + { targetId: 'opus', invocationId: opus.invocationId, responseMessageId: opus.response.id }, + { targetId: 'codex', invocationId: codex.invocationId, responseMessageId: codex.response.id }, + ]; + assert.deepEqual( + await harness.processor.appendExactEntry({ + threadId: 'thread-1', + userId: 'user-1', + entryId: admitted.entry.id, + expectedQueueRevision: 'stale-revision', + expectedRuns, + }), + { outcome: 'rejected', reason: 'state_changed' }, + ); + assert.equal(opusDispatch.mock.calls.length, 0); + assert.equal(codexDispatch.mock.calls.length, 0); + + const result = await harness.processor.appendExactEntry({ + threadId: 'thread-1', + userId: 'user-1', + entryId: admitted.entry.id, + expectedQueueRevision: harness.queue.snapshotRevision('thread-1', 'user-1'), + expectedRuns, + }); + + assert.equal(result.outcome, 'appended'); + assert.deepEqual(result.acceptedTargetIds, ['opus']); + assert.deepEqual(result.rejectedTargetIds, ['codex']); + assert.equal(opusDispatch.mock.calls.length, 1); + assert.equal(codexDispatch.mock.calls.length, 1); + assert.equal(await harness.queue.getDurableEntry('thread-1', admitted.entry.id), null); + assert.deepEqual( + (await harness.messageStore.getById(admitted.message.id)).lifecycle.dispatchRefs.map((ref) => [ + ref.targetId, + ref.phase, + ]), + [ + ['opus', 'dispatched'], + ['codex', 'settled'], + ], ); - assert.equal(customProcessor.isPaused('t1', 'opus'), false, 'an empty terminal carrier must not pause the slot'); - assert.equal(customDeps.router.routeExecution.mock.calls.length, 1); }); - it('AC-E18 keeps the supplement carrier queued when durable failure terminalization itself fails', async () => { - const { ToolExecutionPolicyUnavailableError } = await import( - '../dist/domains/cats/services/agents/invocation/tool-execution-policy.js' - ); - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original-terminal-write-failure', - originalMessageId: 'msg-original-terminal-write-failure', - userId: 'u1', - threadId: 't1', + it('restores the exact row without attaching it when History publication fails', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness); + const { invocationId, response, startedAt } = bindActiveRun(harness); + const markDelivered = mock.method(harness.messageStore, 'markDelivered', () => null); + + const result = await harness.processor.adoptExposedQueuedEntries({ + threadId: 'thread-1', + userId: 'user-1', catId: 'opus', - requiredMessageIds: ['msg-update-terminal-write-failure'], - requiredFrontierMessageId: 'msg-update-terminal-write-failure', - now: 100, - }); - closureStore.failSupplement = mock.fn(async () => { - throw new Error('supplement terminal store unavailable'); - }); - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: id === 'msg-original-terminal-write-failure' ? 'opus' : null, - content: id === 'msg-original-terminal-write-failure' ? 'published answer' : 'late update', - mentions: [], - timestamp: 100, - })), - }, - router: { - routeExecution: mock.fn(async function* () { - throw new ToolExecutionPolicyUnavailableError(); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, + invocationId, + entries: [{ entryId: admitted.entry.id, messageId: admitted.message.id }], + seenAt: startedAt + 1, }); - const customProcessor = new QueueProcessor(customDeps); - const entry = enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, + markDelivered.mock.restore(); + + assert.deepEqual(result, { + outcome: 'rejected', + reason: 'persistence_unavailable', + entryId: admitted.entry.id, }); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id)?.status, 'queued'); + assert.equal((await harness.messageStore.getById(admitted.message.id)).deliveryStatus, 'queued'); + assert.deepEqual((await harness.messageStore.getById(response.id)).lifecycle.inputMessageIds, []); + assert.deepEqual(harness.invocationTracker.getActiveSlots('thread-1')[0].activeRun.inputMessageIds, []); + }); - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); + it('keeps lifecycle-owned adoption outside Queue when target removal persistence must fail closed', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness); + const { invocationId, response, startedAt } = bindActiveRun(harness); + const commitExposure = mock.method(harness.queue, 'commitClaimedAdoptionDurable', async () => null); + const terminalize = mock.method(harness.queue, 'removeProcessedDurable', async () => null); - assert.equal(result.status, 'failed'); - assert.equal(result.primaryEntryRequeued, true); - assert.equal((await closureStore.getSupplement(offered.supplement.id)).status, 'running'); - assert.equal(customDeps.queue.getEntrySnapshot('t1', 'u1', entry.id).status, 'queued'); + const result = await harness.processor.adoptExposedQueuedEntries({ + threadId: 'thread-1', + userId: 'user-1', + catId: 'opus', + invocationId, + entries: [{ entryId: admitted.entry.id, messageId: admitted.message.id }], + seenAt: startedAt + 1, + }); + commitExposure.mock.restore(); + terminalize.mock.restore(); + + assert.deepEqual(result, { + outcome: 'rejected', + reason: 'persistence_unavailable', + entryId: admitted.entry.id, + }); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); + assert.equal(harness.queue.findProcessingByCat('thread-1', 'opus')?.status, 'processing'); + assert.equal((await harness.messageStore.getById(admitted.message.id)).deliveryStatus, 'delivered'); + assert.deepEqual((await harness.messageStore.getById(response.id)).lifecycle.inputMessageIds, [ + admitted.message.id, + ]); }); - it('persists only the exact successful cats before shared-invocation cleanup', async () => { - const sharedDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), + it('admits consecutive FIFO sources separately without concatenating bodies', async () => { + const calls = []; + const harness = createHarness({ + routeExecution: async function* (...args) { + calls.push(args); + await startLifecycle(args, `turn-fifo-source-${calls.length}`); + yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; }, }); - const sharedProcessor = new QueueProcessor(sharedDeps); - enqueueEntry(sharedDeps.queue, { targetCats: ['opus', 'codex'] }); + const first = await admitMessage(harness, { content: 'first author body' }); + const second = await admitMessage(harness, { content: 'second author body' }); - const result = await sharedProcessor.executeEntry(sharedDeps.queue.markProcessing('t1', 'u1')); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.list('thread-1', 'user-1').length === 0); - assert.equal(result.status, 'succeeded'); - assert.deepEqual(result.successfulCatIds, ['opus']); - const succeededUpdate = sharedDeps.invocationRecordStore.update.mock.calls.find( - (call) => call.arguments[1]?.status === 'succeeded', + assert.equal(calls.length, 2, JSON.stringify(errorLog(harness))); + assert.deepEqual( + calls.map(([userId, content, threadId, messageId, targetCats, , options]) => ({ + userId, + content, + threadId, + messageId, + targetCats, + promptMessages: options.persistedPromptMessages.map((message) => ({ + messageId: message.messageId, + content: message.content, + })), + })), + [ + { + userId: 'user-1', + content: 'first author body', + threadId: 'thread-1', + messageId: first.message.id, + targetCats: ['opus'], + promptMessages: [{ messageId: first.message.id, content: 'first author body' }], + }, + { + userId: 'user-1', + content: 'second author body', + threadId: 'thread-1', + messageId: second.message.id, + targetCats: ['opus'], + promptMessages: [{ messageId: second.message.id, content: 'second author body' }], + }, + ], ); - assert.ok(succeededUpdate, 'expected a durable succeeded update'); - assert.deepEqual(succeededUpdate.arguments[1].successfulCatIds, ['opus']); }); - it('does not persist a canceled cat whose route ends with a bare done', async () => { - const invocationTracker = new InvocationTracker(); - const sharedDeps = stubDeps({ - invocationTracker, - router: { - routeExecution: mock.fn(async function* () { - assert.equal(invocationTracker.cancel('t1', 'codex', 'u1', 'user_cancel').cancelled, true); - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), + it('keeps provider failure out of Queue after admission', async () => { + const harness = createHarness({ + routeExecution: async function* (...args) { + await startLifecycle(args, 'turn-provider-failure'); + throw new Error('provider failed'); }, }); - const sharedProcessor = new QueueProcessor(sharedDeps); - enqueueEntry(sharedDeps.queue, { targetCats: ['opus', 'codex'] }); + const admitted = await admitMessage(harness); - const result = await sharedProcessor.executeEntry(sharedDeps.queue.markProcessing('t1', 'u1')); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id) === null); - assert.equal(result.status, 'succeeded'); - assert.deepEqual(result.successfulCatIds, ['opus']); - const succeededUpdate = sharedDeps.invocationRecordStore.update.mock.calls.find( - (call) => call.arguments[1]?.status === 'succeeded', - ); - assert.ok(succeededUpdate, 'expected a durable succeeded update'); - assert.deepEqual(succeededUpdate.arguments[1].successfulCatIds, ['opus']); + assert.equal(await harness.queue.getDurableEntry('thread-1', admitted.entry.id), null); }); - it('does not persist a cat whose terminal error is followed by a bare done', async () => { - const sharedDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'error', catId: 'codex', error: 'stream failed', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), + it('does not enqueue a runtime-replacement continuation after the recovered turn completes', async () => { + const capsule = { + v: 1, + threadId: 'thread-1', + catId: 'opus', + invocationId: 'turn-recovered', + mode: 'independent', + a2aEnabled: true, + ballState: 'in_progress', + continuationReason: 'runtime_replacement', + createdAt: 2_000, + seal: { sessionId: 'session-old', sessionSeq: 2, reason: 'cli_session_replaced' }, + replacement: { + cause: 'active_writer_reborn', + previousNativeThreadId: 'native-old', + detectedAt: 1_900, + attempt: 1, + diagnostics: { + observedAt: 1_900, + classification: 'native_active_turn_without_local_lease', + confidence: 'medium', + localHostLease: { state: 'not_observed', source: 'carrier_affinity' }, + nativeThread: { + readOutcome: 'succeeded', + threadId: 'native-old', + status: 'active', + activeTurn: { turnId: 'turn-old', startedAt: 1_800 }, + }, + writerClientIdentity: 'unavailable', + }, + }, + }; + let routeInvocations = 0; + const harness = createHarness({ + routeExecution: async function* (...args) { + routeInvocations++; + await startLifecycle(args, `turn-runtime-replacement-${routeInvocations}`); + if (routeInvocations === 1) { + yield { + type: 'system_info', + catId: 'opus', + content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), + timestamp: 2_000, + }; + } + yield { type: 'done', catId: 'opus', isFinal: true, timestamp: 2_001 }; }, }); - const sharedProcessor = new QueueProcessor(sharedDeps); - enqueueEntry(sharedDeps.queue, { targetCats: ['opus', 'codex'] }); + const admitted = await admitMessage(harness); - const result = await sharedProcessor.executeEntry(sharedDeps.queue.markProcessing('t1', 'u1')); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id) === null); - assert.equal(result.status, 'succeeded'); - assert.deepEqual(result.successfulCatIds, ['opus']); - const succeededUpdate = sharedDeps.invocationRecordStore.update.mock.calls.find( - (call) => call.arguments[1]?.status === 'succeeded', - ); - assert.ok(succeededUpdate, 'expected a durable succeeded update'); - assert.deepEqual(succeededUpdate.arguments[1].successfulCatIds, ['opus']); + assert.deepEqual(harness.queue.list('thread-1', 'user-1'), []); + assert.equal(routeInvocations, 1, 'a completed recovery attempt must not create a second provider turn'); }); - it('preserves the primary child error when every target fails', async () => { - const sharedDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { - type: 'error', - catId: 'codex', - error: 'queued_prompt_exposure_rejected:message-same-thread', - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), + it('keeps a canceled admitted attempt out of Queue instead of rolling it back behind later work', async () => { + let releaseProvider; + let providerStarted; + const providerStartedPromise = new Promise((resolve) => { + providerStarted = resolve; + }); + const harness = createHarness({ + routeExecution: async function* (...args) { + await startLifecycle(args, 'turn-canceled-admitted'); + providerStarted(); + await new Promise((resolve) => { + releaseProvider = resolve; + }); }, }); - const sharedProcessor = new QueueProcessor(sharedDeps); - enqueueEntry(sharedDeps.queue, { targetCats: ['codex'], source: 'agent', sourceCategory: 'a2a' }); + const first = await admitMessage(harness, { content: 'first' }); + const second = await admitMessage(harness, { content: 'second', targetCats: ['codex'] }); - const result = await sharedProcessor.executeEntry(sharedDeps.queue.markProcessing('t1', 'u1')); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await providerStartedPromise; + harness.invocationTracker.cancel('thread-1', 'opus', 'user-1', 'preempted'); + releaseProvider(); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', first.entry.id) === null); - assert.equal(result.status, 'failed'); - assert.deepEqual(result.successfulCatIds, []); - const failedUpdate = sharedDeps.invocationRecordStore.update.mock.calls.find( - (call) => call.arguments[1]?.status === 'failed', - ); - assert.ok(failedUpdate, 'expected the terminal child failure to be durable'); - assert.equal(failedUpdate.arguments[1].error, 'queued_prompt_exposure_rejected:message-same-thread'); + assert.equal(await harness.queue.getDurableEntry('thread-1', first.entry.id), null); assert.equal( - sharedDeps.invocationRecordStore.update.mock.calls.some((call) => call.arguments[1]?.status === 'succeeded'), + harness.queue.list('thread-1', 'user-1').some((entry) => entry.id === first.entry.id), false, ); + assert.equal( + harness.queue.list('thread-1', 'user-1').some((entry) => entry.id === second.entry.id), + true, + ); }); - it('ADR-042 persists decline while suppressing the marker from the visible stream', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original', - originalMessageId: 'msg-original', - userId: 'u1', - threadId: 't1', - catId: 'opus', - requiredMessageIds: ['msg-update'], - requiredFrontierMessageId: 'msg-update', - now: 100, - }); - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: id === 'msg-original' ? 'opus' : null, - content: id === 'msg-original' ? 'published answer' : 'thanks', - mentions: [], - timestamp: 100, - })), + it('commits an exact Steer target out of Queue before provider execution', async () => { + let harness; + let observedStatus; + harness = createHarness({ + routeExecution: async function* (...args) { + await startLifecycle(args, 'turn-exact-steer'); + observedStatus = harness.queue.findProcessingByCat('thread-1', 'opus')?.status; + yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; }, - router: { - routeExecution: mock.fn(async function* (...args) { - await closureStore.declineSupplement(offered.supplement.id, { - invocationId: 'inv-stub', - now: 200, - }); - args[6].persistenceContext.outputCommitDecisions = { - opus: { kind: 'supplement_declined', freshnessSupplementId: offered.supplement.id }, - }; - yield { type: 'text', catId: 'opus', content: '', timestamp: 200 }; - yield { type: 'done', catId: 'opus', timestamp: 201 }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, - }); - - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); - - assert.equal(result.status, 'succeeded'); - assert.equal((await closureStore.getSupplement(offered.supplement.id)).status, 'declined'); - const visibleText = customDeps.socketManager.broadcastAgentMessage.mock.calls - .map((call) => call.arguments[0]) - .filter((message) => message.type === 'text'); - assert.deepEqual(visibleText, []); - }); - - it('ADR-042 terminalizes a pending supplement when its queued carrier is removed', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original', - originalMessageId: 'msg-original', - userId: 'u1', - threadId: 't1', - catId: 'opus', - requiredMessageIds: ['msg-update'], - requiredFrontierMessageId: 'msg-update', - now: 100, }); - const customDeps = stubDeps({ freshnessClosureStore: closureStore }); - const customProcessor = new QueueProcessor(customDeps); + const admitted = await admitMessage(harness); + const claim = await harness.queue.claimExactSteerEntryDurable('thread-1', 'user-1', admitted.entry.id, 'opus'); + assert.equal(claim.outcome, 'claimed'); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'claimed'); - await customProcessor.finalizeRemovedEntry({ freshnessSupplementId: offered.supplement.id }, 'user_cancel'); - - const failed = await closureStore.getSupplement(offered.supplement.id); - assert.equal(failed.status, 'failed'); - assert.equal(failed.failureReason, 'user_cancel'); - assert.equal(customDeps.socketManager.broadcastAgentMessage.mock.calls.length, 1); + const started = await harness.processor.processClaimedSteerEntries( + 'thread-1', + 'user-1', + [admitted.entry.id], + 'opus', + ); + assert.equal(started.started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id) === null); + assert.equal(observedStatus, 'processing', JSON.stringify(errorLog(harness))); + }); + + it('restores a claimed Steer row in its original place when the target slot is busy', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness); + const claim = await harness.queue.claimExactSteerEntryDurable('thread-1', 'user-1', admitted.entry.id, 'opus'); + assert.equal(claim.outcome, 'claimed'); + harness.invocationTracker.start('thread-1', 'opus', 'user-1'); + + const started = await harness.processor.processClaimedSteerEntries( + 'thread-1', + 'user-1', + [admitted.entry.id], + 'opus', + ); + assert.equal(started.started, false); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'queued'); }); - it('ADR-042 recovers append-before-state supplement commit from the durable idempotent body', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const offered = await closureStore.offerSupplement({ - lineageId: 'msg-original', - originalMessageId: 'msg-original', - userId: 'u1', - threadId: 't1', - catId: 'opus', - requiredMessageIds: ['msg-update'], - requiredFrontierMessageId: 'msg-update', - now: 100, - }); - const durableMessage = { - id: 'msg-durable-supplement', - threadId: 't1', - userId: 'u1', - catId: 'opus', - content: 'durable supplement', - mentions: [], - timestamp: 200, - }; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: id === 'msg-original' ? 'opus' : null, - content: id === 'msg-original' ? 'published answer' : 'late correction', - mentions: [], - timestamp: 100, - })), - getByIdempotencyKey: mock.fn(async (_userId, _threadId, key) => - key === offered.supplement.id ? durableMessage : null, - ), - }, - router: { - routeExecution: mock.fn(async function* (...args) { - args[6].persistenceContext.outputCommitDecisions = { - opus: { - kind: 'committed_degraded_unknown', - messageId: durableMessage.id, - reason: 'supplement_state_commit_failed', - }, - }; - yield { type: 'text', catId: 'opus', content: durableMessage.content, timestamp: 200 }; - yield { type: 'done', catId: 'opus', timestamp: 201 }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - idempotencyKey: offered.supplement.id, - freshnessSupplementId: offered.supplement.id, - freshnessSupplementLineageId: offered.supplement.lineageId, - freshnessSupplementSeq: offered.supplement.seq, - readOnlyToolPolicy: { mode: 'read_only', replayDeniedToolNames: [] }, - }); - - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); + it('removes a public head and publishes failure when explicit routing resolves no target', async () => { + const harness = createHarness(); + harness.router.resolveConversationTargetsAtAdmission.mock.mockImplementation(async () => []); + const admitted = await admitMessage(harness); - assert.equal(result.status, 'succeeded'); - const committed = await closureStore.getSupplement(offered.supplement.id); - assert.equal(committed.status, 'committed'); - assert.equal(committed.committedMessageId, durableMessage.id); - const visibleText = customDeps.socketManager.broadcastAgentMessage.mock.calls - .map((call) => call.arguments[0]) - .filter((message) => message.type === 'text'); - assert.equal(visibleText.length, 1); - const terminalProjections = customDeps.socketManager.broadcastAgentMessage.mock.calls - .map((call) => call.arguments[0]) - .filter((message) => { - if (message.type !== 'system_info' || !message.content.includes('freshness_supplement')) return false; - return JSON.parse(message.content).status === 'committed'; - }); - assert.equal(terminalProjections.length, 1); + const result = await harness.processor.processNext('thread-1', 'user-1'); + assert.equal(result.started, false); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id) === null); + assert.equal(await harness.queue.getDurableEntry('thread-1', admitted.entry.id), null); + const history = harness.messageStore.getByThread('thread-1'); + assert.ok(history.some((message) => message.lifecycle?.kind === 'delivery_failure')); }); - // ── onInvocationComplete ── - - describe('F254 durable queued-user lifecycle', () => { - it('binds exact incrementally exposed queued messages to the active invocation', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const first = enqueueCustodiedEntry(durableDeps.queue, durableStore, { content: 'first queued body' }); - const second = enqueueCustodiedEntry(durableDeps.queue, durableStore, { content: 'second queued body' }); - - await durableProcessor.markPromptMessagesSeen({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'inv-incremental', - messageIds: [first.message.id, second.message.id], - seenAt: 1_234, - }); - - for (const queued of [first, second]) { - const snapshot = durableDeps.queue.getEntrySnapshot('t1', 'u1', queued.entry.id); - assert.deepEqual(snapshot.queuedSeenByCatIds, ['opus']); - assert.deepEqual(snapshot.queuedSeenInvocationIdByCatId, { opus: 'inv-incremental' }); - const stored = durableStore.getById(queued.message.id); - assert.deepEqual(stored.queueCustody.seenByCatIds, ['opus']); - assert.deepEqual(stored.queueCustody.seenInvocationIdByCatId, { opus: 'inv-incremental' }); - assert.deepEqual(stored.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'inv-incremental', seenAt: 1_234 }, - ]); - } - }); - - it('ignores ordinary prompt history while still requiring exact custody exposure witnesses', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const historical = durableStore.append({ - threadId: 't1', - userId: 'u1', - catId: 'codex', - content: 'ordinary published history', - mentions: [], - timestamp: 1_000, - }); - const queued = enqueueCustodiedEntry(durableDeps.queue, durableStore, { content: 'new queued body' }); - - await durableProcessor.markPromptMessagesSeen({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'inv-with-history', - messageIds: [historical.id, queued.message.id], - seenAt: 1_234, - }); - - assert.equal(durableStore.getById(historical.id).queueCustody, undefined); - assert.deepEqual(durableStore.getById(queued.message.id).queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'inv-with-history', seenAt: 1_234 }, - ]); - }); - - it('does not require this invocation to witness a queued carrier owned by another target cat', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const foreignTarget = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - content: 'queued body for fable5 only', - targetCats: ['fable5'], - }); - - await durableProcessor.markPromptMessagesSeen({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'inv-opus-cross-target', - messageIds: [foreignTarget.message.id], - seenAt: 1_234, - }); + it('keeps a targetless head queued while the thread is active and resolves it only after idle', async () => { + const harness = createHarness(); + const admitted = await admitMessage(harness, { targetCats: [] }); + harness.invocationTracker.start('thread-1', 'codex', 'user-1'); - const stored = durableStore.getById(foreignTarget.message.id); - assert.equal(stored.queueCustody.bodyExposures, undefined); - assert.deepEqual(stored.queueCustody.pendingTargetCats, ['fable5']); + const blocked = await harness.processor.processNext('thread-1', 'user-1'); + assert.equal(blocked.started, false); + assert.equal( + harness.router.resolveConversationTargetsAtAdmission.mock.callCount(), + 0, + 'an active thread must not guess a target for a targetless head', + ); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'queued'); + + harness.invocationTracker.complete('thread-1', 'codex'); + const resumed = await harness.processor.processNext('thread-1', 'user-1'); + assert.equal(resumed.started, true); + assert.equal(harness.router.resolveConversationTargetsAtAdmission.mock.callCount(), 1); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id) === null); + }); + + it('injects a caller terminal observation on its next natural turn and clears only after success', async () => { + const observedPrompts = []; + let turnSequence = 0; + const harness = createHarness({ + routeExecution: async function* (...args) { + turnSequence += 1; + const [userId, , threadId, , targetCats, , options] = args; + observedPrompts.push(options.modeSystemPromptByCat?.[targetCats[0]] ?? ''); + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: `turn-observation-${turnSequence}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); + yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; + }, }); - it('aborts before provider startup when true recall wins the prompt-exposure race', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const queued = enqueueCustodiedEntry(durableDeps.queue, durableStore, { content: 'body recalled at boundary' }); - - const snapshot = durableDeps.queue.getEntrySnapshot('t1', 'u1', queued.entry.id); - assert.ok(snapshot); - assert.equal(durableDeps.queue.removeEntrySnapshotIfUnchanged(snapshot), true); - const recalled = await coordinator.recallMessageToComposerDraft(queued.entry.id, queued.message.id, { - ownerUserId: 'u1', - threadId: 't1', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 1_200, - }); - assert.equal(recalled.kind, 'recalled'); - assert.equal(recalled.verdict, 'zero_exposure'); - - await assert.rejects( - durableProcessor.markPromptMessagesSeen({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'inv-lost-race', - messageIds: [queued.message.id], - seenAt: 1_234, - }), - /queued_prompt_exposure_rejected/, - ); - assert.deepEqual(durableStore.getById(queued.message.id).queueCustody.bodyExposures, undefined); + const outbound = await admitMessage(harness, { + from: { kind: 'agent', catId: 'caller' }, + targetCats: ['worker'], }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', outbound.entry.id) === null); - it('classifies recall as exposed when prompt evidence owns the carrier lock first', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const queued = enqueueCustodiedEntry(durableDeps.queue, durableStore, { content: 'body read before recall' }); - - await durableProcessor.markPromptMessagesSeen({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'inv-won-race', - messageIds: [queued.message.id], - seenAt: 1_234, - }); - const recalled = await coordinator.recallMessageToComposerDraft(queued.entry.id, queued.message.id, { - ownerUserId: 'u1', - threadId: 't1', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: 1_300, - }); - - assert.equal(recalled.kind, 'recalled'); - assert.equal(recalled.verdict, 'exposed'); - assert.deepEqual(recalled.message.recall.exposures, [ - { targetCatId: 'opus', invocationId: 'inv-won-race', seenAt: 1_234 }, - ]); + const source = await harness.messageStore.getById(outbound.message.id); + const responseId = source.lifecycle.dispatchRefs[0].statusMessageId; + const response = await harness.messageStore.getById(responseId); + const terminal = await harness.messageStore.commitLifecycleResponseTerminal(responseId, { + invocationId: response.lifecycle.invocationId, + status: 'completed', + completedAt: Date.now(), + content: 'worker finished', + mentions: [], + origin: 'stream', }); - - it('restores the complete multi-target Queue snapshot when custody commit fails after tentative consume', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = { - commitSuccessfulTargetsForMessages: mock.fn(async () => { - throw new Error('redis unavailable'); - }), - }; - const durableProcessor = new QueueProcessor(durableDeps); - const { entry } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - targetCats: ['opus', 'codex'], - }); - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-opus-1'), true); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-opus-1', ['opus']); - - const restored = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - assert.deepEqual(restored.targetCats, ['opus', 'codex']); - assert.deepEqual(restored.queuedSeenByCatIds, ['opus']); - assert.deepEqual(restored.queuedSeenInvocationIdByCatId, { opus: 'inv-opus-1' }); - assert.equal(restored.queuedHandledByCatIds, undefined); - assert.equal(durableDeps.queueCustodyCoordinator.commitSuccessfulTargetsForMessages.mock.calls.length, 1); + assert.ok(terminal.kind === 'applied' || terminal.kind === 'replayed'); + await settleLifecycleResponseInputs(harness.messageStore, terminal.message, responseId); + assert.equal(harness.queue.list('thread-1', 'user-1').length, 0, 'terminal observation must not wake caller'); + + const callerWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', callerWake.entry.id) === null); + assert.match(observedPrompts[1], /→ worker: completed/); + assert.match(observedPrompts[1], /worker finished/); + + const nextCallerWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', nextCallerWake.entry.id) === null); + assert.equal(observedPrompts[2], ''); + }); + + it('registers a persisted direct delivery failure for the caller without creating a wake', async () => { + const observedPrompts = []; + const harness = createHarness({ + routeExecution: async function* (...args) { + const [userId, , threadId, , targetCats, , options] = args; + observedPrompts.push(options.modeSystemPromptByCat?.[targetCats[0]] ?? ''); + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: 'turn-after-delivery-failure', + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); + yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; + }, + }); + harness.router.resolveConversationTargetsAtAdmission.mock.mockImplementation(async () => []); + const outbound = await admitMessage(harness, { + from: { kind: 'agent', catId: 'caller' }, + targetCats: ['missing'], + }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, false); + assert.equal(harness.queue.list('thread-1', 'user-1').length, 0, 'terminal observation must not enqueue caller'); + + harness.router.resolveConversationTargetsAtAdmission.mock.mockImplementation(async (targets) => [...targets]); + const callerWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', callerWake.entry.id) === null); + assert.match(observedPrompts[0], /delivery_failure\(invalid_explicit_target\)/); + assert.match(observedPrompts[0], new RegExp(outbound.message.id)); + }); + + it('retains an included terminal observation when the caller invocation fails', async () => { + const observedPrompts = []; + let callerAttempt = 0; + const registry = new CallerDispatchObservationRegistry(); + const harness = createHarness({ + callerDispatchObservationRegistry: registry, + routeExecution: async function* (...args) { + const [userId, , threadId, , targetCats, , options] = args; + observedPrompts.push(options.modeSystemPromptByCat?.[targetCats[0]] ?? ''); + callerAttempt += 1; + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: `turn-retain-${callerAttempt}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); + if (callerAttempt === 1) throw new Error('provider failed after prompt admission'); + yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; + }, }); - - it('keeps the same message queued through provider launch and terminalizes only on exact success', async () => { - const durableStore = new MessageStore(); - let observedAtProvider; - const seenAt = 1_700; - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* (...args) { - await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'child-inv-stub', - messageIds: [args[3]], - seenAt, - }); - observedAtProvider = structuredClone(durableStore.getRecent(1)[0]); - yield { type: 'done', catId: 'opus', invocationId: 'child-inv-stub', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - - const started = await durableProcessor.processNext('t1', 'u1'); - assert.equal(started.started, true); - await waitFor(() => durableStore.getById(message.id)?.deliveryStatus === 'delivered'); - - assert.equal(observedAtProvider.deliveryStatus, 'queued'); - assert.equal(observedAtProvider.queueCustody.status, 'processing'); - assert.equal(observedAtProvider.queueCustody.receiptScope, 'primary_trigger'); - assert.equal(observedAtProvider.queueCustody.seenInvocationIdByCatId.opus, 'child-inv-stub'); - assert.deepEqual(observedAtProvider.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'child-inv-stub', seenAt }, - ]); - const terminal = durableStore.getById(message.id); - assert.equal(terminal.queueCustody.status, 'terminal'); - assert.deepEqual(terminal.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(terminal.queueCustody.targetOutcomeByCatId.opus, { - invocationId: 'child-inv-stub', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'turn_execution', invocationId: 'child-inv-stub' }, - handledAt: terminal.deliveredAt, - }); - assert.ok(seenAt < terminal.queueCustody.targetOutcomeByCatId.opus.handledAt); - const delivered = durableDeps.socketManager.emitToUser.mock.calls.find( - (call) => call.arguments[1] === 'messages_delivered', - ); - const receipt = delivered.arguments[2].messages[0].extra.queueReceipt; - assert.equal(receipt.version, 1); - assert.equal(receipt.entryId, terminal.queueCustody.entryId); - assert.equal(receipt.scope, 'primary_trigger'); - assert.deepEqual(receipt.reminderAttempts, []); - const [handledTarget] = receipt.targets; - assert.equal(handledTarget.catId, 'opus'); - assert.equal(handledTarget.state, 'handled'); - assert.equal(handledTarget.invocationId, 'child-inv-stub'); - assert.equal(handledTarget.seenAt, seenAt); - assert.deepEqual(handledTarget.outcome, terminal.queueCustody.targetOutcomeByCatId.opus); - assert.deepEqual( - handledTarget.attempts?.map(({ id, targetCatId, sequence, state, invocationId, seenAt: attemptSeenAt }) => ({ - id, - targetCatId, - sequence, - state, - invocationId, - seenAt: attemptSeenAt, - })), - [ - { - id: `${terminal.queueCustody.entryId}:opus:1`, - targetCatId: 'opus', - sequence: 1, - state: 'handled', - invocationId: 'child-inv-stub', - seenAt, - }, - ], - ); - assert.equal(durableDeps.queue.list('t1', 'u1').length, 0); + const response = await harness.messageStore.append({ + from: { kind: 'agent', catId: 'worker' }, + userId: 'user-1', + content: 'worker finished', + mentions: [], + origin: 'stream', + timestamp: 2, + threadId: 'thread-1', + lifecycle: { + kind: 'response', + orderKey: '2:response-worker', + invocationId: 'inv-worker', + targetId: 'worker', + inputEntryIds: ['entry-source'], + inputMessageIds: [], + status: 'completed', + startedAt: 1, + completedAt: 2, + }, + }); + const source = await harness.messageStore.append({ + from: { kind: 'agent', catId: 'caller' }, + userId: 'user-1', + content: '@worker handle this', + mentions: ['worker'], + origin: 'stream', + timestamp: 1, + threadId: 'thread-1', + lifecycle: { + kind: 'input', + orderKey: '1:source-caller', + dispatchRefs: [{ targetId: 'worker', phase: 'settled', statusMessageId: response.id, dispatchedAt: 1 }], + }, + }); + response.lifecycle.inputMessageIds.push(source.id); + registry.registerPersistedSource(source, ['worker']); + + const firstWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', firstWake.entry.id) === null); + assert.match(observedPrompts[0], /worker finished/); + assert.equal(registry.list({ ownerId: 'user-1', threadId: 'thread-1', callerCatId: 'caller' }).length, 1); + + const secondWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', secondWake.entry.id) === null); + assert.match(observedPrompts[1], /worker finished/); + assert.equal(registry.list({ ownerId: 'user-1', threadId: 'thread-1', callerCatId: 'caller' }).length, 0); + }); + + it('delivers one process-start notice without scanning History and keeps current-process observations independent', async () => { + const observedPrompts = []; + let invocationAttempt = 0; + const registry = new CallerDispatchObservationRegistry(); + const harness = createHarness({ + callerDispatchObservationRegistry: registry, + processorOptions: { + callerDispatchProcessStart: { + processGenerationId: 'api:restart-test:42', + }, + }, + routeExecution: async function* (...args) { + const [userId, , threadId, , targetCats, , options] = args; + observedPrompts.push(options.modeSystemPromptByCat?.[targetCats[0]] ?? ''); + invocationAttempt += 1; + await options.onLifecycleInvocationStarted({ + threadId, + userId, + catId: targetCats[0], + invocationId: `turn-after-process-start-${invocationAttempt}`, + parentInvocationId: options.parentInvocationId, + startedAt: Date.now(), + }); + if (invocationAttempt === 1) throw new Error('first process-start prompt was not completed'); + yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; + }, }); - it('does not publish canceled visible output as invocation-lineage receipt evidence', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: durableStore, - now: () => entry.createdAt + 300, - }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const invocationId = 'child-output-preflight-rejected'; - - assert.equal( - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', invocationId, entry.createdAt + 100), - true, - ); - await coordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - const rejectedOutput = durableStore.append({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - content: 'output rejected by the holder fence', + for (let index = 0; index < 250; index += 1) { + await harness.messageStore.append({ + from: { kind: 'user', userId: 'user-1' }, + userId: 'user-1', + content: `old history ${index}`, mentions: [], - timestamp: entry.createdAt + 200, - deliveryStatus: 'queued', - extra: { stream: { invocationId, turnInvocationId: invocationId } }, - }); - assert.equal(durableStore.markCanceled(rejectedOutput.id)?.deliveryTransitioned, true); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'succeeded', invocationId, ['opus']); - - const settled = durableStore.getById(message.id); - assert.equal(settled.deliveryStatus, 'delivered'); - assert.equal(settled.queueCustody.status, 'terminal'); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.disposition, 'completed_with_turn'); - assert.deepEqual(settled.queueCustody.targetOutcomeByCatId.opus.evidenceRef, { - kind: 'turn_execution', - invocationId, - }); - assert.equal( - durableStore - .getByThreadAfter('t1', undefined, undefined, 'u1') - .some((candidate) => candidate.id === rejectedOutput.id), - false, - ); - }); - - it('delivers only the fully settled message from a coalesced cross-thread target carrier', async () => { - const durableStore = new MessageStore(); - const queue = new InvocationQueue(); - const enqueueCarrier = (catId, triggerMessageId, autoExecute = true) => - queue.enqueue({ - threadId: 't1', - userId: 'u1', - content: 'first handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - sourceCategory: 'a2a', - targetCats: [catId], - intent: 'execute', - autoExecute, - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: triggerMessageId, - }).entry; - const opusCarrier = enqueueCarrier('opus', 'message-first'); - const codexCarrier = enqueueCarrier('codex', 'message-first', false); - const first = durableStore.append({ - threadId: 't1', - userId: 'u1', - catId: 'sonnet', - content: 'first handoff', - mentions: ['opus', 'codex'], - timestamp: opusCarrier.createdAt, - deliveryStatus: 'queued', - }); - queue.backfillMessageId('t1', 'u1', opusCarrier.id, first.id); - queue.backfillMessageId('t1', 'u1', codexCarrier.id, first.id); - assert.equal( - durableStore.initializeQueueCustody( - first.id, - createInitialCrossThreadQueuedMessageCustody(first.id, [opusCarrier, codexCarrier], { - requestedTargetCats: ['opus', 'codex'], - createdAt: first.timestamp, - }), - ).kind, - 'initialized', - ); - const second = durableStore.append({ - threadId: 't1', - userId: 'u1', - catId: 'sonnet', - content: 'second handoff', - mentions: ['opus'], - timestamp: opusCarrier.createdAt + 1, - deliveryStatus: 'queued', - }); - assert.equal( - queue.coalesceContentIntoQueuedAgent( - 't1', - 'u1', - opusCarrier.id, - second.content, - second.id, - 'sonnet', - 'parent-source', - ), - true, - ); - assert.equal( - durableStore.initializeQueueCustody( - second.id, - createInitialCrossThreadQueuedMessageCustody( - second.id, - [queue.getEntrySnapshot('t1', 'u1', opusCarrier.id)], - { requestedTargetCats: ['opus'], createdAt: second.timestamp }, - ), - ).kind, - 'initialized', - ); - const invocationId = 'child-opus-coalesced'; - const seenAt = Math.max(opusCarrier.createdAt, codexCarrier.createdAt) + 100; - assert.equal(queue.markQueuedSeen('t1', 'u1', opusCarrier.id, 'opus', invocationId, seenAt), true); - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: durableStore, - now: () => seenAt + 100, - }); - await coordinator.persistEntry(queue.getEntrySnapshot('t1', 'u1', opusCarrier.id)); - const durableDeps = stubDeps({ queue, messageStore: durableStore, queueCustodyCoordinator: coordinator }); - durableDeps.invocationTracker.has = mock.fn((_threadId, catId) => catId === 'codex'); - const durableProcessor = new QueueProcessor(durableDeps); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'succeeded', invocationId, ['opus']); - - assert.equal(durableStore.getById(first.id).deliveryStatus, 'queued'); - assert.deepEqual(durableStore.getById(first.id).queueCustody.pendingTargetCats, ['codex']); - assert.equal(durableStore.getById(second.id).deliveryStatus, 'delivered'); - const delivered = durableDeps.socketManager.emitToUser.mock.calls.find( - (call) => call.arguments[1] === 'messages_delivered', - ); - assert.deepEqual(delivered.arguments[2].messageIds, [second.id]); - assert.equal( - delivered.arguments[2].messageIds.includes(first.id), - false, - 'M1 must not emit until its codex target settles', - ); - const remaining = queue.list('t1', 'u1'); - assert.equal(remaining.length, 1); - assert.equal(remaining[0].id, codexCarrier.id); - assert.deepEqual(remaining[0].targetCats, ['codex']); - - const codexInvocationId = 'child-codex-coalesced'; - assert.equal(queue.markQueuedSeen('t1', 'u1', codexCarrier.id, 'codex', codexInvocationId, seenAt + 200), true); - await coordinator.persistEntry(queue.getEntrySnapshot('t1', 'u1', codexCarrier.id)); - durableDeps.invocationTracker.has = mock.fn(() => false); - await durableProcessor.onInvocationComplete('t1', 'codex', 'succeeded', codexInvocationId, ['codex']); - - assert.equal(durableStore.getById(first.id).deliveryStatus, 'delivered'); - assert.deepEqual(durableStore.getById(first.id).queueCustody.pendingTargetCats, []); - const deliveredEvents = durableDeps.socketManager.emitToUser.mock.calls.filter( - (call) => call.arguments[1] === 'messages_delivered', - ); - assert.deepEqual( - deliveredEvents.map((call) => call.arguments[2].messageIds), - [[second.id], [first.id]], - 'M1 must emit only after its remaining codex target settles', - ); - assert.deepEqual(queue.list('t1', 'u1'), []); - }); - - it('persists exact terminal-silent consumption proof on the handled target receipt', async () => { - const durableStore = new MessageStore(); - const seenAt = 1_750; - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* (...args) { - await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'child-terminal-silent', - messageIds: [args[3]], - seenAt, - }); - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-terminal-silent', - turnCustodyTerminalWitness: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - - const started = await durableProcessor.processNext('t1', 'u1'); - assert.equal(started.started, true); - await waitFor(() => durableStore.getById(message.id)?.deliveryStatus === 'delivered'); - - assert.deepEqual(durableStore.getById(message.id).queueCustody.targetOutcomeByCatId.opus.consumption, { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }); - }); - - it('commits its handled receipt before awaiting exact coordination retirement', async () => { - const durableStore = new MessageStore(); - const seenAt = 1_751; - let releaseRetirement; - const retirementFence = new Promise((resolve) => { - releaseRetirement = resolve; - }); - const completeFromCoordinationTerminal = mock.fn(async () => { - await retirementFence; - return { outcome: 'applied' }; - }); - const durableDeps = stubDeps({ - messageStore: durableStore, - a2aDispatchDispositionService: { - inspectHandoff: mock.fn(), - completeFromCoordinationTerminal, - }, - router: { - routeExecution: mock.fn(async function* (...args) { - await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'fable5', - invocationId: 'child-terminal-retirement', - messageIds: [args[3]], - seenAt, - }); - yield { - type: 'done', - catId: 'fable5', - invocationId: 'child-terminal-retirement', - turnCustodyTerminalWitness: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const activeSource = durableStore.append({ - userId: 'u1', - catId: 'fable5', - content: '@opus active coordination', - mentions: ['opus'], - timestamp: 1_700, - threadId: 'source-thread', - extra: { - crossPost: { sourceThreadId: 't1', sourceInvocationId: 'predecessor-invocation' }, - coordination: { id: 'coord-terminal-retirement', phase: 'active', hop: 1 }, - }, - }); - const { message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { targetCats: ['fable5'] }); - message.catId = 'opus'; - message.mentions = ['fable5']; - message.extra = { - crossPost: { sourceThreadId: 'source-thread', sourceInvocationId: 'source-invocation' }, - coordination: { id: 'coord-terminal-retirement', phase: 'terminal', hop: 2 }, - causal: { kind: 'invocation_reply', triggerMessageId: activeSource.id }, - stream: { invocationId: 'source-invocation', turnInvocationId: 'source-invocation' }, - targetCats: ['fable5'], - }; - - const started = await durableProcessor.processNext('t1', 'u1'); - assert.equal(started.started, true); - await waitFor(() => completeFromCoordinationTerminal.mock.calls.length === 1); - assert.equal(completeFromCoordinationTerminal.mock.calls[0].arguments[0], message.id); - assert.equal(durableStore.getById(message.id).deliveryStatus, 'delivered'); - assert.equal(durableStore.getById(message.id).queueCustody.status, 'terminal'); - assert.equal(durableDeps.queue.list('t1', 'u1').length, 0); - - releaseRetirement(); - await waitFor(() => durableStore.getById(message.id)?.deliveryStatus === 'delivered'); - assert.deepEqual(durableStore.getById(message.id).queueCustody.targetOutcomeByCatId.fable5.consumption, { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }); - }); - - it('settles a managed hold carrier only from exact structured-continuation proof', async () => { - const durableStore = new MessageStore(); - const seenAt = 1_752; - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* (...args) { - const adoptedWakes = await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'child-managed-rehold', - messageIds: [args[3]], - seenAt, - }); - assert.equal(adoptedWakes.length, 1); - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-managed-rehold', - turnCustodyTerminalWitnesses: adoptedWakes.map((wake) => ({ - kind: 'managed_hold_continued', - sourceMessageId: wake.sourceMessageId, - taskId: wake.taskId, - transition: 'reheld', - })), - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - source: 'connector', - sourceCategory: 'scheduled', - messageSource: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-managed-rehold', threadId: 't1', catId: 'opus', wakeWhen: true }, - }, - }); - - const started = await durableProcessor.processNext('t1', 'u1'); - assert.equal(started.started, true); - await waitFor(() => durableStore.getById(message.id)?.deliveryStatus === 'delivered'); - - const outcome = durableStore.getById(message.id).queueCustody.targetOutcomeByCatId.opus; - assert.equal(outcome.disposition, 'managed_hold_disposition'); - assert.deepEqual(outcome.consumption, { - kind: 'managed_hold_continued', - sourceMessageId: message.id, - taskId: 'task-managed-rehold', - transition: 'reheld', - }); - }); - - it('settles every managed hold body adopted by an already-running ordinary child', async () => { - const durableStore = new MessageStore(); - const childInvocationId = 'child-user-turn-adopted-holds'; - const durableDeps = stubDeps({ - messageStore: durableStore, - turnExecutionStore: { - get: mock.fn(async (invocationId) => - invocationId === childInvocationId - ? { - invocationId, - parentInvocationId: 'parent-user-turn', - threadId: 't1', - userId: 'u1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 1_700, - status: 'succeeded', - endedAt: 1_900, - } - : null, - ), - }, - }); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const first = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - content: 'first managed completion', - source: 'connector', - sourceCategory: 'scheduled', - messageSource: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-adopted-1', threadId: 't1', catId: 'opus', wakeWhen: true }, - }, - }); - const second = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - content: 'second managed completion', - source: 'connector', - sourceCategory: 'scheduled', - messageSource: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-adopted-2', threadId: 't1', catId: 'opus', wakeWhen: true }, - }, - }); - - const adoptedWakes = await durableProcessor.markPromptMessagesSeen({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: childInvocationId, - messageIds: [first.message.id, second.message.id], - seenAt: 1_750, + timestamp: index + 1, + threadId: 'thread-1', }); - assert.deepEqual( - adoptedWakes.map(({ sourceMessageId, taskId }) => ({ sourceMessageId, taskId })), - [ - { sourceMessageId: first.message.id, taskId: 'task-adopted-1' }, - { sourceMessageId: second.message.id, taskId: 'task-adopted-2' }, + } + const getByThreadBefore = harness.messageStore.getByThreadBefore.bind(harness.messageStore); + harness.messageStore.getByThreadBefore = mock.fn((...args) => getByThreadBefore(...args)); + + const source = await harness.messageStore.append({ + from: { kind: 'agent', catId: 'caller' }, + userId: 'user-1', + content: '@worker current process work', + mentions: ['worker'], + origin: 'stream', + timestamp: 300, + threadId: 'thread-1', + lifecycle: { + kind: 'input', + orderKey: '300:current-process-source', + dispatchRefs: [ + { targetId: 'worker', phase: 'dispatched', statusMessageId: 'response-current', dispatchedAt: 300 }, ], - ); - - await durableProcessor.onInvocationComplete( - 't1', - 'opus', - 'succeeded', - 'parent-user-turn', - ['opus'], - false, - { opus: childInvocationId }, - [first.entry.id, second.entry.id], - { - [childInvocationId]: adoptedWakes.map((wake) => ({ - kind: 'managed_hold_continued', - sourceMessageId: wake.sourceMessageId, - taskId: wake.taskId, - transition: 'reheld', - })), - }, - ); - - assert.deepEqual(durableDeps.queue.list('t1', 'u1'), []); - for (const { message } of [first, second]) { - const settled = durableStore.getById(message.id); - assert.equal(settled.deliveryStatus, 'delivered'); - assert.equal(settled.queueCustody.status, 'terminal'); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.disposition, 'managed_hold_disposition'); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.consumption.sourceMessageId, message.id); - } - }); - - it('handled dispatch provider termination settles the source and starts exactly one source-free continuation', async () => { - const durableStore = new MessageStore(); - const seenAt = 1_754; - const dispositionAt = 2_000; - const routeCalls = []; - let secondarySourceMessageId; - const continuationCoordinator = { - resolveSessionStrategy: mock.fn(async () => 'reborn'), - prepareInvocationContext: mock.fn(async ({ content }) => ({ content, sessionPolicy: 'fresh' })), - commitInvocationOutcome: mock.fn(async () => {}), - }; - const durableDeps = stubDeps({ - messageStore: durableStore, - sessionContinuationCoordinator: continuationCoordinator, - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push({ content: args[1], messageId: args[3] }); - if (routeCalls.length === 1) { - await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'child-dispatch-handled', - messageIds: [args[3], secondarySourceMessageId].filter(Boolean), - seenAt, - }); - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-dispatch-handled', - turnCustodyTerminalWitness: { - kind: 'dispatch_handled_continuation', - sourceMessageId: args[3], - dispositionEventId: `dispatch-disposition:child-dispatch-handled:${args[3]}`, - dispositionAt, - }, - timestamp: dispositionAt + 1, - }; - return; - } - yield { type: 'text', catId: 'opus', content: 'continued owner work', timestamp: dispositionAt + 2 }; - yield { type: 'done', catId: 'opus', timestamp: dispositionAt + 3 }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const sourceMessage = durableStore.append({ - userId: 'u1', - catId: 'sonnet', - content: 'terminal A2A carrier body must not be replayed', - mentions: ['opus'], - timestamp: 100, - threadId: 't1', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - }, - }); - const entry = enqueueEntry(durableDeps.queue, { - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - autoExecute: true, - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: sourceMessage.id, - }); - durableDeps.queue.backfillMessageId('t1', 'u1', entry.id, sourceMessage.id); - const secondarySource = durableStore.append({ - userId: 'u1', - catId: 'sonnet', - content: 'coalesced A2A body also must not be replayed', - mentions: ['opus'], - timestamp: 101, - threadId: 't1', - deliveryStatus: 'queued', - }); - secondarySourceMessageId = secondarySource.id; - assert.equal( - durableDeps.queue.coalesceContentIntoQueuedAgent( - 't1', - 'u1', - entry.id, - secondarySource.content, - secondarySource.id, - 'sonnet', - 'parent-source', - ), - true, - ); - const persistedEntry = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - durableStore.initializeQueueCustody( - sourceMessage.id, - createInitialCrossThreadQueuedMessageCustody(sourceMessage.id, [persistedEntry]), - ); - durableStore.initializeQueueCustody( - secondarySource.id, - createInitialCrossThreadQueuedMessageCustody(secondarySource.id, [persistedEntry]), - ); - - const started = await durableProcessor.processNext('t1', 'u1'); - assert.equal(started.started, true); - await waitFor(() => routeCalls.length === 2); - await waitFor(() => durableStore.getById(sourceMessage.id)?.deliveryStatus === 'delivered'); - await waitFor(() => durableStore.getById(secondarySource.id)?.deliveryStatus === 'delivered'); - await new Promise((resolve) => setTimeout(resolve, 100)); - - assert.equal(routeCalls.length, 2, 'the terminal witness must schedule exactly one continuation'); - assert.equal( - continuationCoordinator.resolveSessionStrategy.mock.calls.length, - 0, - 'reborn session policy must not suppress owner-work liveness continuation', - ); - assert.equal(routeCalls[1].messageId, null, 'continuation must not retain the settled source identity'); - assert.match(routeCalls[1].content, /A2A carrier was terminally handled/i); - assert.doesNotMatch(routeCalls[1].content, /terminal A2A carrier body must not be replayed/); - assert.doesNotMatch(routeCalls[1].content, /coalesced A2A body also must not be replayed/); - assert.doesNotMatch(routeCalls[1].content, new RegExp(sourceMessage.id)); - assert.deepEqual(durableStore.getById(sourceMessage.id).queueCustody.targetOutcomeByCatId.opus.consumption, { - kind: 'dispatch_handled_continuation', - sourceMessageId: sourceMessage.id, - dispositionEventId: `dispatch-disposition:child-dispatch-handled:${sourceMessage.id}`, - dispositionAt, - }); - assert.equal( - durableStore.getById(secondarySource.id).queueCustody.targetOutcomeByCatId.opus.consumption, - undefined, - 'the typed witness must remain attached only to its exact source message', - ); - }); - - it('retires a consumed coordination terminal once after its exact source invocation was superseded', async () => { - const h = await createA2ADispositionHarness(); - h.source.extra = { - crossPost: { - sourceThreadId: 'thread-origin', - sourceInvocationId: 'origin-invocation', - }, - coordination: { - id: 'coord-superseded-source', - phase: 'active', - hop: 1, - subjectRef: 'task:superseded-source', - }, - targetCats: ['codex-sol'], - }; - const terminal = h.messageStore.append({ - userId: 'user-1', - catId: 'codex-sol', - content: '@fable5 terminal result', - mentions: ['fable5'], - timestamp: 1_750, - threadId: 'thread-origin', - origin: 'callback', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-1', - sourceInvocationId: 'inv-1', - }, - coordination: { - id: 'coord-superseded-source', - phase: 'terminal', - hop: 2, - subjectRef: 'task:superseded-source', - }, - causal: { - kind: 'invocation_reply', - triggerMessageId: h.source.id, - }, - stream: { - invocationId: 'inv-1', - turnInvocationId: 'inv-1', - }, - targetCats: ['fable5'], - }, - }); - h.setLatest(false); - - const durableDeps = stubDeps({ - messageStore: h.messageStore, - a2aDispatchDispositionService: h.service, - router: { - routeExecution: mock.fn(async function* (...args) { - const childInvocationId = 'terminal-child'; - await args[6].onPromptMessagesExposed({ - threadId: terminal.threadId, - userId: terminal.userId, - catId: 'fable5', - invocationId: childInvocationId, - messageIds: [terminal.id], - seenAt: 1_760, - }); - yield { - type: 'done', - catId: 'fable5', - invocationId: childInvocationId, - turnCustodyTerminalWitness: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - timestamp: 1_770, - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ - messageStore: h.messageStore, - }); - const entry = enqueueEntry(durableDeps.queue, { - threadId: terminal.threadId, - userId: terminal.userId, - content: terminal.content, - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['fable5'], - autoExecute: true, - callerCatId: 'codex-sol', - a2aParentInvocationId: 'inv-1', - a2aTriggerMessageId: terminal.id, - }); - durableDeps.queue.backfillMessageId(terminal.threadId, terminal.userId, entry.id, terminal.id); - const queued = durableDeps.queue.getEntrySnapshot(terminal.threadId, terminal.userId, entry.id); - h.messageStore.initializeQueueCustody( - terminal.id, - createInitialCrossThreadQueuedMessageCustody(terminal.id, [queued]), - ); - const processor = new QueueProcessor(durableDeps); - - const processing = durableDeps.queue.markProcessing(terminal.threadId, terminal.userId); - assert.equal(processing.id, entry.id); - const result = await processor.executeEntry(processing); - assert.equal(result.status, 'succeeded'); - await processor.onInvocationComplete( - terminal.threadId, - 'fable5', - result.status, - result.invocationId, - result.successfulCatIds, - result.primaryEntryRequeued, - result.terminalInvocationIdByCatId, - result.attemptedQueueEntryIds, - result.terminalConsumptionByInvocationId, - ); - - assert.equal( - durableDeps.queue.list(terminal.threadId, terminal.userId).length, - 0, - 'the already-completed terminal carrier must not be restored to Queue', - ); - assert.equal(h.messageStore.getById(terminal.id).queueCustody.status, 'terminal'); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.dispatch_dispositioned') - .length, - 1, - ); - assert.equal(durableDeps.router.routeExecution.mock.calls.length, 1); - - await processor.onInvocationComplete( - terminal.threadId, - 'fable5', - 'succeeded', - 'inv-stub', - ['fable5'], - false, - { fable5: 'terminal-child' }, - [entry.id], - { - 'terminal-child': [ - { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - sourceMessageId: terminal.id, - }, - ], - }, - ); - assert.equal(durableDeps.queue.list(terminal.threadId, terminal.userId).length, 0); - assert.equal(durableDeps.router.routeExecution.mock.calls.length, 1, 'replay must not rerun the provider'); - assert.equal( - (await h.eventLog.read('ball:thread:thread-1')).filter((event) => event.kind === 'ball.dispatch_dispositioned') - .length, - 1, - 'replay must keep one canonical disposition event', - ); + }, }); - - it('persists exact child awakening before the cross-thread body is exposed', async () => { - const durableStore = new MessageStore(); - let releaseExposure; - const exposureGate = new Promise((resolve) => { - releaseExposure = resolve; - }); - const awakenedAt = 1_755; - const seenAt = 1_765; - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* (...args) { - yield { - type: 'system_info', - catId: 'opus', - // Lifecycle truth must travel in typed fields. The compatibility - // body is deliberately opaque so QueueProcessor cannot infer - // awakening by parsing user-visible/system prose. - content: 'child execution started', - turnInvocationId: 'child-awakened-before-read', - turnExecutionStartedAt: awakenedAt, - extra: { - turnExecution: { - invocationId: 'child-awakened-before-read', - parentInvocationId: 'parent-queue', - executionKind: 'ordinary', - }, - }, - timestamp: awakenedAt, - }; - await exposureGate; - await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'child-awakened-before-read', - messageIds: [args[3]], - seenAt, - }); - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-awakened-before-read', - timestamp: seenAt + 10, - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const sourceMessage = durableStore.append({ - userId: 'u1', - catId: 'sonnet', - content: 'terminal release', - mentions: ['opus'], - timestamp: 100, - threadId: 't1', - deliveryStatus: 'queued', - }); - const entry = enqueueEntry(durableDeps.queue, { - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - autoExecute: true, - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: sourceMessage.id, - }); - durableDeps.queue.backfillMessageId('t1', 'u1', entry.id, sourceMessage.id); - const persistedEntry = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - durableStore.initializeQueueCustody( - sourceMessage.id, - createInitialCrossThreadQueuedMessageCustody(sourceMessage.id, [persistedEntry]), - ); - - const started = await durableProcessor.processNext('t1', 'u1'); - assert.equal(started.started, true); - await waitFor( - () => - durableStore.getById(sourceMessage.id)?.queueCustody?.awakenedInvocationIdByCatId?.opus === - 'child-awakened-before-read', - ); - - const awakened = durableStore.getById(sourceMessage.id); - assert.equal(awakened.deliveryStatus, 'queued'); - assert.equal(awakened.queueCustody.bodyExposures, undefined); - const [awakenedTarget] = projectQueueReceipt(awakened.queueCustody).targets; - assert.equal(awakenedTarget.catId, 'opus'); - assert.equal(awakenedTarget.state, 'awakened'); - assert.equal(awakenedTarget.invocationId, 'child-awakened-before-read'); - assert.equal(awakenedTarget.awakenedAt, awakenedAt); - assert.deepEqual( - awakenedTarget.attempts?.map(({ targetCatId, sequence, state }) => ({ targetCatId, sequence, state })), - [{ targetCatId: 'opus', sequence: 1, state: 'queued' }], - ); - - releaseExposure(); - await waitFor(() => durableStore.getById(sourceMessage.id)?.deliveryStatus === 'delivered'); - const terminal = durableStore.getById(sourceMessage.id); - assert.deepEqual(terminal.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'child-awakened-before-read', seenAt }, - ]); - assert.equal(terminal.queueCustody.awakenedInvocationIdByCatId, undefined); - assert.equal(terminal.queueCustody.awakenedAtByCatId, undefined); - }); - - it('keeps one cross-thread message while independent target carriers settle exact receipts', async () => { - const durableStore = new MessageStore(); - const seenAtByCat = { opus: 1_760, codex: 1_770 }; - let releaseCodex; - const codexGate = new Promise((resolve) => { - releaseCodex = resolve; - }); - let markCodexStarted; - const codexStarted = new Promise((resolve) => { - markCodexStarted = resolve; - }); - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* (...args) { - const catId = args[4][0]; - const childInvocationId = `child-${catId}`; - await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId, - invocationId: childInvocationId, - messageIds: [args[3]], - seenAt: seenAtByCat[catId], - }); - if (catId === 'codex') { - markCodexStarted(); - await codexGate; - } - yield { - type: 'done', - catId, - invocationId: childInvocationId, - ...(catId === 'opus' - ? { - turnCustodyTerminalWitness: { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }, - } - : {}), - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const sourceMessage = durableStore.append({ - userId: 'u1', - catId: 'sonnet', - content: 'terminal release', - mentions: ['opus', 'codex'], - timestamp: 100, - threadId: 't1', - deliveryStatus: 'queued', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - sourceInvocationId: 'parent-source', - effectClass: 'coordinate', - }, - }, - }); - const entries = ['opus', 'codex'].map((catId) => { - const entry = enqueueEntry(durableDeps.queue, { - source: 'agent', - sourceCategory: 'a2a', - targetCats: [catId], - autoExecute: true, - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: sourceMessage.id, - }); - durableDeps.queue.backfillMessageId('t1', 'u1', entry.id, sourceMessage.id); - return durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - }); - durableStore.initializeQueueCustody( - sourceMessage.id, - createInitialCrossThreadQueuedMessageCustody(sourceMessage.id, entries), - ); - - const firstStarted = await durableProcessor.processNext('t1', 'u1'); - assert.equal(firstStarted.started, true); - await waitFor(() => durableStore.getById(sourceMessage.id)?.queueCustody?.handledByCatIds.includes('opus')); - await codexStarted; - - const afterFirst = durableStore.getById(sourceMessage.id); - assert.equal(afterFirst.deliveryStatus, 'queued'); - assert.deepEqual(afterFirst.queueCustody.pendingTargetCats, ['codex']); - assert.deepEqual(afterFirst.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(afterFirst.queueCustody.targetOutcomeByCatId.opus.consumption, { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }); - assert.deepEqual(Object.keys(afterFirst.queueCustody.carrierStateByTargetCatId), ['codex']); - - releaseCodex(); - await waitFor(() => durableStore.getById(sourceMessage.id)?.deliveryStatus === 'delivered'); - - const terminal = durableStore.getById(sourceMessage.id); - assert.equal(terminal.queueCustody.status, 'terminal'); - assert.deepEqual(terminal.queueCustody.pendingTargetCats, []); - assert.deepEqual(terminal.queueCustody.handledByCatIds, ['opus', 'codex']); - assert.equal(terminal.queueCustody.carrierStateByTargetCatId, undefined); - assert.equal(terminal.queueCustody.targetOutcomeByCatId.codex.consumption, undefined); - const deliveredEvents = durableDeps.socketManager.emitToUser.mock.calls.filter( - (call) => call.arguments[1] === 'messages_delivered', - ); - assert.equal(deliveredEvents.length, 1); - assert.equal(deliveredEvents[0].arguments[2].messages.length, 1); - assert.equal(deliveredEvents[0].arguments[2].messages[0].id, sourceMessage.id); - const textEvents = durableDeps.socketManager.broadcastAgentMessage.mock.calls.filter( - (call) => call.arguments[0].type === 'text', - ); - assert.deepEqual(textEvents, [], 'terminal clean-stop must not fabricate a cat reply bubble'); - }); - - it('uses the ordinary child that read the body when a routing guard owns the terminal event', async () => { - const durableStore = new MessageStore(); - const seenAt = 1_800; - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* (...args) { - await args[6].onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'child-ordinary-read', - messageIds: [args[3]], - seenAt, - }); - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-ordinary-read', - timestamp: Date.now(), - }; - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-routing-guard', - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - - const started = await durableProcessor.processNext('t1', 'u1'); - assert.equal(started.started, true); - await waitFor(() => durableDeps.router.routeExecution.mock.calls.length === 1); - await new Promise((resolve) => setTimeout(resolve, 100)); - - assert.equal(durableDeps.router.routeExecution.mock.calls.length, 1); - assert.equal(durableDeps.queue.list('t1', 'u1').length, 0); - const terminal = durableStore.getById(message.id); - assert.equal(terminal.deliveryStatus, 'delivered'); - assert.deepEqual(terminal.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'child-ordinary-read', seenAt }, - ]); - assert.deepEqual(terminal.queueCustody.targetOutcomeByCatId.opus, { - invocationId: 'child-ordinary-read', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'turn_execution', invocationId: 'child-ordinary-read' }, - handledAt: terminal.deliveredAt, - }); - }); - - it('resolves a direct parent completion to the succeeded exact child that read the Queue body', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ - messageStore: durableStore, - turnExecutionStore: { - get: mock.fn(async (invocationId) => - invocationId === 'child-direct-read' - ? { - invocationId, - parentInvocationId: 'parent-direct', - threadId: 't1', - userId: 'u1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 1_900, - status: 'succeeded', - endedAt: 2_000, - } - : null, - ), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'child-direct-read', 1_950); - await durableDeps.queueCustodyCoordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'succeeded', 'parent-direct', ['opus'], false, {}, [ - entry.id, - ]); - - assert.equal(durableDeps.queue.list('t1', 'u1').length, 0); - const terminal = durableStore.getById(message.id); - assert.equal(terminal.deliveryStatus, 'delivered'); - assert.equal(terminal.queueCustody.targetOutcomeByCatId.opus.invocationId, 'child-direct-read'); - }); - - it('keeps an interrupted exact child visible as failed Queue responsibility after aggregate success', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ - messageStore: durableStore, - turnExecutionStore: { - get: mock.fn(async (invocationId) => - invocationId === 'child-interrupted-read' - ? { - invocationId, - parentInvocationId: 'parent-with-guard', - threadId: 't1', - userId: 'u1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 2_100, - status: 'interrupted', - endedAt: 2_200, - terminalReason: 'provider_ended_without_terminal_done', - } - : null, - ), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - source: 'connector', - sourceCategory: 'scheduled', - messageSource: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-interrupted', threadId: 't1', catId: 'opus', wakeWhen: true }, - }, - }); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'child-interrupted-read', 2_150); - await durableDeps.queueCustodyCoordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'succeeded', 'parent-with-guard', ['opus'], false, {}, [ - entry.id, - ]); - - const queued = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - assert.deepEqual(queued.queuedFailedByCatIds, ['opus']); - assert.equal(queued.queuedSeenInvocationIdByCatId, undefined); - const persisted = durableStore.getById(message.id); - assert.deepEqual(persisted.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual(persisted.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'child-interrupted-read', seenAt: 2_150 }, - ]); - assert.equal(persisted.queueCustody.targetOutcomeByCatId, undefined); - assert.equal(durableDeps.router.routeExecution.mock.calls.length, 0); - }); - - it('returns an exact child failure to Queue even when the direct caller reports no completed cats', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ - messageStore: durableStore, - turnExecutionStore: { - get: mock.fn(async (invocationId) => - invocationId === 'child-direct-failed' - ? { - invocationId, - parentInvocationId: 'parent-direct-failed', - threadId: 't1', - userId: 'u1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 2_300, - status: 'failed', - endedAt: 2_400, - terminalReason: 'provider_failure', - } - : null, - ), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - source: 'connector', - sourceCategory: 'scheduled', - messageSource: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-failed', threadId: 't1', catId: 'opus', wakeWhen: true }, - }, - }); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'child-direct-failed', 2_350); - await durableDeps.queueCustodyCoordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'failed', 'parent-direct-failed', [], true, {}, [ - entry.id, - ]); - - const queued = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - assert.deepEqual(queued.queuedFailedByCatIds, ['opus']); - assert.equal(queued.queuedSeenInvocationIdByCatId, undefined); - const persisted = durableStore.getById(message.id); - assert.deepEqual(persisted.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual(persisted.queueCustody.bodyExposures, [ - { targetCatId: 'opus', invocationId: 'child-direct-failed', seenAt: 2_350 }, - ]); - assert.equal(persisted.queueCustody.targetOutcomeByCatId, undefined); - }); - - it('returns a canceled managed-hold child to the same single Queue carrier', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - source: 'connector', - sourceCategory: 'scheduled', - messageSource: { - connector: 'hold-ball', - label: '持球通知', - meta: { taskId: 'task-canceled', threadId: 't1', catId: 'opus', wakeWhen: true }, - }, - }); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'child-canceled', 2_450); - await durableDeps.queueCustodyCoordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - - await durableProcessor.onInvocationComplete( - 't1', - 'opus', - 'canceled', - 'parent-canceled', - [], - true, - { opus: 'child-canceled' }, - [entry.id], - ); - - const queued = durableDeps.queue.list('t1', 'u1'); - assert.equal(queued.length, 1); - assert.equal(queued[0].id, entry.id); - assert.deepEqual(queued[0].queuedFailedByCatIds, ['opus']); - assert.equal(queued[0].queuedSeenInvocationIdByCatId, undefined); - const persisted = durableStore.getById(message.id); - assert.deepEqual(persisted.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual(persisted.queueCustody.handledByCatIds, []); - assert.equal(persisted.queueCustody.targetOutcomeByCatId, undefined); - }); - - it('handles only the succeeded target and returns its failed sibling without an immediate retry', async () => { - const durableStore = new MessageStore(); - const records = new Map([ - [ - 'child-opus-succeeded', - { - invocationId: 'child-opus-succeeded', - parentInvocationId: 'parent-multi', - threadId: 't1', - userId: 'u1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 2_500, - status: 'succeeded', - endedAt: 2_600, - }, - ], - [ - 'child-codex-failed', - { - invocationId: 'child-codex-failed', - parentInvocationId: 'parent-multi', - threadId: 't1', - userId: 'u1', - catId: 'codex', - executionKind: 'ordinary', - startedAt: 2_500, - status: 'failed', - endedAt: 2_600, - terminalReason: 'provider_failure', - }, - ], - ]); - const durableDeps = stubDeps({ - messageStore: durableStore, - turnExecutionStore: { get: mock.fn(async (invocationId) => records.get(invocationId) ?? null) }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - targetCats: ['opus', 'codex'], - }); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'child-opus-succeeded', 2_550); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'codex', 'child-codex-failed', 2_560); - await durableDeps.queueCustodyCoordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'succeeded', 'parent-multi', ['opus'], false, {}, [ - entry.id, - ]); - - const queued = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - assert.deepEqual(queued.targetCats, ['codex']); - assert.deepEqual(queued.queuedFailedByCatIds, ['codex']); - assert.equal(queued.queuedSeenInvocationIdByCatId, undefined); - const persisted = durableStore.getById(message.id); - assert.deepEqual(persisted.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(persisted.queueCustody.failedByCatIds, ['codex']); - assert.equal(persisted.queueCustody.targetOutcomeByCatId.opus.invocationId, 'child-opus-succeeded'); - assert.equal(persisted.queueCustody.targetOutcomeByCatId.codex, undefined); - assert.equal(durableDeps.router.routeExecution.mock.calls.length, 0); - }); - - it('trusts exact child truth when the parent aggregate fails', async () => { - const durableStore = new MessageStore(); - const records = new Map([ - [ - 'child-opus-succeeded', - { - invocationId: 'child-opus-succeeded', - parentInvocationId: 'parent-multi-failed', - threadId: 't1', - userId: 'u1', - catId: 'opus', - executionKind: 'ordinary', - startedAt: 2_500, - status: 'succeeded', - endedAt: 2_600, - }, - ], - [ - 'child-codex-failed', - { - invocationId: 'child-codex-failed', - parentInvocationId: 'parent-multi-failed', - threadId: 't1', - userId: 'u1', - catId: 'codex', - executionKind: 'ordinary', - startedAt: 2_500, - status: 'failed', - endedAt: 2_600, - terminalReason: 'provider_failure', - }, - ], - ]); - const durableDeps = stubDeps({ - messageStore: durableStore, - turnExecutionStore: { get: mock.fn(async (invocationId) => records.get(invocationId) ?? null) }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - targetCats: ['opus', 'codex'], - }); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'child-opus-succeeded', 2_550); - durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'codex', 'child-codex-failed', 2_560); - await durableDeps.queueCustodyCoordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - - await durableProcessor.onInvocationComplete( - 't1', - 'opus', - 'failed', - 'parent-multi-failed', - ['opus'], - false, - { opus: 'child-opus-succeeded', codex: 'child-codex-failed' }, - [entry.id], - { - 'child-opus-succeeded': [{ kind: 'source_response', outputMessageIds: ['output-opus'] }], - }, - ); - - const queued = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - assert.deepEqual(queued.targetCats, ['codex']); - assert.deepEqual(queued.queuedFailedByCatIds, ['codex']); - const persisted = durableStore.getById(message.id); - assert.deepEqual(persisted.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(persisted.queueCustody.failedByCatIds, ['codex']); - assert.deepEqual(persisted.queueCustody.targetOutcomeByCatId.opus.consumption, { - kind: 'source_response', - outputMessageIds: ['output-opus'], - }); - assert.equal(persisted.queueCustody.targetOutcomeByCatId.codex, undefined); - assert.equal(durableDeps.router.routeExecution.mock.calls.length, 0); - }); - - it('passes primary and F175 batch message identities through the route exposure boundary', async () => { - let routedOptions; - const batchDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (...args) { - routedOptions = args[6]; - await routedOptions.onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: 'child-batch-exposure', - messageIds: routedOptions.persistedPromptMessageIds, - seenAt: Date.now(), - }); - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-batch-exposure', - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const batchProcessor = new QueueProcessor(batchDeps); - const entries = ['primary', 'batched-a', 'batched-b'].map((content, index) => { - const entry = enqueueEntry(batchDeps.queue, { content }); - batchDeps.queue.backfillMessageId('t1', 'u1', entry.id, `msg-batch-${index}`); - return entry; - }); - batchDeps.messageStore.getById = mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: null, - content: entries[Number(id.at(-1))].content, - mentions: ['opus'], - timestamp: Date.now(), - })); - - await batchProcessor.processNext('t1', 'u1'); - await waitFor(() => routedOptions !== undefined); - - assert.deepEqual( - new Set(routedOptions.persistedPromptMessageIds), - new Set(entries.map((_entry, index) => `msg-batch-${index}`)), - ); - assert.deepEqual( - routedOptions.persistedPromptMessages.map(({ messageId, content }) => ({ messageId, content })), - entries.map((entry, index) => ({ messageId: `msg-batch-${index}`, content: entry.content })), - ); - await waitFor(() => batchDeps.queue.list('t1', 'u1').length === 0); - }); - - it('passes only successfully hydrated Queue members after missing or throwing batch reads', async () => { - for (const hydrationFailure of ['missing', 'throw']) { - let routedOptions; - let custodyAtExposure; - const batchDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (...args) { - routedOptions = args[6]; - await routedOptions.onPromptMessagesExposed({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - invocationId: `child-partial-${hydrationFailure}`, - messageIds: routedOptions.persistedPromptMessages.map(({ messageId }) => messageId), - seenAt: Date.now(), - }); - custodyAtExposure = batchDeps.queue.list('t1', 'u1').map((candidate) => ({ - messageId: candidate.messageId, - seenBy: candidate.queuedSeenByCatIds ?? [], - })); - yield { - type: 'done', - catId: 'opus', - invocationId: `child-partial-${hydrationFailure}`, - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const batchProcessor = new QueueProcessor(batchDeps); - const entries = ['hydrated primary', 'unavailable batched'].map((content, index) => { - const entry = enqueueEntry(batchDeps.queue, { content }); - batchDeps.queue.backfillMessageId('t1', 'u1', entry.id, `msg-partial-${index}`); - return entry; - }); - batchDeps.messageStore.getById = mock.fn(async (id) => { - if (id === 'msg-partial-1') { - if (hydrationFailure === 'throw') throw new Error('hydration unavailable'); - return null; - } - return { - id, - threadId: 't1', - userId: 'u1', - catId: null, - content: entries[0].content, - contentBlocks: [ - { - type: 'context_attachment', - attachment: { v: 1, id: 'ctx-hydrated', kind: 'thread', threadId: 'thread-hydrated' }, - }, - ], - mentions: ['opus'], - timestamp: Date.now(), - }; - }); - - await batchProcessor.processNext('t1', 'u1'); - await waitFor(() => routedOptions !== undefined); - - assert.deepEqual( - routedOptions.persistedPromptMessageIds, - ['msg-partial-0', 'msg-partial-1'], - `${hydrationFailure}: intended batch census remains diagnostic`, - ); - assert.deepEqual( - routedOptions.persistedPromptMessages.map(({ messageId, content }) => ({ messageId, content })), - [{ messageId: 'msg-partial-0', content: 'hydrated primary' }], - `${hydrationFailure}: only the durable per-message subset may reach routing`, - ); - assert.deepEqual( - custodyAtExposure, - [ - { messageId: 'msg-partial-0', seenBy: ['opus'] }, - { messageId: 'msg-partial-1', seenBy: [] }, - ], - `${hydrationFailure}: unavailable member receives no seen/custody transition`, - ); - } - }); - - it('does not immediately re-run a succeeded Queue attempt that never bound exact body exposure', async () => { - const durableStore = new MessageStore(); - let routeCalls = 0; - const missingExposureDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* () { - routeCalls += 1; - if (routeCalls > 1) throw new Error('unexpected immediate retry'); - yield { - type: 'done', - catId: 'opus', - invocationId: 'child-without-exposure', - timestamp: Date.now(), - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const missingExposureProcessor = new QueueProcessor(missingExposureDeps); - enqueueCustodiedEntry(missingExposureDeps.queue, durableStore); - - await missingExposureProcessor.processNext('t1', 'u1'); - await waitFor(() => routeCalls >= 1); - await new Promise((resolve) => setTimeout(resolve, 100)); - - assert.equal(routeCalls, 1, 'missing exposure must preserve Queue ownership without a hot retry loop'); - assert.equal(missingExposureDeps.queue.list('t1', 'u1').length, 1); - }); - - it('marks responded only when the successful invocation has an exact replyTo link', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-reply'), true); - const seen = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - await coordinator.persistEntry(seen); - durableStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'opus', - content: 'explicitly addressed response', - mentions: [], - timestamp: Date.now(), - replyTo: message.id, - extra: { stream: { invocationId: 'inv-reply', turnInvocationId: 'inv-reply' } }, - }); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-reply', ['opus']); - - assert.equal(durableStore.getById(message.id).queueCustody.targetOutcomeByCatId.opus.disposition, 'responded'); - }); - - it('settles an exact source response even when the reading child later cancels', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - const seenAt = entry.createdAt + 100; - const settledAt = entry.createdAt + 300; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore, now: () => settledAt }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const childInvocationId = 'child-response-then-cancel'; - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', childInvocationId, seenAt), true); - await coordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - const response = durableStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'opus', - content: 'durable review verdict', - mentions: [], - timestamp: entry.createdAt + 200, - replyTo: message.id, - extra: { - stream: { invocationId: 'parent-response-then-cancel', turnInvocationId: childInvocationId }, - }, - }); - - await durableProcessor.onInvocationComplete( - 't1', - 'opus', - 'canceled_by_user', - 'parent-response-then-cancel', - ['opus'], - true, - { opus: childInvocationId }, - [entry.id], - ); - - const settled = durableStore.getById(message.id); - assert.equal(settled.deliveryStatus, 'delivered'); - assert.equal(settled.queueCustody.status, 'terminal'); - const outcome = settled.queueCustody.targetOutcomeByCatId.opus; - assert.ok(outcome.handledAt > seenAt); - assert.deepEqual( - { ...outcome, handledAt: settledAt }, - { - invocationId: childInvocationId, - disposition: 'responded', - evidenceRef: { kind: 'invocation_lineage', invocationId: childInvocationId }, - handledAt: settledAt, - consumption: { - kind: 'source_response', - outputMessageIds: [response.id], - }, - }, - ); - assert.deepEqual(durableDeps.queue.list('t1', 'u1'), []); - }); - - it('settles a detached scheduler source from a default-user response when the child fails', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - userId: 'default-user', - messageUserId: 'scheduler', - messageSource: { connector: 'hold-ball' }, - }); - const childInvocationId = 'child-scheduler-response-then-fail'; - const seenAt = entry.createdAt + 100; - const settledAt = entry.createdAt + 300; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore, now: () => settledAt }); - durableDeps.queueCustodyCoordinator = coordinator; - assert.equal( - durableDeps.queue.markQueuedSeen('t1', 'default-user', entry.id, 'opus', childInvocationId, seenAt), - true, - ); - const exposedEntry = durableDeps.queue.getEntrySnapshot('t1', 'default-user', entry.id); - await coordinator.persistEntry(exposedEntry); - assert.equal(durableDeps.queue.removeEntrySnapshotIfUnchanged(exposedEntry), true); - const response = durableStore.append({ - userId: 'default-user', - threadId: 't1', - catId: 'opus', - content: 'durable response from the triggering user turn', - mentions: [], - timestamp: entry.createdAt + 200, - replyTo: message.id, - extra: { - stream: { invocationId: 'parent-scheduler-response-then-fail', turnInvocationId: childInvocationId }, - }, - }); - - await new QueueProcessor(durableDeps).onInvocationComplete( - 't1', - 'opus', - 'failed', - 'parent-scheduler-response-then-fail', - ['opus'], - true, - { opus: childInvocationId }, - [entry.id], - ); - - const settled = durableStore.getById(message.id); - assert.equal(settled.deliveryStatus, 'delivered'); - assert.equal(settled.queueCustody.status, 'terminal'); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.disposition, 'responded'); - assert.deepEqual(settled.queueCustody.targetOutcomeByCatId.opus.consumption, { - kind: 'source_response', - outputMessageIds: [response.id], - }); - }); - - it('terminalizes a detached managed predecessor exactly once across two completion sweeps', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - source: 'connector', - sourceCategory: 'scheduled', - messageSource: { - connector: 'hold-ball', - label: 'managed completion', - meta: { taskId: 'task-detached-rehold', threadId: 't1', catId: 'opus', wakeWhen: true }, - }, - }); - const childInvocationId = 'child-detached-managed-rehold'; - const seenAt = entry.createdAt + 100; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', childInvocationId, seenAt), true); - const exposedEntry = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - await coordinator.persistEntry(exposedEntry); - assert.equal(durableDeps.queue.removeEntrySnapshotIfUnchanged(exposedEntry), true); - const exactWitness = { - kind: 'managed_hold_continued', - sourceMessageId: message.id, - taskId: 'task-detached-rehold', - transition: 'reheld', - }; - const settle = (processor) => - processor.onInvocationComplete( - 't1', - 'opus', - 'succeeded', - 'parent-detached-managed-rehold', - ['opus'], - false, - { opus: childInvocationId }, - [entry.id], - { [childInvocationId]: [exactWitness] }, - ); - - await settle(new QueueProcessor(durableDeps)); - await settle(new QueueProcessor(durableDeps)); - - const settled = durableStore.getById(message.id); - assert.equal(settled.deliveryStatus, 'delivered'); - assert.equal(settled.queueCustody.status, 'terminal'); - assert.deepEqual(settled.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(settled.queueCustody.targetOutcomeByCatId.opus.consumption, exactWitness); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.disposition, 'managed_hold_disposition'); - assert.equal(durableDeps.queue.list('t1', 'u1').length, 0, 'the predecessor Queue carrier stays retired'); - assert.equal( - durableDeps.router.routeExecution.mock.calls.length, - 0, - 'recovery does not start a second invocation', - ); - assert.equal( - durableDeps.socketManager.broadcastToRoom.mock.calls.filter( - (call) => call.arguments[1] === 'message_receipt_updated' && call.arguments[2]?.messageId === message.id, - ).length, - 1, - 'the predecessor receipt terminalizes exactly once', - ); - }); - - it('settles the exact child after exposed true recall removed its Queue carrier', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - const childInvocationId = 'child-success-after-recall'; - const seenAt = entry.createdAt + 100; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore, now: () => seenAt + 300 }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', childInvocationId, seenAt), true); - const exposedEntry = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - await coordinator.persistEntry(exposedEntry); - assert.equal(durableDeps.queue.removeEntrySnapshotIfUnchanged(exposedEntry), true); - const recalled = durableStore.recallMessageToComposerDraft(message.id, { - ownerUserId: 'u1', - threadId: 't1', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: seenAt + 200, - }); - assert.equal(recalled.kind, 'recalled'); - assert.equal(recalled.verdict, 'exposed'); - - await durableProcessor.onInvocationComplete( - 't1', - 'opus', - 'succeeded', - 'parent-success-after-recall', - ['opus'], - false, - { opus: childInvocationId }, - ); - - const settled = durableStore.getById(message.id); - assert.equal(settled.content, ''); - assert.equal(settled.deliveryStatus, 'canceled'); - assert.equal(settled.recall.exposure, 'seen'); - assert.deepEqual(settled.queueCustody.handledByCatIds, ['opus']); - assert.deepEqual(settled.queueCustody.withdrawnByCatIds ?? [], []); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.invocationId, childInvocationId); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.disposition, 'completed_with_turn'); - assert.deepEqual(settled.queueCustody.targetOutcomeByCatId.opus.evidenceRef, { - kind: 'turn_execution', - invocationId: childInvocationId, - }); - const receiptEvent = durableDeps.socketManager.broadcastToRoom.mock.calls.find( - (call) => call.arguments[1] === 'message_receipt_updated', - ); - assert.deepEqual(receiptEvent?.arguments, [ - 'thread:t1', - 'message_receipt_updated', - { threadId: 't1', messageId: message.id }, - ]); - }); - - it('settles an exposed recalled source through the durable exposure index when visibility no longer lists it', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - const childInvocationId = 'child-hidden-after-recall'; - const seenAt = entry.createdAt + 100; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore, now: () => seenAt + 300 }); - durableDeps.queueCustodyCoordinator = coordinator; - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', childInvocationId, seenAt), true); - const exposedEntry = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - await coordinator.persistEntry(exposedEntry); - assert.equal(durableDeps.queue.removeEntrySnapshotIfUnchanged(exposedEntry), true); - assert.equal( - durableStore.recallMessageToComposerDraft(message.id, { - ownerUserId: 'u1', - threadId: 't1', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: seenAt + 200, - }).kind, - 'recalled', - ); - durableStore.getByThreadAfter = async () => []; - durableStore.getByQueueExposure = async (threadId, targetCatId, invocationId) => { - assert.equal(threadId, 't1'); - assert.equal(targetCatId, 'opus'); - assert.equal(invocationId, childInvocationId); - return [durableStore.getById(message.id)]; - }; - - await new QueueProcessor(durableDeps).onInvocationComplete( - 't1', - 'opus', - 'succeeded', - 'parent-hidden-after-recall', - ['opus'], - false, - { opus: childInvocationId }, - ); - - const settled = durableStore.getById(message.id); - assert.deepEqual(settled.queueCustody.handledByCatIds, ['opus']); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.invocationId, childInvocationId); - }); - - it('retains an exact response witness when an exposed recalled child later cancels', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - const childInvocationId = 'child-response-after-recall'; - const seenAt = entry.createdAt + 100; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore, now: () => seenAt + 400 }); - durableDeps.queueCustodyCoordinator = coordinator; - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', childInvocationId, seenAt), true); - const exposedEntry = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - await coordinator.persistEntry(exposedEntry); - assert.equal(durableDeps.queue.removeEntrySnapshotIfUnchanged(exposedEntry), true); - assert.equal( - durableStore.recallMessageToComposerDraft(message.id, { - ownerUserId: 'u1', - threadId: 't1', - expectedDraftRevision: 0, - merge: 'replace', - recalledAt: seenAt + 200, - }).kind, - 'recalled', - ); - const response = durableStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'opus', - content: 'exact response survived recall', - mentions: [], - timestamp: seenAt + 300, - replyTo: message.id, - extra: { stream: { invocationId: 'parent-response-after-recall', turnInvocationId: childInvocationId } }, - }); - - await new QueueProcessor(durableDeps).onInvocationComplete( - 't1', - 'opus', - 'canceled', - 'parent-response-after-recall', - ['opus'], - false, - { opus: childInvocationId }, - ); - - const settled = durableStore.getById(message.id); - assert.equal(settled.content, ''); - assert.equal(settled.deliveryStatus, 'canceled'); - assert.equal(settled.queueCustody.targetOutcomeByCatId.opus.disposition, 'responded'); - assert.deepEqual(settled.queueCustody.targetOutcomeByCatId.opus.consumption, { - kind: 'source_response', - outputMessageIds: [response.id], - }); - }); - - it('does not treat unbound output or tool activity as source consumption after cancellation', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - const childInvocationId = 'child-ambiguous-output-then-cancel'; - const seenAt = entry.createdAt + 100; - assert.equal(durableDeps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', childInvocationId, seenAt), true); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - await coordinator.persistEntry(durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)); - durableStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'opus', - content: 'visible work without an exact source reference', - mentions: [], - timestamp: entry.createdAt + 200, - extra: { stream: { invocationId: 'parent-ambiguous-output', turnInvocationId: childInvocationId } }, - }); - durableStore.append({ - userId: 'u1', - threadId: 't1', - catId: 'opus', - content: '', - mentions: [], - timestamp: entry.createdAt + 201, - toolEvents: [{ id: 'tool-1', type: 'tool_result', label: 'physical action', timestamp: entry.createdAt + 201 }], - extra: { - stream: { invocationId: 'parent-ambiguous-output', turnInvocationId: childInvocationId }, - causal: { kind: 'invocation_reply', triggerMessageId: message.id }, - }, - }); - - await new QueueProcessor(durableDeps).onInvocationComplete( - 't1', - 'opus', - 'canceled', - 'parent-ambiguous-output', - ['opus'], - true, - { opus: childInvocationId }, - [entry.id], - ); - - const unsettled = durableStore.getById(message.id); - assert.equal(unsettled.deliveryStatus, 'queued'); - assert.equal(unsettled.queueCustody.status, 'queued'); - assert.deepEqual(unsettled.queueCustody.pendingTargetCats, ['opus']); - assert.deepEqual(unsettled.queueCustody.failedByCatIds, ['opus']); - assert.equal(unsettled.queueCustody.targetOutcomeByCatId?.opus, undefined); - assert.equal(durableDeps.queue.list('t1', 'u1').length, 1); - }); - - it('retains a queued coalesced carrier when a remaining source also has a live target', async () => { - const durableStore = new MessageStore(); - const queue = new InvocationQueue(); - const carrier = queue.enqueue({ - threadId: 't1', - userId: 'u1', - content: 'first handoff', - source: 'agent', - ownerAuthProvenance: 'unknown', - sourceCategory: 'a2a', - targetCats: ['opus'], - intent: 'execute', - autoExecute: true, - callerCatId: 'sonnet', - a2aParentInvocationId: 'parent-source', - a2aTriggerMessageId: 'message-first', - }).entry; - const first = durableStore.append({ - threadId: 't1', - userId: 'u1', - catId: 'sonnet', - content: 'first handoff', - mentions: ['opus', 'codex'], - timestamp: carrier.createdAt, - deliveryStatus: 'queued', - }); - queue.backfillMessageId('t1', 'u1', carrier.id, first.id); - const liveCodexInvocationId = 'child-live-codex'; - const liveCodexStartedAt = carrier.createdAt + 25; - const liveCodexSeenAt = carrier.createdAt + 40; - const liveCodexCarrier = { - ...queue.getEntrySnapshot('t1', 'u1', carrier.id), - id: 'carrier-live-codex', - targetCats: ['codex'], - allTargetCats: ['codex'], - status: 'processing', - processingStartedAt: liveCodexStartedAt, - queuedAwakenedInvocationIdByCatId: { codex: liveCodexInvocationId }, - queuedAwakenedAtByCatId: { codex: carrier.createdAt + 30 }, - queuedSeenByCatIds: ['codex'], - queuedSeenInvocationIdByCatId: { codex: liveCodexInvocationId }, - queuedBodyExposures: [{ targetCatId: 'codex', invocationId: liveCodexInvocationId, seenAt: liveCodexSeenAt }], - }; - assert.equal( - durableStore.initializeQueueCustody( - first.id, - createInitialCrossThreadQueuedMessageCustody( - first.id, - [queue.getEntrySnapshot('t1', 'u1', carrier.id), liveCodexCarrier], - { - requestedTargetCats: ['opus', 'codex'], - createdAt: first.timestamp, - }, - ), - ).kind, - 'initialized', - ); - const settledAt = carrier.createdAt + 300; - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore, now: () => settledAt }); - await coordinator.persistEntry(liveCodexCarrier); - const second = durableStore.append({ - threadId: 't1', - userId: 'u1', - catId: 'sonnet', - content: 'second handoff', - mentions: ['opus'], - timestamp: carrier.createdAt + 1, - deliveryStatus: 'queued', - }); - assert.equal( - queue.coalesceContentIntoQueuedAgent( - 't1', - 'u1', - carrier.id, - second.content, - second.id, - 'sonnet', - 'parent-source', - ), - true, - ); - assert.equal( - durableStore.initializeQueueCustody( - second.id, - createInitialCrossThreadQueuedMessageCustody(second.id, [queue.getEntrySnapshot('t1', 'u1', carrier.id)], { - requestedTargetCats: ['opus'], - createdAt: second.timestamp, - }), - ).kind, - 'initialized', - ); - const childInvocationId = 'child-coalesced-response-then-cancel'; - const seenAt = carrier.createdAt + 100; - assert.equal(queue.markQueuedSeen('t1', 'u1', carrier.id, 'opus', childInvocationId, seenAt), true); - await coordinator.persistEntry(queue.getEntrySnapshot('t1', 'u1', carrier.id)); - const response = durableStore.append({ - threadId: 't1', - userId: 'u1', - catId: 'opus', - content: 'the second handoff is complete', - mentions: [], - timestamp: carrier.createdAt + 200, - extra: { - stream: { invocationId: 'parent-coalesced-cancel', turnInvocationId: childInvocationId }, - causal: { kind: 'invocation_reply', triggerMessageId: second.id }, - }, - }); - const durableDeps = stubDeps({ queue, messageStore: durableStore, queueCustodyCoordinator: coordinator }); - const durableProcessor = new QueueProcessor(durableDeps); - - await durableProcessor.onInvocationComplete( - 't1', - 'opus', - 'canceled', - 'parent-coalesced-cancel', - ['opus'], - true, - { opus: childInvocationId }, - [carrier.id], - ); - - const firstAfter = durableStore.getById(first.id); - const secondAfter = durableStore.getById(second.id); - assert.equal(firstAfter.deliveryStatus, 'queued'); - assert.equal(firstAfter.queueCustody.status, 'processing'); - assert.deepEqual(firstAfter.queueCustody.pendingTargetCats, ['opus', 'codex']); - assert.deepEqual(firstAfter.queueCustody.failedByCatIds, ['opus']); - assert.deepEqual(firstAfter.queueCustody.carrierStateByTargetCatId, { - opus: { status: 'queued' }, - codex: { status: 'processing', processingStartedAt: liveCodexStartedAt }, - }); - assert.equal(firstAfter.queueCustody.seenInvocationIdByCatId.codex, liveCodexInvocationId); - assert.equal(secondAfter.deliveryStatus, 'delivered'); - assert.equal(secondAfter.queueCustody.targetOutcomeByCatId.opus.disposition, 'responded'); - assert.deepEqual(secondAfter.queueCustody.targetOutcomeByCatId.opus.consumption, { - kind: 'source_response', - outputMessageIds: [response.id], - }); - const remaining = queue.list('t1', 'u1'); - assert.equal(remaining.length, 1); - assert.equal(remaining[0].id, carrier.id); - assert.equal(remaining[0].messageId, first.id); - assert.deepEqual(remaining[0].mergedMessageIds, []); - assert.equal(remaining[0].content, first.content); - }); - - it('terminalizes an unseen reminder as missed when its exact invocation ends', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ messageStore: durableStore }); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - const snapshot = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - await coordinator.requestReminder(snapshot, 'opus', 'inv-ending', 'reminder-ending'); - - await durableProcessor.onInvocationComplete('t1', 'opus', 'failed', 'inv-ending', ['opus']); - - assert.deepEqual(durableStore.getById(message.id).queueCustody.reminderAttempts[0], { - id: 'reminder-ending', - targetCatId: 'opus', - invocationId: 'inv-ending', - state: 'missed', - requestedAt: durableStore.getById(message.id).queueCustody.reminderAttempts[0].requestedAt, - missedAt: durableStore.getById(message.id).queueCustody.reminderAttempts[0].missedAt, - missedReason: 'invocation_ended_before_delivery', - }); - }); - - it('returns failed execution to the same durable Queue identity without exposing the message', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* () { - throw new Error('provider failed'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - - await durableProcessor.processNext('t1', 'u1'); - await waitFor(() => - durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.queuedFailedByCatIds?.includes('opus'), - ); - - const restored = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - const stored = durableStore.getById(message.id); - assert.equal(restored.status, 'queued'); - assert.equal(stored.deliveryStatus, 'queued'); - assert.equal(stored.queueCustody.status, 'queued'); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.equal(stored.queueCustody.awakenedInvocationIdByCatId, undefined); - assert.equal(stored.queueCustody.awakenedAtByCatId, undefined); - assert.deepEqual(stored.queueCustody.seenInvocationIdByCatId, {}); - const [failedTarget] = projectQueueReceipt(stored.queueCustody).targets; - assert.equal(failedTarget.catId, 'opus'); - assert.equal(failedTarget.state, 'failed'); - assert.deepEqual( - failedTarget.attempts?.map(({ targetCatId, sequence, state, terminalReason }) => ({ - targetCatId, - sequence, - state, - terminalReason, - })), - [{ targetCatId: 'opus', sequence: 1, state: 'failed', terminalReason: 'invocation_failed' }], - ); - }); - - it('retries only the selected target from a multi-target failed receipt', async () => { - const durableStore = new MessageStore(); - const routedTargetSets = []; - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - routedTargetSets.push([...targetCats]); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - durableDeps.queueCustodyCoordinator = coordinator; - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - targetCats: ['opus', 'codex'], - }); - - for (const catId of ['opus', 'codex']) { - const failedInvocationId = `failed-${catId}`; - durableDeps.queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, catId, failedInvocationId); - durableDeps.queue.markQueuedFailedForCatAcrossUsers( - entry.threadId, - catId, - failedInvocationId, - new Set([entry.id]), - 'invocation_failed', - ); - } - await coordinator.persistEntry(durableDeps.queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id)); - const opusAttempt = durableStore - .getById(message.id) - .queueCustody.targetAttempts.find((attempt) => attempt.targetCatId === 'opus'); - assert.ok(opusAttempt); - - const retry = await durableProcessor.retryFailedTarget( - entry.threadId, - entry.userId, - entry.id, - 'opus', - opusAttempt.id, - async (transitions) => { - for (const transition of transitions) { - const result = durableStore.transitionQueueCustody(transition.messageId, { - expectedRevision: transition.current.revision, - next: transition.next, - }); - assert.equal(result.kind, 'updated'); - } - return { outcome: 'committed' }; - }, - ); - - assert.equal(retry.outcome, 'retried'); - await waitFor(() => routedTargetSets.length === 1); - assert.deepEqual(routedTargetSets, [['opus']]); - const retryCreate = durableDeps.invocationRecordStore.create.mock.calls.at(-1)?.arguments[0]; - assert.equal(retryCreate.idempotencyKey, retry.attemptId); - assert.deepEqual(retryCreate.targetCats, ['opus']); - }); - - it('upgrades a queued legacy source before execution so failure cannot leave delivered-plus-queued truth', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* () { - throw new Error('provider failed before durable response'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const entry = enqueueEntry(durableDeps.queue, { content: 'legacy queued source' }); - const message = durableStore.append({ - userId: entry.userId, - catId: null, - content: entry.content, - mentions: entry.targetCats, - timestamp: entry.createdAt, - threadId: entry.threadId, - deliveryStatus: 'queued', - }); - durableDeps.queue.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - - await durableProcessor.processNext('t1', 'u1'); - await waitFor(() => durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.status === 'queued'); - - const restored = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - const stored = durableStore.getById(message.id); - assert.equal(restored.id, stored.queueCustody.entryId); - assert.equal(restored.status, 'queued'); - assert.equal(stored.deliveryStatus, 'queued'); - assert.equal(stored.queueCustody.status, 'queued'); - }); - - it('keeps exact child-created proof when the invocation fails before body exposure', async () => { - const durableStore = new MessageStore(); - const awakenedAt = 2_010; - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: 'child execution started', - turnInvocationId: 'child-created-then-failed', - turnExecutionStartedAt: awakenedAt, - extra: { - turnExecution: { - invocationId: 'child-created-then-failed', - parentInvocationId: 'parent-created-then-failed', - executionKind: 'ordinary', - }, - }, - timestamp: awakenedAt, - }; - throw new Error('provider failed before prompt exposure'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - - await durableProcessor.processNext('t1', 'u1'); - await waitFor(() => - durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.queuedFailedByCatIds?.includes('opus'), - ); - - const stored = durableStore.getById(message.id); - assert.equal(stored.queueCustody.bodyExposures, undefined); - assert.deepEqual(stored.queueCustody.awakenedInvocationIdByCatId, { - opus: 'child-created-then-failed', - }); - assert.deepEqual(stored.queueCustody.awakenedAtByCatId, { opus: awakenedAt }); - const [failedTarget] = projectQueueReceipt(stored.queueCustody).targets; - assert.equal(failedTarget.catId, 'opus'); - assert.equal(failedTarget.state, 'failed'); - assert.equal(failedTarget.invocationId, 'child-created-then-failed'); - assert.equal(failedTarget.awakenedAt, awakenedAt); - assert.deepEqual( - failedTarget.attempts?.map(({ targetCatId, sequence, state, terminalReason }) => ({ - targetCatId, - sequence, - state, - terminalReason, - })), - [{ targetCatId: 'opus', sequence: 1, state: 'failed', terminalReason: 'invocation_failed' }], - ); - }); - - it('does not infer child awakening from compatibility content without typed execution truth', async () => { - const durableStore = new MessageStore(); - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ - type: 'invocation_created', - invocationId: 'content-only-child', - parentInvocationId: 'content-only-parent', - executionKind: 'ordinary', - startedAt: 2_020, - }), - timestamp: 2_020, - }; - throw new Error('provider failed before typed child proof'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - - await durableProcessor.processNext('t1', 'u1'); - await waitFor(() => - durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.queuedFailedByCatIds?.includes('opus'), - ); - - const stored = durableStore.getById(message.id); - assert.equal(stored.queueCustody.awakenedInvocationIdByCatId, undefined); - assert.equal(stored.queueCustody.awakenedAtByCatId, undefined); - const [failedTarget] = projectQueueReceipt(stored.queueCustody).targets; - assert.equal(failedTarget.catId, 'opus'); - assert.equal(failedTarget.state, 'failed'); - assert.deepEqual( - failedTarget.attempts?.map(({ targetCatId, sequence, state, terminalReason }) => ({ - targetCatId, - sequence, - state, - terminalReason, - })), - [{ targetCatId: 'opus', sequence: 1, state: 'failed', terminalReason: 'invocation_failed' }], - ); - }); - - it('retains a custodied agent trigger when A2A admission cannot establish successor custody', async () => { - const durableStore = new MessageStore(); - const realSetTimeout = globalThis.setTimeout; - const recoveryTimers = []; - let scheduledRecoveryCount = 0; - const setTimeoutMock = mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => { - if (delay === 10_000) { - scheduledRecoveryCount += 1; - const timer = realSetTimeout(callback, 60_000, ...args); - recoveryTimers.push(timer); - return timer; - } - return realSetTimeout(callback, delay, ...args); - }); - const durableDeps = stubDeps({ - messageStore: durableStore, - router: { - routeExecution: mock.fn(async function* () { - throw new Error('durable A2A custody unavailable for codex: InvocationQueue unavailable'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore, { - content: '@opus review this', - source: 'agent', - sourceCategory: 'a2a', - autoExecute: true, - }); - - try { - await durableProcessor.processNext('t1', 'u1'); - await waitFor(() => - durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id)?.queuedFailedByCatIds?.includes('opus'), - ); - - const restored = durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - const stored = durableStore.getById(message.id); - assert.equal(restored.status, 'queued'); - assert.equal(restored.messageId, message.id, 'the exact A2A trigger must remain bound to the retried row'); - assert.equal(restored.content, '@opus review this'); - assert.equal(stored.deliveryStatus, 'queued'); - assert.equal(stored.queueCustody.status, 'queued'); - assert.deepEqual(stored.queueCustody.failedByCatIds, ['opus']); - assert.equal(durableProcessor.getPauseReason('t1', 'opus'), undefined); - assert.equal(scheduledRecoveryCount, 0, 'a durably failed primary trigger must await explicit retry authority'); - } finally { - for (const timer of recoveryTimers) clearTimeout(timer); - setTimeoutMock.mock.restore(); - } - }); - }); - - it('F254 Phase E: adopts a pending closure and reloads exact bodies before model execution', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-1', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - originTriggerMessageId: 'msg-origin', - draftContent: 'old draft', - requiredMessageIds: ['msg-required'], - requiredFrontierMessageId: 'msg-required', - observedRawFrontierMessageId: 'msg-required', - now: 100, - }); - const routeCalls = []; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getLatestThreadMessageIdIncludingQueued: mock.fn(async () => 'msg-required'), - getByThreadAfter: mock.fn(async () => []), - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: null, - content: 'late user facts', - mentions: [], - timestamp: 100, - })), - }, - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push(args); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - const entry = enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, - idempotencyKey: `freshness-closure:${closure.id}`, - }); - const processing = customDeps.queue.markProcessing('t1', 'u1'); - - const result = await customProcessor.executeEntry(processing); - - assert.equal(result.status, 'succeeded'); - assert.equal(routeCalls.length, 1); - assert.ok(routeCalls[0][1].includes('late user facts')); - assert.equal(routeCalls[0][6].freshnessClosureId, closure.id); - assert.deepEqual(routeCalls[0][6].freshnessClosureRequiredMessageIds, ['msg-required']); - }); - - it('F254 Phase E: explicit retry tells the successor which prior side effects must not be repeated', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const blocked = await closureStore.openOrAdvance({ - closureId: 'closure-side-effect-retry', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - originTriggerMessageId: 'msg-origin', - draftContent: 'stale draft after hold', - requiredMessageIds: ['msg-required'], - requiredFrontierMessageId: 'msg-required', - observedRawFrontierMessageId: 'msg-required', - replayUnsafeToolNames: ['mcp__cat-cafe-collab__cat_cafe_hold_ball'], - now: 100, - }); - assert.equal(blocked.status, 'blocked'); - const retried = await closureStore.retry(blocked.id, { - actorId: 'u1', - evidenceRef: 'api:retry:test', - now: 110, - }); - const routeCalls = []; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getLatestThreadMessageIdIncludingQueued: mock.fn(async () => 'msg-required'), - getByThreadAfter: mock.fn(async () => []), - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: null, - content: 'late user facts', - mentions: [], - timestamp: 100, - })), - }, - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push(args); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: retried.id, - freshnessRequiredFrontierMessageId: retried.requiredFrontierMessageId, - idempotencyKey: `freshness-closure:${retried.id}:retry:${retried.retryEpoch}`, - }); - const processing = customDeps.queue.markProcessing('t1', 'u1'); - - const result = await customProcessor.executeEntry(processing); - - assert.equal(result.status, 'succeeded'); - assert.equal(routeCalls.length, 1); - assert.match(routeCalls[0][1], /hold_ball/); - assert.match(routeCalls[0][1], /不要重复已完成的副作用/); - const closed = await closureStore.get(retried.id); - assert.equal(closed.status, 'blocked', 'a silent successor must end in durable closure truth'); - assert.equal(closed.blockedReason, 'infrastructure'); - const blockedProjection = customDeps.socketManager.broadcastAgentMessage.mock.calls - .map((call) => { - try { - return JSON.parse(call.arguments[0].content); - } catch { - return null; - } - }) - .find((projection) => projection?.type === 'freshness_closure' && projection.status === 'blocked'); - assert.ok(blockedProjection, 'QueueProcessor must broadcast the durable blocked projection'); - }); - - it('F254 Phase E: closure adoption advances seenCursor to the required frontier before model execution', async () => { - // Root cause of the 2026-07-11 supersede loop (thread_mrf4rg9atprwlyzq): - // the successor's rebuilt prompt carried the required bodies, but nothing - // advanced the DeliveryCursorStore seenCursor (the successor entry has no - // messageId, so route-serial's incrementalMode AC-A3 seed is skipped too). - // The output freshness gate then re-read the frozen cursor, judged the very - // same injected messages unseen, and superseded every replacement forever. - // ADR-041 §5: "Automatic injection records the same-invocation input - // frontier, advances seenCursor" — injection must count as seen. - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-cursor-advance', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - originTriggerMessageId: 'msg-origin', - draftContent: 'old draft', - requiredMessageIds: ['msg-a', 'msg-b'], - requiredFrontierMessageId: 'msg-b', - observedRawFrontierMessageId: 'msg-b', - now: 100, - }); - const ackCalls = []; - let ackCountSeenByModel = -1; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - deliveryCursorStore: { - ackSeenCursor: mock.fn(async (userId, catId, threadId, messageId) => { - ackCalls.push({ userId, catId, threadId, messageId }); - }), - }, - messageStore: { - ...stubDeps().messageStore, - getLatestThreadMessageIdIncludingQueued: mock.fn(async () => 'msg-b'), - getByThreadAfter: mock.fn(async () => []), - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: 'peer-cat', - content: `body:${id}`, - mentions: [], - timestamp: 100, - })), - }, - router: { - routeExecution: mock.fn(async function* () { - ackCountSeenByModel = ackCalls.length; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, - idempotencyKey: `freshness-closure:${closure.id}`, - }); - const processing = customDeps.queue.markProcessing('t1', 'u1'); - - const result = await customProcessor.executeEntry(processing); - - assert.equal(result.status, 'succeeded'); - assert.equal(ackCalls.length, 1, 'closure adoption must ack the seen cursor exactly once'); - assert.deepEqual(ackCalls[0], { userId: 'u1', catId: 'opus', threadId: 't1', messageId: 'msg-b' }); - assert.equal(ackCountSeenByModel, 1, 'seenCursor must advance BEFORE model execution starts'); - }); - - it('F254 Phase E: closure adoption survives a seenCursor ack failure (fail-open, no block)', async () => { - // Cursor ack is a freshness-gate seed, not commit truth. Infra hiccups must not - // fail the successor — worst case the gate re-checks one more round (budget-bounded). - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-cursor-ack-fail', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - originTriggerMessageId: 'msg-origin', - draftContent: 'old draft', - requiredMessageIds: ['msg-a'], - requiredFrontierMessageId: 'msg-a', - observedRawFrontierMessageId: 'msg-a', - now: 100, - }); - const routeCalls = []; - const failingAck = mock.fn(async () => { - throw new Error('redis unavailable'); - }); - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - deliveryCursorStore: { - ackSeenCursor: failingAck, - }, - messageStore: { - ...stubDeps().messageStore, - getLatestThreadMessageIdIncludingQueued: mock.fn(async () => 'msg-a'), - getByThreadAfter: mock.fn(async () => []), - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: 'peer-cat', - content: `body:${id}`, - mentions: [], - timestamp: 100, - })), - }, - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push(args); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, - idempotencyKey: `freshness-closure:${closure.id}`, - }); - const processing = customDeps.queue.markProcessing('t1', 'u1'); - - const result = await customProcessor.executeEntry(processing); - - assert.equal(result.status, 'succeeded'); - assert.equal(failingAck.mock.callCount(), 1, 'the ack must actually be attempted'); - assert.equal(routeCalls.length, 1, 'model execution must proceed despite ack failure'); - }); - - it('IR-10: rescans retry currency and injects original intent, latest draft, and current relevant updates', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-current-preflight', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - originTriggerMessageId: '0001-origin', - draftContent: 'the retained stale draft', - requiredMessageIds: ['0002-required'], - requiredFrontierMessageId: '0002-required', - observedRawFrontierMessageId: '0002-required', - now: 100, - }); - const messages = [ - { - id: '0001-origin', - threadId: 't1', - userId: 'u1', - catId: null, - content: 'the original user intent', - mentions: ['opus'], - timestamp: 100, - }, - { - id: '0002-required', - threadId: 't1', - userId: 'u1', - catId: null, - content: 'the first late fact', - mentions: ['opus'], - timestamp: 110, - }, - { - id: '0003-directed-away', - threadId: 't1', - userId: 'u1', - catId: null, - content: 'fable-only steer must not enter opus prompt', - mentions: ['fable5'], - timestamp: 120, - }, - { - id: '0004-current', - threadId: 't1', - userId: 'u1', - catId: null, - content: 'the current relevant update', - mentions: ['opus'], - timestamp: 130, - }, - ]; - const routeCalls = []; - const ackCalls = []; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - deliveryCursorStore: { - ackSeenCursor: mock.fn(async (...args) => ackCalls.push(args)), - }, - messageStore: { - ...stubDeps().messageStore, - getLatestThreadMessageIdIncludingQueued: mock.fn(async () => '0004-current'), - getByThreadAfter: mock.fn(async (_threadId, afterId, limit) => - messages.filter((message) => message.id > (afterId ?? '')).slice(0, limit), - ), - getById: mock.fn(async (id) => messages.find((message) => message.id === id) ?? null), - }, - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push(args); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, - idempotencyKey: `freshness-closure:${closure.id}`, - }); - - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); - - assert.equal(result.status, 'succeeded'); - assert.equal(routeCalls.length, 1); - const prompt = routeCalls[0][1]; - assert.match(prompt, /\[Original intent\]/); - assert.match(prompt, /the original user intent/); - assert.match(prompt, /\[Latest retained draft\]/); - assert.match(prompt, /the retained stale draft/); - assert.match(prompt, /\[Current relevant updates\]/); - assert.match(prompt, /the first late fact/); - assert.match(prompt, /the current relevant update/); - assert.doesNotMatch(prompt, /fable-only steer/); - const refreshed = await closureStore.get(closure.id); - assert.deepEqual(refreshed.requiredMessageIds, ['0002-required', '0004-current']); - assert.equal(refreshed.observedRawFrontierMessageId, '0004-current'); - assert.equal(ackCalls[0][3], '0004-current'); - }); - - it('IR-10: blocks a legacy closure with no origin identity before model execution', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-missing-origin', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - draftContent: 'old draft', - requiredMessageIds: ['0002-required'], - requiredFrontierMessageId: '0002-required', - observedRawFrontierMessageId: '0002-required', - now: 100, - }); - const routeExecution = mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getLatestThreadMessageIdIncludingQueued: mock.fn(async () => '0002-required'), - getByThreadAfter: mock.fn(async () => []), - }, - router: { routeExecution, ackCollectedCursors: mock.fn(async () => {}) }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, - idempotencyKey: `freshness-closure:${closure.id}`, - }); - - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); - - assert.equal(result.status, 'failed'); - assert.equal(routeExecution.mock.callCount(), 0); - const blocked = await closureStore.get(closure.id); - assert.equal(blocked.status, 'blocked'); - assert.equal(blocked.blockedReason, 'freshness_preflight_incomplete'); - assert.deepEqual(blocked.blockedEvidenceRefs, ['origin-trigger:missing-identity']); - }); - - it('IR-10: blocks an incomplete raw-frontier scan before model execution', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-incomplete-scan', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - originTriggerMessageId: '0001-origin', - draftContent: 'old draft', - requiredMessageIds: ['0002-required'], - requiredFrontierMessageId: '0002-required', - observedRawFrontierMessageId: '0002-required', - now: 100, - }); - const routeExecution = mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getLatestThreadMessageIdIncludingQueued: mock.fn(async () => '0003-queued-gap'), - getByThreadAfter: mock.fn(async () => []), - getById: mock.fn(async (id) => ({ - id, - threadId: 't1', - userId: 'u1', - catId: null, - content: `body:${id}`, - mentions: ['opus'], - timestamp: 100, - })), - }, - router: { routeExecution, ackCollectedCursors: mock.fn(async () => {}) }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - freshnessRequiredFrontierMessageId: closure.requiredFrontierMessageId, - idempotencyKey: `freshness-closure:${closure.id}`, - }); - - const result = await customProcessor.executeEntry(customDeps.queue.markProcessing('t1', 'u1')); - - assert.equal(result.status, 'failed'); - assert.equal(routeExecution.mock.callCount(), 0); - const blocked = await closureStore.get(closure.id); - assert.equal(blocked.status, 'blocked'); - assert.equal(blocked.blockedReason, 'freshness_preflight_incomplete'); - assert.deepEqual(blocked.blockedEvidenceRefs, ['raw-frontier:incomplete:0002-required:0003-queued-gap']); - }); - - it('F254 Phase E: closes an append-recovered closure without another model pass', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-append-recovered', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - draftContent: 'already committed draft', - requiredMessageIds: ['msg-required'], - requiredFrontierMessageId: 'msg-required', - observedRawFrontierMessageId: 'msg-required', - now: 100, - }); - const committedMessage = { - id: 'msg-already-committed', - threadId: 't1', - userId: 'u1', - catId: 'opus', - content: 'already committed draft', - mentions: [], - timestamp: 110, - }; - const customDeps = stubDeps({ - freshnessClosureStore: closureStore, - messageStore: { - ...stubDeps().messageStore, - getByIdempotencyKey: mock.fn(async (_userId, _threadId, key) => - key === `freshness-closure:${closure.id}:final` ? committedMessage : null, - ), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - idempotencyKey: `freshness-closure:${closure.id}`, - }); - const processing = customDeps.queue.markProcessing('t1', 'u1'); - - const result = await customProcessor.executeEntry(processing); - - assert.equal(result.status, 'succeeded'); - assert.equal(customDeps.router.routeExecution.mock.calls.length, 0, 'recovery must not invoke the model'); - assert.equal(customDeps.invocationTracker.startAll.mock.calls.length, 0); - const committedClosure = await closureStore.get(closure.id); - assert.equal(committedClosure.status, 'committed'); - assert.equal(committedClosure.committedMessageId, committedMessage.id); - assert.equal( - customDeps.messageStore.getByIdempotencyKey.mock.calls[0].arguments[2], - `freshness-closure:${closure.id}:final`, - ); - }); - - it('F254 Phase E: cancels a redundant successor before model execution', async () => { - const closureStore = new InMemoryFreshnessClosureStore(); - const closure = await closureStore.openOrAdvance({ - closureId: 'closure-running', - userId: 'u1', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-base', - draftContent: 'old draft', - requiredMessageIds: ['msg-required'], - requiredFrontierMessageId: 'msg-required', - observedRawFrontierMessageId: 'msg-required', - now: 100, - }); - await closureStore.claimAttempt(closure.id, { - invocationId: 'inv-already-running', - inputFrontierMessageId: 'msg-required', - observedRawFrontierMessageId: 'msg-required', - now: 110, - }); - const customDeps = stubDeps({ freshnessClosureStore: closureStore }); - const customProcessor = new QueueProcessor(customDeps); - enqueueEntry(customDeps.queue, { - source: 'agent', - sourceCategory: 'freshness', - autoExecute: true, - freshnessClosureId: closure.id, - }); - const processing = customDeps.queue.markProcessing('t1', 'u1'); - - const result = await customProcessor.executeEntry(processing); - - assert.equal(result.status, 'succeeded'); - assert.equal(customDeps.router.routeExecution.mock.calls.length, 0); - assert.equal(customDeps.invocationTracker.startAll.mock.calls.length, 0); - }); - - it('succeeded + queue has entries → auto-dequeues and starts execution', async () => { - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - - // Should have started execution (invocationTracker.start called) - assert.ok(deps.invocationTracker.startAll.mock.calls.length > 0); - // Entry should be marked processing then removed - // Wait a tick for background execution - await new Promise((r) => setTimeout(r, 50)); - }); - - it('issue #845: done event with metadata.usage → invocation.update writes usageByCat', async () => { - // Reproduce the QueueProcessor execution path where a routed done event carries - // metadata.usage. Prior to the fix, executeEntry only wrote `status: succeeded` - // without `usageByCat`, leaving 159+ historical succeeded invocations with empty - // usage in production. The Phase A fix mirrors the messages.ts collectedUsage - // pattern so the queue path now persists per-cat token usage. - const customDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { - type: 'done', - catId: 'opus', - timestamp: Date.now(), - metadata: { - provider: 'claude', - model: 'claude-opus-4-7', - usage: { inputTokens: 1234, outputTokens: 567, cacheReadTokens: 100, costUsd: 0.05 }, - }, - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - const entry = enqueueEntry(customDeps.queue); - customDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await customProcessor.onInvocationComplete('t1', 'opus', 'succeeded'); - // Wait for background executeEntry to finish (it's spawned in setImmediate). - await new Promise((r) => setTimeout(r, 100)); - - const updateCalls = customDeps.invocationRecordStore.update.mock.calls; - const succeededCall = updateCalls.find((c) => c.arguments[1]?.status === 'succeeded'); - assert.ok(succeededCall, 'expected an update(...,{status:succeeded,...}) call'); - const payload = succeededCall.arguments[1]; - assert.ok(payload.usageByCat, 'usageByCat must be present on the succeeded update'); - assert.deepEqual(payload.usageByCat.opus, { - inputTokens: 1234, - outputTokens: 567, - cacheReadTokens: 100, - costUsd: 0.05, - }); - }); - - it('issue #845: done event without metadata.usage → succeeded update omits usageByCat', async () => { - // Guard the opposite direction: when a provider does not emit usage on done, - // we must not write an empty usageByCat (would mask the diagnostic that the - // provider is dropping usage upstream). - const customDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - const entry = enqueueEntry(customDeps.queue); - customDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await customProcessor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 100)); - - const updateCalls = customDeps.invocationRecordStore.update.mock.calls; - const succeededCall = updateCalls.find((c) => c.arguments[1]?.status === 'succeeded'); - assert.ok(succeededCall, 'expected an update(...,{status:succeeded,...}) call'); - assert.equal( - succeededCall.arguments[1].usageByCat, - undefined, - 'usageByCat must remain undefined when provider emitted no usage', - ); - }); - - it('governance-blocked queue invocation stays failed and never flips to succeeded', async () => { - const customDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ - type: 'governance_blocked', - projectPath: '/home/user/projects/EchoAgent', - reasonKind: 'needs_bootstrap', - invocationId: 'inv-stub', - }), - timestamp: Date.now(), - }; - yield { - type: 'done', - catId: 'opus', - timestamp: Date.now(), - errorCode: 'GOVERNANCE_BOOTSTRAP_REQUIRED', - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - const entry = enqueueEntry(customDeps.queue); - customDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await customProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true, 'queue entry should start executing'); - await new Promise((r) => setTimeout(r, 100)); - - const updateCalls = customDeps.invocationRecordStore.update.mock.calls; - const failedCall = updateCalls.find( - (c) => c.arguments[1]?.status === 'failed' && c.arguments[1]?.error === 'GOVERNANCE_BOOTSTRAP_REQUIRED', - ); - assert.ok(failedCall, 'queue path must persist governance-blocked invocations as failed'); - - const succeededCall = updateCalls.find((c) => c.arguments[1]?.status === 'succeeded'); - assert.equal(succeededCall, undefined, 'governance-blocked queue invocations must not be finalized as succeeded'); - }); - - it('succeeded + stale user queued entry → auto-dequeues and starts execution', async () => { - const entry = enqueueEntry(deps.queue, { source: 'user' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - deps.queue.list('t1', 'u1')[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - - assert.ok( - deps.invocationTracker.startAll.mock.calls.length > 0, - 'stale user queued entry is still pending work and should be dispatched on completion', - ); - }); - - it('succeeded + stale connector queued entry → auto-dequeues and starts execution', async () => { - const entry = enqueueEntry(deps.queue, { source: 'connector' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-connector-1'); - deps.queue.list('t1', 'u1')[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - - assert.ok( - deps.invocationTracker.startAll.mock.calls.length > 0, - 'stale connector queued entry is still pending work and should be dispatched on completion', - ); - }); - - it('succeeded + empty queue → no action', async () => { - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - assert.equal(deps.invocationTracker.startAll.mock.calls.length, 0); - }); - - describe('F254 D1.2b queued handled closure', () => { - it('succeeded + seen single-target queued entry → consumes entry and marks delivered', async () => { - deps.freshnessEventLog = { append: mock.fn(async () => {}) }; - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - assert.equal(deps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-opus-1'), true); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-opus-1', ['opus']); - - assert.equal(deps.queue.list('t1', 'u1').length, 0, 'seen single-target entry should be consumed'); - assert.deepEqual( - deps.messageStore.markDelivered.mock.calls.map((call) => call.arguments[0]), - ['msg-1'], - 'fully consumed queued messages should be marked delivered', - ); - const deliveredCall = deps.socketManager.emitToUser.mock.calls.find( - (call) => call.arguments[1] === 'messages_delivered', - ); - assert.ok(deliveredCall, 'fully consumed queued messages should be emitted to the live timeline'); - assert.equal(deliveredCall.arguments[0], 'u1'); - assert.deepEqual(deliveredCall.arguments[2].messageIds, ['msg-1']); - assert.deepEqual( - deliveredCall.arguments[2].messages.map((message) => message.id), - ['msg-1'], - ); - assert.equal( - deps.invocationTracker.startAll.mock.calls.length, - 0, - 'handled entry should not be auto-dispatched again', - ); - assert.equal(deps.freshnessEventLog.append.mock.calls.length, 1); - assert.deepEqual(deps.freshnessEventLog.append.mock.calls[0].arguments[0], { - kind: 'queued_handled', - threadId: 't1', - catId: 'opus', - invocationId: 'inv-opus-1', - timestamp: deps.freshnessEventLog.append.mock.calls[0].arguments[0].timestamp, - queueEntryId: entry.id, - messageIds: ['msg-1'], - disposition: 'completed_with_turn', - evidenceRef: { kind: 'turn_execution', invocationId: 'inv-opus-1' }, - remainingTargetCats: [], - }); - assert.equal( - queuedTelemetry.getFreshnessQueueTelemetrySnapshot().queuedHandledTotal, - 1, - 'successful queued_handled closure should count once', - ); - assert.equal( - queuedTelemetry.getFreshnessQueueTelemetrySnapshot().queuedHandledFullyConsumedTotal, - 1, - 'fully consumed queued_handled closure should count once', - ); - }); - - it('delivery persistence failure restores fully consumed queued entry and suppresses handled evidence', async () => { - deps.freshnessEventLog = { append: mock.fn(async () => {}) }; - deps.messageStore.markDelivered = mock.fn(async () => { - throw new Error('redis unavailable'); - }); - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - assert.equal(deps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-opus-1'), true); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-opus-1', ['opus']); - - const remaining = deps.queue.list('t1', 'u1'); - assert.equal(remaining.length, 1, 'delivery failure must not drop still-queued work'); - assert.equal(remaining[0].id, entry.id); - assert.deepEqual(remaining[0].targetCats, ['opus']); - assert.deepEqual(remaining[0].queuedSeenByCatIds, ['opus']); - assert.deepEqual(remaining[0].queuedSeenInvocationIdByCatId, { opus: 'inv-opus-1' }); - assert.equal( - deps.freshnessEventLog.append.mock.calls.length, - 0, - 'queued_handled evidence is only valid after delivery persistence succeeds', - ); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some((call) => call.arguments[1] === 'messages_delivered'), - false, - 'failed delivery persistence must not emit messages_delivered', - ); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some((call) => call.arguments[1] === 'queue_updated'), - false, - 'restored queue state should not publish a handled queue update', - ); - assert.equal( - queuedTelemetry.getFreshnessQueueTelemetrySnapshot().queuedHandledTotal, - 0, - 'delivery persistence failure must not count queued_handled', - ); - }); - - it('partial delivery failure still emits already-transitioned messages before restoring queue entry', async () => { - deps.freshnessEventLog = { append: mock.fn(async () => {}) }; - deps.messageStore.markDelivered = mock.fn(async (id) => { - if (id === 'msg-2') throw new Error('redis unavailable'); - return { - id, - threadId: 't1', - content: `delivered:${id}`, - catId: null, - timestamp: Date.now(), - mentions: [], - userId: 'u1', - deliveryStatus: 'delivered', - deliveredAt: Date.now(), - deliveryTransitioned: true, - }; - }); - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-2'); - assert.equal(deps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-opus-1'), true); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-opus-1', ['opus']); - - const remaining = deps.queue.list('t1', 'u1'); - assert.equal(remaining.length, 1, 'partial delivery failure must restore the queue entry'); - assert.equal(remaining[0].id, entry.id); - assert.deepEqual(remaining[0].targetCats, ['opus']); - assert.equal( - deps.freshnessEventLog.append.mock.calls.length, - 0, - 'queued_handled evidence is only valid after every queued message delivery succeeds', - ); - assert.equal( - queuedTelemetry.getFreshnessQueueTelemetrySnapshot().queuedHandledTotal, - 0, - 'partial delivery failure must not count queued_handled', - ); - const deliveredCall = deps.socketManager.emitToUser.mock.calls.find( - (call) => call.arguments[1] === 'messages_delivered', - ); - assert.ok(deliveredCall, 'already-transitioned messages must still reach the live timeline'); - assert.deepEqual(deliveredCall.arguments[2].messageIds, ['msg-1']); - assert.deepEqual( - deliveredCall.arguments[2].messages.map((message) => message.id), - ['msg-1'], - ); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some((call) => call.arguments[1] === 'queue_updated'), - false, - 'restored queue state should not publish a handled queue update', - ); - }); - - it('succeeded + seen multi-target queued entry → removes only completing cat and preserves delivery', async () => { - const entry = enqueueEntry(deps.queue, { targetCats: ['opus', 'codex'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - assert.equal(deps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-opus-1'), true); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-opus-1', ['opus']); - - const remaining = deps.queue.list('t1', 'u1'); - assert.equal(remaining.length, 1, 'multi-target entry should remain for other cats'); - assert.deepEqual(remaining[0].targetCats, ['codex']); - assert.equal( - deps.messageStore.markDelivered.mock.calls.length, - 0, - 'partially consumed queued message is not delivered until all targets are handled', - ); - assert.equal( - deps.socketManager.emitToUser.mock.calls.some((call) => call.arguments[1] === 'messages_delivered'), - false, - 'partially consumed queued message should not be emitted as delivered yet', - ); - }); - - it('failed + seen queued entry → does not consume entry', async () => { - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - assert.equal(deps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-opus-1'), true); - - await processor.onInvocationComplete('t1', 'opus', 'failed', undefined, ['opus']); - - assert.equal(deps.queue.list('t1', 'u1').length, 1, 'failed invocation should keep queued work retryable'); - assert.equal(deps.messageStore.markDelivered.mock.calls.length, 0); - }); - - it('succeeded + processing entry → does not consume entry', async () => { - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - assert.equal(deps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-opus-1'), true); - assert.ok(deps.queue.markProcessing('t1', 'u1')); - - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-opus-1', ['opus']); - - assert.equal(deps.queue.list('t1', 'u1').length, 1, 'processing entry should remain owned by execution cleanup'); - assert.equal(deps.messageStore.markDelivered.mock.calls.length, 0); - }); - - it('succeeded after a different invocation saw then failed → does not mark stale seen entry handled', async () => { - deps.freshnessEventLog = { append: mock.fn(async () => {}) }; - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - assert.equal(deps.queue.markQueuedSeen('t1', 'u1', entry.id, 'opus', 'inv-failed'), true); - - await processor.onInvocationComplete('t1', 'opus', 'failed', 'inv-failed', ['opus']); - await processor.onInvocationComplete('t1', 'opus', 'succeeded', 'inv-unrelated-success', ['opus']); - - assert.equal( - deps.messageStore.markDelivered.mock.calls.length, - 0, - 'unrelated success must not deliver a queued body read by a failed invocation', - ); - assert.equal(deps.freshnessEventLog.append.mock.calls.length, 0); - }); - - it('queued execution completion passes its invocationId to queued handled closure', async () => { - let queuedFreshnessEntry; - const customDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - assert.equal( - customDeps.queue.markQueuedSeen('t1', 'u1', queuedFreshnessEntry.id, 'opus', 'inv-stub'), - true, - ); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - const activeEntry = enqueueEntry(customDeps.queue, { source: 'user', content: 'run current work' }); - customDeps.queue.backfillMessageId('t1', 'u1', activeEntry.id, 'msg-active'); - queuedFreshnessEntry = enqueueEntry(customDeps.queue, { - source: 'connector', - content: 'queued body read by current invocation', - }); - customDeps.queue.backfillMessageId('t1', 'u1', queuedFreshnessEntry.id, 'msg-queued'); - - const result = await customProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - await waitFor(() => customDeps.messageStore.markDelivered.mock.calls.length > 0); - - assert.ok( - customDeps.messageStore.markDelivered.mock.calls.some((call) => call.arguments[0] === 'msg-queued'), - 'queued body read by this queue-created invocation should be delivered on success', - ); - assert.equal( - customDeps.invocationTracker.startAll.mock.calls.length, - 1, - 'handled queued body should not be dispatched again after the current invocation succeeds', - ); - }); - - it('aggregate succeeded handles only cats with done evidence', async () => { - let codexQueuedEntry; - const customDeps = stubDeps({ - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - completeSlot: mock.fn(), - has: mock.fn((_threadId, catId) => catId === 'codex'), - resolveFinalStatus: mock.fn(() => 'succeeded'), - }, - router: { - routeExecution: mock.fn(async function* () { - assert.equal(customDeps.queue.markQueuedSeen('t1', 'u1', codexQueuedEntry.id, 'codex', 'inv-stub'), true); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - const activeEntry = enqueueEntry(customDeps.queue, { - source: 'user', - content: 'multi-cat current work', - targetCats: ['opus', 'codex'], - }); - customDeps.queue.backfillMessageId('t1', 'u1', activeEntry.id, 'msg-active'); - codexQueuedEntry = enqueueEntry(customDeps.queue, { - source: 'connector', - content: 'codex queued body read before cancel', - targetCats: ['codex'], - }); - customDeps.queue.backfillMessageId('t1', 'u1', codexQueuedEntry.id, 'msg-codex-queued'); - - const result = await customProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - await new Promise((r) => setTimeout(r, 50)); - - assert.equal( - customDeps.messageStore.markDelivered.mock.calls.some((call) => call.arguments[0] === 'msg-codex-queued'), - false, - 'cat without done evidence must not close queued_handled even when aggregate status succeeds', - ); - assert.ok( - customDeps.queue.list('t1', 'u1').some((entry) => entry.id === codexQueuedEntry.id), - 'queued work for the canceled/non-done cat remains retryable', - ); - }); - - it('duplicate queue invocation does not close old queued_seen evidence', async () => { - let trackerHasCalls = 0; - const customDeps = stubDeps({ - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - completeSlot: mock.fn(), - has: mock.fn((_threadId, catId) => { - trackerHasCalls += 1; - return trackerHasCalls > 1 && catId === 'opus'; - }), - resolveFinalStatus: mock.fn(() => 'succeeded'), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ - outcome: 'duplicate', - invocationId: 'inv-duplicate', - })), - update: mock.fn(async () => {}), - }, - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const customProcessor = new QueueProcessor(customDeps); - const activeEntry = enqueueEntry(customDeps.queue, { source: 'connector', content: 'duplicate queued work' }); - customDeps.queue.backfillMessageId('t1', 'u1', activeEntry.id, 'msg-active'); - const queuedEntry = enqueueEntry(customDeps.queue, { source: 'connector', content: 'old seen body' }); - customDeps.queue.backfillMessageId('t1', 'u1', queuedEntry.id, 'msg-old-seen'); - assert.equal(customDeps.queue.markQueuedSeen('t1', 'u1', queuedEntry.id, 'opus', 'inv-duplicate'), true); - - const result = await customProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - await new Promise((r) => setTimeout(r, 50)); - - assert.equal( - customDeps.router.routeExecution.mock.calls.length, - 0, - 'duplicate path should not run provider work', - ); - assert.equal( - customDeps.messageStore.markDelivered.mock.calls.some((call) => call.arguments[0] === 'msg-old-seen'), - false, - 'duplicate path must not turn old queued_seen evidence into queued_handled', - ); - assert.ok( - customDeps.queue.list('t1', 'u1').some((entry) => entry.id === queuedEntry.id), - 'old queued body remains retryable after duplicate path', - ); - }); - }); - - it('canceled → pauses queue, emits queue_paused', async () => { - enqueueEntry(deps.queue); - - await processor.onInvocationComplete('t1', 'opus', 'canceled'); - - // Should NOT start new execution - assert.equal(deps.invocationTracker.startAll.mock.calls.length, 0); - // Should emit queue_paused - const emitCalls = deps.socketManager.emitToUser.mock.calls; - assert.ok(emitCalls.length > 0); - const pausedCall = emitCalls.find((c) => c.arguments[1] === 'queue_paused'); - assert.ok(pausedCall, 'should emit queue_paused'); - assert.equal(pausedCall.arguments[2].reason, 'canceled'); - }); - - it('failed + stale user queued entry → pauses queue instead of treating it as empty', async () => { - const entry = enqueueEntry(deps.queue, { source: 'user' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - deps.queue.list('t1', 'u1')[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - await processor.onInvocationComplete('t1', 'opus', 'failed'); - - assert.equal(processor.isPaused('t1', 'opus'), true, 'stale user work should still keep the slot paused'); - const pausedCall = deps.socketManager.emitToUser.mock.calls.find((c) => c.arguments[1] === 'queue_paused'); - assert.ok(pausedCall, 'should emit queue_paused for stale user work'); - assert.equal(pausedCall.arguments[2].reason, 'failed'); - }); - - it('canceled + stale connector queued entry → pauses queue instead of treating it as empty', async () => { - const entry = enqueueEntry(deps.queue, { source: 'connector' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-connector-1'); - deps.queue.list('t1', 'u1')[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - await processor.onInvocationComplete('t1', 'opus', 'canceled'); - - assert.equal(processor.isPaused('t1', 'opus'), true, 'stale connector work should still keep the slot paused'); - const pausedCall = deps.socketManager.emitToUser.mock.calls.find((c) => c.arguments[1] === 'queue_paused'); - assert.ok(pausedCall, 'should emit queue_paused for stale connector work'); - assert.equal(pausedCall.arguments[2].reason, 'canceled'); - }); - - it('failed + unrelated auto-continuation → pauses failed cat queued work', async () => { - const queuedWork = enqueueEntry(deps.queue, { targetCats: ['opus'], source: 'user', content: 'opus queued work' }); - deps.queue.backfillMessageId('t1', 'u1', queuedWork.id, 'msg-opus-work'); - const codexCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'codex', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-codex-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-codex', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const continuation = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'codex', - capsule: codexCapsule, - }); - assert.equal(continuation.outcome, 'enqueued'); - - await processor.onInvocationComplete('t1', 'opus', 'failed'); - - assert.equal(processor.isPaused('t1', 'opus'), true, 'unrelated continuation must not bypass failed opus pause'); - assert.equal( - deps.invocationTracker.startAll.mock.calls.length, - 0, - 'unrelated codex continuation should not be started by opus failure cleanup', - ); - const pausedCall = deps.socketManager.emitToUser.mock.calls.find((c) => c.arguments[1] === 'queue_paused'); - assert.ok(pausedCall, 'should emit queue_paused for failed opus work'); - assert.equal(pausedCall.arguments[2].reason, 'failed'); - }); - - it('failed-slot recovery does not hot-loop a requeued unrelated continuation', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const routedTargetCats = []; - const completionCalls = []; - const completeInvocation = processor.onInvocationComplete.bind(processor); - processor.onInvocationComplete = async (...args) => { - completionCalls.push({ catId: args[1], status: args[2], primaryEntryRequeued: args[5] }); - if (completionCalls.length > 6) return; - return completeInvocation(...args); - }; - deps.router.routeExecution = mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - routedTargetCats.push([...targetCats]); - if (routedTargetCats.length > 3) throw new Error('recovery dispatch loop fuse'); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }); - const queuedWork = enqueueEntry(deps.queue, { targetCats: ['opus'], source: 'user', content: 'opus queued work' }); - deps.queue.backfillMessageId('t1', 'u1', queuedWork.id, 'msg-opus-work'); - const codexCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'codex', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-codex-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-codex', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const continuation = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'codex', - capsule: codexCapsule, - }); - assert.equal(continuation.outcome, 'enqueued'); - - await processor.onInvocationComplete('t1', 'opus', 'failed'); - t.mock.timers.tick(10_000); - for (let turn = 0; turn < 4; turn += 1) await new Promise(setImmediate); - - assert.deepEqual( - routedTargetCats, - [['opus'], ['codex']], - `the failed slot owns recovery and a failed continuation gets one attempt; completion calls: ${JSON.stringify(completionCalls)}`, - ); - const remaining = deps.queue.list('t1', 'u1'); - assert.equal(remaining.length, 1); - assert.equal(remaining[0].targetCats[0], 'codex'); - assert.equal(remaining[0].sourceCategory, 'continuation'); - assert.equal(remaining[0].status, 'queued'); - }); - - it('failed + stale user queued entry → #595 auto-recovery starts dispatch after pause delay', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const entry = enqueueEntry(deps.queue, { source: 'user' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - deps.queue.list('t1', 'u1')[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - await processor.onInvocationComplete('t1', 'opus', 'failed'); - assert.equal(processor.isPaused('t1', 'opus'), true); - - t.mock.timers.tick(10_000); - - assert.equal(deps.queue.list('t1', 'u1')[0].status, 'processing'); - assert.equal(processor.isPaused('t1', 'opus'), false); - }); - - it('isThreadBusy treats stale queued user work as busy until it is dispatched or cleared', () => { - enqueueEntry(deps.queue, { source: 'user' }); - deps.queue.list('t1', 'u1')[0].createdAt = Date.now() - InvocationQueue.STALE_QUEUED_THRESHOLD_MS - 1; - - assert.equal(deps.queue.hasQueuedForThread('t1'), false, 'freshness gate should ignore stale user work'); - assert.equal(processor.isThreadBusy('t1'), true, 'delivery-batch-done must not close while stale work is pending'); - }); - - it('canceled_by_user → auto-dequeues and does not emit queue_paused', async () => { - deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - content: 'resume after cancel', - source: 'user', - ownerAuthProvenance: 'unknown', - targetCats: ['opus'], - intent: 'execute', - }); - - await processor.onInvocationComplete('t1', 'opus', 'canceled_by_user'); - await new Promise((resolve) => setTimeout(resolve, 80)); - - assert.ok(deps.invocationTracker.startAll.mock.calls.length > 0, 'user cancel should auto-resume queued work'); - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const pausedCall = emitCalls.find((c) => c.arguments[1] === 'queue_paused'); - assert.equal(pausedCall, undefined, 'user cancel should not pause the queue'); - }); - - it('canceled with processing-only queue → does not emit queue_paused', async () => { - enqueueEntry(deps.queue); - // Simulate steer immediate: queued entry is promoted to processing before the canceled cleanup runs. - deps.queue.markProcessing('t1', 'u1'); - - await processor.onInvocationComplete('t1', 'opus', 'canceled'); - - assert.equal(processor.isPaused('t1'), false); - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const pausedCall = emitCalls.find((c) => c.arguments[1] === 'queue_paused'); - assert.equal(pausedCall, undefined); - }); - - it('user cancel during queued execution stops broadcasting late agent events', async () => { - let controller; - deps.invocationTracker.startAll.mock.mockImplementation(() => { - controller = new AbortController(); - return controller; - }); - deps.router.routeExecution = mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'before cancel', timestamp: Date.now() }; - controller.abort('user_cancel'); - yield { type: 'text', catId: 'opus', content: 'after cancel', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }); - - enqueueEntry(deps.queue); - - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, true); - await new Promise((resolve) => setTimeout(resolve, 80)); - - const broadcasts = deps.socketManager.broadcastAgentMessage.mock.calls.map((call) => call.arguments[0]); - assert.ok( - broadcasts.some((msg) => msg.type === 'text' && msg.content === 'before cancel'), - 'pre-cancel text should be broadcast', - ); - assert.equal( - broadcasts.some((msg) => msg.type === 'text' && msg.content === 'after cancel'), - false, - 'post-cancel text must not be broadcast', - ); - assert.equal( - broadcasts.some((msg) => msg.type === 'done' && msg.catId === 'opus'), - false, - 'post-cancel done from the stale producer must not be broadcast', - ); - - const canceledUpdate = deps.invocationRecordStore.update.mock.calls.find( - (call) => call.arguments[1]?.status === 'canceled', - ); - assert.ok(canceledUpdate, 'aborted queued invocation should be recorded as canceled'); - }); - - it('user cancel during queued execution cleans up streaming placeholders', async () => { - let controller; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - deps.invocationTracker.startAll.mock.mockImplementation(() => { - controller = new AbortController(); - return controller; - }); - deps.router.routeExecution = mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'before cancel', timestamp: Date.now() }; - controller.abort('user_cancel'); - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }); - const cancelProcessor = new QueueProcessor({ ...deps, streamingHook }); - - enqueueEntry(deps.queue); - - const result = await cancelProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - await waitFor(() => streamingHook.cleanupPlaceholders.mock.calls.length >= 1); - - assert.ok(streamingHook.onStreamStart.mock.calls.length >= 1, 'onStreamStart should be called'); - assert.ok(streamingHook.onStreamEnd.mock.calls.length >= 1, 'onStreamEnd should be called on cancel'); - assert.equal( - streamingHook.cleanupPlaceholders.mock.calls.length, - 1, - 'cancel path should clean up the streaming placeholder exactly once', - ); - }); - - it('user cancel consumes the attempted durable Queue carrier instead of silently requeueing it', async () => { - const durableStore = new MessageStore(); - let controller; - const durableDeps = stubDeps({ - messageStore: durableStore, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => { - controller = new AbortController(); - return controller; - }), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - }, - router: { - routeExecution: mock.fn(async function* () { - controller.abort('user_cancel'); - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - durableDeps.queueCustodyCoordinator = new QueuedMessageCustodyCoordinator({ messageStore: durableStore }); - const durableProcessor = new QueueProcessor(durableDeps); - const { entry, message } = enqueueCustodiedEntry(durableDeps.queue, durableStore); - - await durableProcessor.processNext('t1', 'u1'); - await waitFor(() => - durableDeps.invocationRecordStore.update.mock.calls.some((call) => call.arguments[1]?.status === 'canceled'), - ); - await waitFor(() => durableDeps.queue.getEntrySnapshot('t1', 'u1', entry.id) === null); - - const stored = durableStore.getById(message.id); - assert.equal(stored.deliveryStatus, 'queued', 'terminal Queue custody must not masquerade as delivered work'); - assert.equal(stored.queueCustody.status, 'terminal'); - assert.deepEqual(stored.queueCustody.withdrawnByCatIds, ['opus']); - }); - - it('excludes the current processing agent entry from A2A cross-path dedup', async () => { - let dedupResult; - deps.router.routeExecution = mock.fn( - async function* (_userId, _content, threadId, _messageId, _targetCats, _intent, options) { - dedupResult = options.hasQueuedOrActiveAgentForCat(threadId, 'codex'); - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }, - ); - - deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - content: 'agent-sourced review request', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['opus-47', 'codex'], - intent: 'execute', - autoExecute: true, - callerCatId: 'opus', - }); - - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, true); - await new Promise((resolve) => setTimeout(resolve, 80)); - - assert.equal( - dedupResult, - false, - 'current processing entry targetCats must not make same-route A2A back to codex look already active', - ); - }); - - it('failed → pauses queue, emits queue_paused', async () => { - enqueueEntry(deps.queue); - - await processor.onInvocationComplete('t1', 'opus', 'failed'); - - assert.equal(deps.invocationTracker.startAll.mock.calls.length, 0); - const emitCalls = deps.socketManager.emitToUser.mock.calls; - const pausedCall = emitCalls.find((c) => c.arguments[1] === 'queue_paused'); - assert.ok(pausedCall); - assert.equal(pausedCall.arguments[2].reason, 'failed'); - }); - - // ── processNext ── - - it('processNext starts next entry when paused', async () => { - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, true); - assert.ok(result.entry); - }); - - it('PR7 continuation dequeue executes eligible siblings without reopening a failed target', async () => { - const routedTargetSets = []; - deps.router.routeExecution = mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - routedTargetSets.push([...targetCats]); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }); - processor = new QueueProcessor(deps); - const failed = enqueueEntry(deps.queue, { targetCats: ['opus'], source: 'agent', sourceCategory: 'a2a' }); - deps.queue.markQueuedFailedForCatAcrossUsers( - failed.threadId, - 'opus', - 'failed-opus', - new Set([failed.id]), - 'invocation_failed', - ); - enqueueEntry(deps.queue, { targetCats: ['codex'], source: 'agent', sourceCategory: 'a2a' }); - - const result = await processor.processNext('t1', 'u1'); - - assert.equal(result.started, true, 'the nonfailed sibling remains dispatchable'); - await waitFor(() => routedTargetSets.length === 1); - assert.deepEqual(routedTargetSets, [['codex']], 'ordinary dequeue must not reintroduce the failed sibling'); - }); - - it('PR7 continuation dequeue preserves an eligible sibling on the same carrier', async () => { - const routedTargetSets = []; - deps.router.routeExecution = mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - routedTargetSets.push([...targetCats]); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }); - processor = new QueueProcessor(deps); - const mixed = enqueueEntry(deps.queue, { - targetCats: ['opus', 'codex'], - source: 'agent', - sourceCategory: 'a2a', - }); - deps.queue.markQueuedFailedForCatAcrossUsers( - mixed.threadId, - 'opus', - 'failed-opus', - new Set([mixed.id]), - 'invocation_failed', - ); - - const result = await processor.processNext('t1', 'u1'); - - assert.equal(result.started, true, 'the carrier remains dispatchable for its nonfailed target'); - await waitFor(() => routedTargetSets.length === 1); - assert.deepEqual(routedTargetSets, [['codex']], 'provider custody must exclude the failed sibling'); - }); - - it('queued execution broadcasts intent_mode with invocationId when processing starts', async () => { - const entry = enqueueEntry(deps.queue, { targetCats: ['codex'], intent: 'execute' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 50)); - - const intentCall = deps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'intent_mode'); - assert.ok(intentCall, 'should broadcast intent_mode for queued execution'); - assert.deepEqual(intentCall.arguments[2], { - threadId: 't1', - mode: 'execute', - targetCats: ['codex'], - invocationId: 'inv-stub', - }); - }); - - it('queued execution broadcasts spawn_started before waiting for first CLI event', async () => { - let releaseFirstEvent; - deps.router.routeExecution = mock.fn(async function* () { - await new Promise((resolve) => { - releaseFirstEvent = resolve; - }); - yield { type: 'done', catId: 'codex', isFinal: true, timestamp: Date.now() }; - }); - - const entry = enqueueEntry(deps.queue, { targetCats: ['codex'], intent: 'execute' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 50)); - - const spawnCall = deps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'spawn_started'); - assert.ok(spawnCall, 'should broadcast spawn_started for queued execution before intent_mode'); - assert.deepEqual(spawnCall.arguments[2], { - threadId: 't1', - targetCats: ['codex'], - invocationId: 'inv-stub', - }); - - const earlyIntentCall = deps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'intent_mode'); - assert.equal(earlyIntentCall, undefined, 'intent_mode must stay deferred until the first CLI event'); - - releaseFirstEvent(); - await new Promise((r) => setTimeout(r, 50)); - - const intentCall = deps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'intent_mode'); - assert.ok(intentCall, 'intent_mode should broadcast after the first CLI event'); - }); - - it('emits queue_updated(action=completed) after entry is removed from queue', async () => { - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 50)); - - const queueUpdates = deps.socketManager.emitToUser.mock.calls - .filter((c) => c.arguments[1] === 'queue_updated') - .map((c) => c.arguments[2]); - const completed = queueUpdates.find((u) => u.action === 'completed'); - assert.ok(completed, 'should emit queue_updated completed after cleanup'); - assert.equal(completed.threadId, 't1'); - assert.deepEqual(completed.queue, [], 'queue snapshot should be empty after processed entry cleanup'); - }); - - it('processNext returns started=false when queue empty', async () => { - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, false); - }); - - // ── Mutex ── - - it('concurrent tryExecuteNext on same thread + same cat → only one starts (F108: per-slot mutex)', async () => { - // Make executeEntry slow - const slowDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - await new Promise((r) => setTimeout(r, 100)); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const slowProcessor = new QueueProcessor(slowDeps); - - // Both entries target same cat → same slot key - enqueueEntry(slowDeps.queue, { content: 'a', targetCats: ['opus'] }); - enqueueEntry(slowDeps.queue, { content: 'b', targetCats: ['opus'] }); - - // Fire two processNext concurrently - const [r1, r2] = await Promise.all([slowProcessor.processNext('t1', 'u1'), slowProcessor.processNext('t1', 'u1')]); - - // One should start, other should not (per-slot mutex) - const startedCount = [r1, r2].filter((r) => r.started).length; - assert.equal(startedCount, 1, 'only one should start due to per-slot mutex'); - }); - - // ── executeEntry creates InvocationRecord ── - - it('executeEntry creates InvocationRecord with queue idempotency key', async () => { - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - const createCalls = deps.invocationRecordStore.create.mock.calls; - assert.ok(createCalls.length > 0); - const createArg = createCalls[0].arguments[0]; - assert.ok(createArg.idempotencyKey.startsWith('queue-')); - }); - - it('connector-sourced entry uses connector-${messageId} idempotency key', async () => { - const entry = enqueueEntry(deps.queue, { source: 'connector' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-conn-1'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - const createCalls = deps.invocationRecordStore.create.mock.calls; - assert.ok(createCalls.length > 0); - const createArg = createCalls[0].arguments[0]; - assert.strictEqual(createArg.idempotencyKey, 'connector-msg-conn-1'); - }); - - it('copies a queued wait continuation carrier into the exact child InvocationRecord', async () => { - const waitContinuationCarrier = { - v: 1, - waitId: 'task-pr-7', - outcomeId: 'wait:pr:owner/repo#7:g3:matched', - ownerFence: { kind: 'containing_task', generation: 3 }, - }; - const entry = enqueueEntry(deps.queue, { - source: 'connector', - sourceCategory: 'review', - waitContinuationCarrier, - }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-wait-queued'); - - await processor.processNext('t1', 'u1'); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const createArg = deps.invocationRecordStore.create.mock.calls[0].arguments[0]; - assert.deepEqual(createArg.waitContinuationCarrier, waitContinuationCarrier); - assert.deepEqual(createArg.actionLeaseCarrier, { kind: 'none' }); - }); - - // ── P1-2 fix: isPaused state tracking ── - - it('isPaused returns true after canceled when queue has entries', async () => { - enqueueEntry(deps.queue); - assert.equal(processor.isPaused('t1'), false); - - await processor.onInvocationComplete('t1', 'opus', 'canceled'); - assert.equal(processor.isPaused('t1'), true); - - // processNext clears paused - await processor.processNext('t1', 'u1'); - assert.equal(processor.isPaused('t1'), false); - }); - - it('isPaused returns false when queue is empty even after failed', async () => { - // No entries in queue — no pause should be persisted - await processor.onInvocationComplete('t1', 'opus', 'failed'); - assert.equal(processor.isPaused('t1'), false); - - // Add entry → still not paused - enqueueEntry(deps.queue); - assert.equal(processor.isPaused('t1'), false); - - // Succeeded clears paused flag - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - assert.equal(processor.isPaused('t1'), false); - }); - - // ── P1 fix: chain auto-dequeue ── - - it('chain auto-dequeue: entry1 succeed → entry2 auto-starts', async () => { - deps.router.routeExecution = mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - for (const catId of targetCats) { - yield { type: 'done', catId, timestamp: Date.now() }; - } - }); - // Enqueue two entries from different users - const e1 = enqueueEntry(deps.queue, { userId: 'u1', content: 'first', targetCats: ['a'] }); - deps.queue.backfillMessageId('t1', 'u1', e1.id, 'msg-1'); - const e2 = enqueueEntry(deps.queue, { userId: 'u2', content: 'second', targetCats: ['b'] }); - deps.queue.backfillMessageId('t1', 'u2', e2.id, 'msg-2'); - - // Trigger first entry via onInvocationComplete('succeeded') - await processor.onInvocationComplete('t1', 'a', 'succeeded'); - - // Wait for both executions to complete (e1 finishes → chains → e2 starts) - await new Promise((r) => setTimeout(r, 200)); - - // Both entries should have been processed (tracker.start called twice) - assert.ok( - deps.invocationTracker.startAll.mock.calls.length >= 2, - `expected >=2 tracker.start calls, got ${deps.invocationTracker.startAll.mock.calls.length}`, - ); - }); - - it('#815: does not consume delivered historical A2A entries outside the current invocation context', async () => { - const active = enqueueEntry(deps.queue, { targetCats: ['opus'], content: 'current user work' }); - deps.queue.backfillMessageId('t1', 'u1', active.id, 'current-user-msg'); - const historicalA2A = enqueueEntry(deps.queue, { - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - autoExecute: true, - content: 'historical handoff', - }); - deps.queue.backfillMessageId('t1', 'u1', historicalA2A.id, 'historical-a2a-msg'); - deps.messageStore.getById = mock.fn(async (id) => { - if (id === 'historical-a2a-msg') { - return { id, deliveryStatus: 'delivered', content: 'historical handoff', mentions: [] }; - } - return null; - }); - - const processing = deps.queue.markProcessing('t1', 'u1'); - assert.equal(processing.id, active.id); - - const status = await processor.executeEntry(processing); - - assert.equal(status.status, 'succeeded'); - assert.ok( - deps.queue.list('t1', 'u1').some((entry) => entry.id === historicalA2A.id), - 'historical delivered A2A trigger was not in this invocation context and must stay queued', - ); - }); - - it('#1150: serializes a2a_subsumed behind an older same-scope queue publication', async () => { - let routeStarted; - let releaseRoute; - const routeStartedPromise = new Promise((resolve) => { - routeStarted = resolve; - }); - let olderLookupStarted; - let releaseOlderLookup; - const olderLookupStartedPromise = new Promise((resolve) => { - olderLookupStarted = resolve; - }); - const orderedDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - routeStarted(); - await new Promise((resolve) => { - releaseRoute = resolve; - }); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - messageStore: { - ...stubDeps().messageStore, - getById: mock.fn(async (id) => { - if (id === 'stalled-snapshot-msg') { - olderLookupStarted(); - await new Promise((resolve) => { - releaseOlderLookup = resolve; - }); - return null; - } - if (id === 'a2a-msg') { - return { id, deliveryStatus: 'delivered', content: 'subsumed handoff', mentions: [] }; - } - return null; - }), - }, - }); - const orderedProcessor = new QueueProcessor(orderedDeps); - const active = enqueueEntry(orderedDeps.queue, { targetCats: ['opus'], content: 'current user work' }); - orderedDeps.queue.backfillMessageId('t1', 'u1', active.id, 'current-user-msg'); - orderedDeps.queue.backfillMessageId('t1', 'u1', active.id, 'a2a-msg'); - const subsumedA2A = enqueueEntry(orderedDeps.queue, { - source: 'agent', - sourceCategory: 'a2a', - targetCats: ['opus'], - autoExecute: true, - content: 'subsumed handoff', - }); - orderedDeps.queue.backfillMessageId('t1', 'u1', subsumedA2A.id, 'a2a-msg'); - - const processing = orderedDeps.queue.markProcessing('t1', 'u1'); - assert.equal(processing.id, active.id); - const execution = orderedProcessor.executeEntry(processing); - await routeStartedPromise; - - const olderPublication = emitQueueUpdated( - orderedDeps.socketManager, - 'u1', - 't1', - [ - { - ...active, - id: 'older-snapshot', - messageId: 'stalled-snapshot-msg', - mergedMessageIds: [], - status: 'queued', - }, - ], - orderedDeps.messageStore, - 'older', - ); - await olderLookupStartedPromise; - releaseRoute(); - await new Promise((resolve) => setImmediate(resolve)); - - try { - const prematureActions = orderedDeps.socketManager.emitToUser.mock.calls - .filter((call) => call.arguments[1] === 'queue_updated') - .map((call) => call.arguments[2].action); - assert.equal( - prematureActions.includes('a2a_subsumed'), - false, - 'a2a_subsumed must wait behind the older same-scope publication tail', - ); - } finally { - releaseOlderLookup?.(); - await Promise.all([olderPublication, execution]); - } - - const queueActions = orderedDeps.socketManager.emitToUser.mock.calls - .filter((call) => call.arguments[1] === 'queue_updated') - .map((call) => call.arguments[2].action); - assert.ok(queueActions.indexOf('older') < queueActions.indexOf('a2a_subsumed')); - assert.ok(queueActions.indexOf('a2a_subsumed') < queueActions.indexOf('completed')); - }); - - it('uses SessionContinuationCoordinator to prepare context and commit outcome', async () => { - const routeContents = []; - const coordinator = { - prepareInvocationContext: mock.fn(async ({ content }) => ({ - content: `prepared:${content}`, - sessionPolicy: 'resume', - })), - commitInvocationOutcome: mock.fn(async () => {}), - }; - const coordinatorDeps = stubDeps({ - sessionContinuationCoordinator: coordinator, - router: { - routeExecution: mock.fn(async function* (_userId, content) { - routeContents.push(content); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const coordinatorProcessor = new QueueProcessor(coordinatorDeps); - const active = enqueueEntry(coordinatorDeps.queue, { targetCats: ['opus'], content: 'work' }); - coordinatorDeps.queue.backfillMessageId('t1', 'u1', active.id, 'current-user-msg'); - const processing = coordinatorDeps.queue.markProcessing('t1', 'u1'); - - const status = await coordinatorProcessor.executeEntry(processing); - - assert.equal(status.status, 'succeeded'); - assert.equal(coordinator.prepareInvocationContext.mock.calls.length, 1); - assert.deepEqual(coordinator.prepareInvocationContext.mock.calls[0].arguments[0], { - threadId: 't1', - catId: 'opus', - userId: 'u1', - content: 'work', - }); - assert.deepEqual(routeContents, ['prepared:work']); - assert.equal(coordinator.commitInvocationOutcome.mock.calls.length, 1); - assert.equal(coordinator.commitInvocationOutcome.mock.calls[0].arguments[0].finalStatus, 'succeeded'); - }); - - it('persists produced continuation even when it was already auto-queued', async () => { - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-queued-produced', - createdAt: Date.now(), - seal: { sessionId: 'sess-queued-produced', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const coordinator = { - prepareInvocationContext: mock.fn(async ({ content }) => ({ content, sessionPolicy: 'resume' })), - commitInvocationOutcome: mock.fn(async () => {}), - }; - const coordinatorDeps = stubDeps({ - sessionContinuationCoordinator: coordinator, - router: { - routeExecution: mock.fn(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const coordinatorProcessor = new QueueProcessor(coordinatorDeps); - const active = enqueueEntry(coordinatorDeps.queue, { targetCats: ['opus'], content: 'work' }); - coordinatorDeps.queue.backfillMessageId('t1', 'u1', active.id, 'current-user-msg'); - const processing = coordinatorDeps.queue.markProcessing('t1', 'u1'); - - const status = await coordinatorProcessor.executeEntry(processing); - - assert.equal(status.status, 'succeeded'); - assert.equal(coordinator.commitInvocationOutcome.mock.calls.length, 1); - const commitInput = coordinator.commitInvocationOutcome.mock.calls[0].arguments[0]; - assert.deepEqual(Array.from(commitInput.producedCapsules ?? []), [capsule]); - const queuedContinuation = coordinatorDeps.queue - .list('t1', 'u1') - .find((entry) => entry.sourceCategory === 'continuation'); - assert.ok(queuedContinuation, 'continuation should still be auto-queued'); - }); - - it('deduplicates replayed dispatch-handled continuation capsules by stable disposition identity', async () => { - const capsule = buildDispatchHandledContinuationCapsule({ - threadId: 't1', - catId: 'opus', - invocationId: 'child-dispatch-handled', - dispositionAt: 2_000, - }); - processor.continuationWindows.set( - 't1:opus', - Array.from({ length: 5 }, () => Date.now()), - ); - - const first = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'strict', - catId: 'opus', - capsule, - }); - const replay = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'strict', - catId: 'opus', - capsule, - }); - - assert.equal(first.outcome, 'enqueued'); - assert.equal( - processor.continuationWindows.get('t1:opus').length, - 5, - 'dispatch liveness continuation must neither consume nor be suppressed by session-seal rate budget', - ); - assert.equal(replay.outcome, 'skipped_existing_entry'); - assert.equal(deps.queue.list('t1', 'u1').filter((entry) => entry.sourceCategory === 'continuation').length, 1); - }); - - it('threshold seal capsule in queued execution starts bounded same-cat continuation without pending duplicate', async () => { - let routeCalls = 0; - let pendingContinuation = null; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-first', - createdAt: Date.now(), - seal: { sessionId: 'sess-1', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const routeContents = []; - const sealDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, content) { - routeCalls++; - routeContents.push(content); - if (routeCalls === 1) { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - } else { - yield { type: 'text', catId: 'opus', content: 'continued', timestamp: Date.now() }; - } - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - threadStore: { - isRebornSession: mock.fn(async () => false), - setPendingContinuation: mock.fn(async (_threadId, _catId, _userId, entry) => { - pendingContinuation = entry; - }), - consumePendingContinuation: mock.fn(async () => { - const pending = pendingContinuation; - pendingContinuation = null; - return pending; - }), - }, - }); - const sealProcessor = new QueueProcessor(sealDeps); - const entry = enqueueEntry(sealDeps.queue, { targetCats: ['opus'], content: 'initial work' }); - sealDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await sealProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 150)); - - assert.equal(routeCalls, 2, 'second route call should be the continuation'); - assert.match(routeContents[1], /previous session was sealed/i); - assert.equal( - (routeContents[1].match(/Continue the same structured work from the sealed session/g) ?? []).length, - 1, - 'queued continuation must not duplicate the bootstrap prompt', - ); - assert.equal( - sealDeps.threadStore.setPendingContinuation.mock.calls.length, - 1, - 'auto-queued continuation must also be persisted as durable pending state', - ); - assert.equal( - sealDeps.threadStore.consumePendingContinuation.mock.calls.length, - 2, - 'initial and continuation executions still check pending storage; the queued capsule supplies the continuation', - ); - assert.ok(sealDeps.invocationTracker.startAll.mock.calls.length >= 2); - }); - - it('threshold seal capsule survives lost in-memory continuation queue entry via pending storage', async () => { - let routeCalls = 0; - let pendingContinuation = null; - const routeContents = []; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-lost-queue-entry', - createdAt: Date.now(), - seal: { sessionId: 'sess-lost-queue-entry', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const sealDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, content) { - routeCalls++; - routeContents.push(content); - if (routeCalls === 1) { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - } else { - yield { type: 'text', catId: 'opus', content: 'resumed from durable pending', timestamp: Date.now() }; - } - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - threadStore: { - isRebornSession: mock.fn(async () => false), - setPendingContinuation: mock.fn(async (_threadId, _catId, _userId, entry) => { - pendingContinuation = entry; - }), - consumePendingContinuation: mock.fn(async () => { - const pending = pendingContinuation; - pendingContinuation = null; - return pending; - }), - }, - }); - const sealProcessor = new QueueProcessor(sealDeps); - const initial = enqueueEntry(sealDeps.queue, { targetCats: ['opus'], content: 'initial work' }); - sealDeps.queue.backfillMessageId('t1', 'u1', initial.id, 'msg-1'); - const initialProcessing = sealDeps.queue.markProcessing('t1', 'u1'); - - const initialStatus = await sealProcessor.executeEntry(initialProcessing); - assert.equal(initialStatus.status, 'succeeded'); - assert.equal(sealDeps.threadStore.setPendingContinuation.mock.calls.length, 1); - - const queuedContinuation = sealDeps.queue.list('t1', 'u1').find((entry) => entry.sourceCategory === 'continuation'); - assert.ok(queuedContinuation, 'continuation wake-up entry should be queued before simulated process loss'); - sealDeps.queue.remove('t1', 'u1', queuedContinuation.id); - - const followup = enqueueEntry(sealDeps.queue, { targetCats: ['opus'], content: 'follow-up work' }); - sealDeps.queue.backfillMessageId('t1', 'u1', followup.id, 'msg-2'); - const followupProcessing = sealDeps.queue.markProcessing('t1', 'u1'); - - const followupStatus = await sealProcessor.executeEntry(followupProcessing); - assert.equal(followupStatus.status, 'succeeded'); - assert.equal(routeCalls, 2); - assert.match(routeContents[1], /previous session was sealed/i); - assert.match(routeContents[1], /follow-up work/); - assert.equal( - (routeContents[1].match(/Continue the same structured work from the sealed session/g) ?? []).length, - 1, - 'durable pending restore must inject the continuation prompt exactly once', - ); - }); - - it('threshold seal capsule in queued multi-cat execution resumes the capsule owner cat', async () => { - let routeCalls = 0; - const routeTargetCats = []; - const routeContents = []; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'codex', - mode: 'parallel', - a2aEnabled: true, - }), - { - invocationId: 'inv-codex-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-codex', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const sealDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, content, _threadId, _messageId, targetCats) { - routeCalls++; - routeContents.push(content); - routeTargetCats.push([...targetCats]); - if (routeCalls === 1) { - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - } else { - yield { type: 'text', catId: targetCats[0], content: 'continued', timestamp: Date.now() }; - } - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const sealProcessor = new QueueProcessor(sealDeps); - const entry = enqueueEntry(sealDeps.queue, { targetCats: ['opus', 'codex'], content: 'parallel work' }); - sealDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await sealProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 150)); - - assert.equal(routeCalls, 2, 'second route call should be the continuation'); - assert.deepEqual(routeTargetCats[0], ['opus', 'codex']); - assert.deepEqual(routeTargetCats[1], ['codex']); - assert.match(routeContents[1], /Cat: codex/); - }); - - it('threshold seal capsules in queued multi-cat execution resume every sealed cat', async () => { - let routeCalls = 0; - const routeTargetCats = []; - const opusCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'parallel', - a2aEnabled: true, - }), - { - invocationId: 'inv-opus-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-opus', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const codexCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'codex', - mode: 'parallel', - a2aEnabled: true, - }), - { - invocationId: 'inv-codex-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-codex', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const sealDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - routeCalls++; - routeTargetCats.push([...targetCats]); - if (routeCalls === 1) { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: opusCapsule }), - timestamp: Date.now(), - }; - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: codexCapsule }), - timestamp: Date.now(), - }; - } else { - yield { type: 'text', catId: targetCats[0], content: 'continued', timestamp: Date.now() }; - } - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const sealProcessor = new QueueProcessor(sealDeps); - const entry = enqueueEntry(sealDeps.queue, { targetCats: ['opus', 'codex'], content: 'parallel work' }); - sealDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await sealProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 250)); - - assert.equal(routeCalls, 3, 'both sealed cats should get continuation runs'); - assert.deepEqual(routeTargetCats[0], ['opus', 'codex']); - assert.deepEqual( - routeTargetCats.slice(1).sort((a, b) => a[0].localeCompare(b[0])), - [['codex'], ['opus']], - ); - }); - - it('threshold seal capsule advances its successor without replaying the failed queued carrier', async () => { - let routeCalls = 0; - const routeContents = []; - let pendingContinuation = null; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-first', - createdAt: Date.now(), - seal: { sessionId: 'sess-1', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const failDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, content) { - routeCalls++; - routeContents.push(content); - if (routeCalls === 1) { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - throw new Error('route failed after seal notice'); - } - yield { type: 'text', catId: 'opus', content: 'continued', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - threadStore: { - isRebornSession: mock.fn(async () => false), - setPendingContinuation: mock.fn(async (_threadId, _catId, _userId, entry) => { - pendingContinuation = entry; - }), - consumePendingContinuation: mock.fn(async () => { - const pending = pendingContinuation; - pendingContinuation = null; - return pending; - }), - }, - }); - const failProcessor = new QueueProcessor(failDeps); - const entry = enqueueEntry(failDeps.queue, { targetCats: ['opus'], content: 'initial work' }); - failDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await failProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 150)); - - assert.equal(routeCalls, 2, 'the sealed successor advances without an ordinary retry of the failed carrier'); - assert.match(routeContents[1], /previous session was sealed/i); - assert.equal( - (routeContents[1].match(/Continue the same structured work from the sealed session/g) ?? []).length, - 1, - 'stored pending continuation and queued continuation must not duplicate the bootstrap prompt', - ); - const failedPrimary = failDeps.queue.getEntrySnapshot('t1', 'u1', entry.id); - assert.equal(failedPrimary.content, 'initial work'); - assert.deepEqual(failedPrimary.queuedFailedByCatIds, ['opus']); - }); - - it('failed continuation dispatches its newly sealed successor without replaying the attempted carrier', async () => { - const routeContents = []; - const successorCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-successor-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-successor', sessionSeq: 2, reason: 'threshold' }, - }, - ); - const continuationDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, content) { - routeContents.push(content); - if (routeContents.length === 1) { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: successorCapsule }), - timestamp: Date.now(), - }; - throw new Error('old continuation failed after sealing its successor'); - } - yield { type: 'text', catId: 'opus', content: 'continued', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const continuationProcessor = new QueueProcessor(continuationDeps); - const oldContinuation = enqueueEntry(continuationDeps.queue, { - targetCats: ['opus'], - content: 'old-continuation', - source: 'agent', - sourceCategory: 'continuation', - autoExecute: true, - continuationKey: 'old-continuation', - }); - - await continuationProcessor.tryAutoExecute('t1', { bypassNonAgentGate: true }); - await waitFor(() => routeContents.length === 2); - await new Promise((resolve) => setTimeout(resolve, 100)); - - assert.equal(routeContents[0], 'old-continuation'); - assert.match(routeContents[1], /previous session was sealed/i, 'the exact new successor must dispatch next'); - const retained = continuationDeps.queue.getEntrySnapshot('t1', 'u1', oldContinuation.id); - assert.equal(retained.content, 'old-continuation'); - assert.deepEqual(retained.queuedFailedByCatIds, ['opus']); - }); - - it('threshold seal capsule after user stop stores pending but does not auto-run continuation', async () => { - let routeCalls = 0; - let pendingContinuation = null; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-first', - createdAt: Date.now(), - seal: { sessionId: 'sess-user-stop', sessionSeq: 1, reason: 'user-stop-after-seal' }, - }, - ); - const stopDeps = stubDeps({ - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - completeSlot: mock.fn(), - has: mock.fn(() => false), - resolveFinalStatus: mock.fn(() => 'canceled_by_user'), - }, - router: { - routeExecution: mock.fn(async function* () { - routeCalls++; - if (routeCalls === 1) { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ type: 'session_seal_requested', continuityCapsule: capsule }), - timestamp: Date.now(), - }; - } else { - yield { type: 'text', catId: 'opus', content: 'unexpected auto continuation', timestamp: Date.now() }; - } - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - threadStore: { - isRebornSession: mock.fn(async () => false), - setPendingContinuation: mock.fn(async (_threadId, _catId, _userId, entry) => { - pendingContinuation = entry; - }), - consumePendingContinuation: mock.fn(async () => { - const pending = pendingContinuation; - pendingContinuation = null; - return pending; - }), - }, - }); - const stopProcessor = new QueueProcessor(stopDeps); - const entry = enqueueEntry(stopDeps.queue, { targetCats: ['opus'], content: 'initial work' }); - stopDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - const result = await stopProcessor.processNext('t1', 'u1'); - assert.equal(result.started, true); - - await new Promise((r) => setTimeout(r, 150)); - - assert.equal(routeCalls, 1, 'user stop must not immediately auto-run the produced continuation'); - assert.equal( - stopDeps.threadStore.setPendingContinuation.mock.calls.length, - 1, - 'capsule remains available for resume', - ); - assert.equal( - stopDeps.queue - .list('t1', 'u1') - .some((queued) => queued.sourceCategory === 'continuation' && queued.autoExecute === true), - false, - 'user-stopped capsule must not be queued as autoExecute continuation', - ); - }); - - it('enqueueContinuation pins seal work ahead of queued user work without dropping either', async () => { - enqueueEntry(deps.queue, { targetCats: ['opus'], source: 'user', content: 'new user work' }); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-1', - createdAt: Date.now(), - seal: { sessionId: 'sess-1', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - const outcome = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'strict', - catId: 'opus', - capsule, - }); - - assert.equal(outcome.outcome, 'enqueued'); - const queue = deps.queue.list('t1', 'u1'); - assert.equal(queue.length, 2); - assert.match(queue[0].content, /Continue the same structured work from the sealed session/); - assert.equal(queue[0].ownerAuthProvenance, 'strict'); - assert.equal(queue[1].content, 'new user work'); - }); - - it('enqueueContinuation rejects a carrier without explicit owner provenance', async () => { - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-missing-provenance', - createdAt: Date.now(), - seal: { sessionId: 'sess-missing-provenance', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - await assert.rejects( - () => processor.enqueueContinuation({ threadId: 't1', userId: 'u1', catId: 'opus', capsule }), - /ownerAuthProvenance must be explicit/, - ); - }); - - it('enqueueContinuation pins seal work ahead of queued agent work without dropping either', async () => { - const originalNow = Date.now; - let now = 1_000_000; - Date.now = () => now; - try { - enqueueEntry(deps.queue, { targetCats: ['opus'], source: 'agent', content: 'stale queued work' }); - now += InvocationQueue.STALE_QUEUED_THRESHOLD_MS + 1; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-stale-queued', - createdAt: now, - seal: { sessionId: 'sess-stale-queued', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - const outcome = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule, - }); - - assert.equal(outcome.outcome, 'enqueued'); - const queue = deps.queue.list('t1', 'u1'); - assert.equal(queue.length, 2); - assert.match(queue[0].content, /Continue the same structured work from the sealed session/); - assert.equal(queue[1].content, 'stale queued work', 'old queued agent work must not be dropped'); - } finally { - Date.now = originalNow; - } - }); - - it('enqueueContinuation does not retain empty continuation window after skipped duplicate', async () => { - enqueueEntry(deps.queue, { - targetCats: ['opus'], - source: 'agent', - sourceCategory: 'continuation', - continuationKey: 't1:opus:inv-duplicate-window:sess-duplicate-window:1', - content: 'pending continuation work', - }); - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-duplicate-window', - createdAt: Date.now(), - seal: { sessionId: 'sess-duplicate-window', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - const outcome = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule, - }); - - assert.equal(outcome.outcome, 'skipped_existing_entry'); - assert.equal(processor.continuationWindows.has('t1:opus'), false); - }); - - it('enqueueContinuation preserves distinct sealed work while deduping the same seal item', async () => { - const firstCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-first-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-first-seal', sessionSeq: 1, reason: 'threshold' }, - }, - ); - const secondCapsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-second-seal', - createdAt: Date.now(), - seal: { sessionId: 'sess-second-seal', sessionSeq: 2, reason: 'threshold' }, - }, - ); - - const first = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule: firstCapsule, - }); - const duplicateFirst = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule: firstCapsule, - }); - const second = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule: secondCapsule, - }); - - assert.equal(first.outcome, 'enqueued'); - assert.equal(duplicateFirst.outcome, 'skipped_existing_entry'); - assert.equal(second.outcome, 'enqueued'); - assert.equal(deps.queue.list('t1', 'u1').length, 2); - }); - - it('enqueueContinuation pins seal work ahead of old queued user work without dropping either', async () => { - const originalNow = Date.now; - let now = 1_500_000; - Date.now = () => now; - try { - enqueueEntry(deps.queue, { targetCats: ['opus'], source: 'user', content: 'old but real user work' }); - now += InvocationQueue.STALE_QUEUED_THRESHOLD_MS + 1; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-old-user-work', - createdAt: now, - seal: { sessionId: 'sess-old-user-work', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - const outcome = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule, - }); - - assert.equal(outcome.outcome, 'enqueued'); - const queue = deps.queue.list('t1', 'u1'); - assert.equal(queue.length, 2); - assert.match(queue[0].content, /Continue the same structured work from the sealed session/); - assert.equal(queue[1].content, 'old but real user work'); - } finally { - Date.now = originalNow; - } - }); - - it('enqueueContinuation ignores stale processing entries when checking existing pending work', async () => { - const originalNow = Date.now; - let now = 2_000_000; - Date.now = () => now; - try { - const entry = enqueueEntry(deps.queue, { - targetCats: ['opus'], - source: 'agent', - content: 'stale processing work', - }); - deps.queue.markProcessingById('t1', entry.id); - now += InvocationQueue.STALE_PROCESSING_THRESHOLD_MS + 1; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-stale-processing', - createdAt: now, - seal: { sessionId: 'sess-stale-processing', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - const outcome = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule, - }); - - assert.equal(outcome.outcome, 'enqueued'); - assert.equal(outcome.entry?.targetCats[0], 'opus'); - } finally { - Date.now = originalNow; - } - }); - - it('continuation dispatch runs seal continuation first and preserves old queued agent work', async () => { - const originalNow = Date.now; - let now = 3_000_000; - Date.now = () => now; - const routeContents = []; - try { - const dispatchDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, content, _threadId, _messageId, targetCats) { - routeContents.push(content); - yield { type: 'text', catId: targetCats[0], content: 'ok', timestamp: Date.now() }; - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const dispatchProcessor = new QueueProcessor(dispatchDeps); - enqueueEntry(dispatchDeps.queue, { - source: 'agent', - targetCats: ['opus'], - content: 'old queued handoff', - }); - now += InvocationQueue.STALE_QUEUED_THRESHOLD_MS + 1; - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-fresh-continuation', - createdAt: now, - seal: { sessionId: 'sess-fresh-continuation', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - const outcome = await dispatchProcessor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule, - }); - assert.equal(outcome.outcome, 'enqueued'); - assert.equal(dispatchDeps.queue.list('t1', 'u1').length, 2, 'continuation should wait behind agent work'); - - await dispatchProcessor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 80)); - - assert.ok(routeContents.length > 0, 'seal continuation should be dispatched first'); - assert.match(routeContents[0], /Continue the same structured work from the sealed session/); - - await dispatchProcessor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 80)); - - assert.ok(routeContents.length > 1, 'old queued agent work should still dispatch after continuation'); - assert.match(routeContents[1], /old queued handoff/); - } finally { - Date.now = originalNow; - } - }); - - it('enqueueContinuation rate-limits after five continuations per hour for a thread cat', async () => { - const capsule = completeCapsuleForSeal( - buildCapsuleFromRouteState({ - threadId: 't1', - catId: 'opus', - mode: 'independent', - a2aEnabled: true, - }), - { - invocationId: 'inv-rate-limit', - createdAt: Date.now(), - seal: { sessionId: 'sess-rate-limit', sessionSeq: 1, reason: 'threshold' }, - }, - ); - - for (let i = 0; i < 5; i++) { - const outcome = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule, - }); - assert.equal(outcome.outcome, 'enqueued'); - deps.queue.clear('t1', 'u1'); - } - - const sixth = await processor.enqueueContinuation({ - threadId: 't1', - userId: 'u1', - ownerAuthProvenance: 'unknown', - catId: 'opus', - capsule, - }); - - assert.equal(sixth.outcome, 'skipped_rate_limited'); - assert.equal(deps.queue.list('t1', 'u1').length, 0); - }); - - // ── #768: intent_mode deferred until CLI is alive ── - - it('#768 regression: intent_mode is NOT broadcast when routeExecution throws before yielding', async () => { - const failDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - throw new Error('CLI spawn failed'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const failProcessor = new QueueProcessor(failDeps); - - const entry = enqueueEntry(failDeps.queue); - failDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await failProcessor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 100)); - - const intentCall = failDeps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'intent_mode'); - assert.equal(intentCall, undefined, 'intent_mode must NOT be broadcast when CLI fails before producing events'); - }); - - it('#768 regression: intent_mode IS broadcast once CLI produces first event', async () => { - const entry = enqueueEntry(deps.queue, { targetCats: ['codex'], intent: 'execute' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - const intentCall = deps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'intent_mode'); - assert.ok(intentCall, 'intent_mode should be broadcast after first CLI event'); - }); - - it('#768 regression: intent_mode is NOT broadcast when routeExecution yields nothing (empty generator)', async () => { - const emptyDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - // Generator completes without yielding any events - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const emptyProcessor = new QueueProcessor(emptyDeps); - - const entry = enqueueEntry(emptyDeps.queue); - emptyDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await emptyProcessor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 100)); - - const intentCall = emptyDeps.socketManager.broadcastToRoom.mock.calls.find((c) => c.arguments[1] === 'intent_mode'); - assert.equal(intentCall, undefined, 'intent_mode must NOT be broadcast when CLI produces zero events'); - }); - - // ── P1 fix: executeEntry failure marks InvocationRecord ── - - it('executeEntry failure marks InvocationRecord as failed', async () => { - const failDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - throw new Error('route boom'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const failProcessor = new QueueProcessor(failDeps); - - const entry = enqueueEntry(failDeps.queue); - failDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await failProcessor.processNext('t1', 'u1'); - // Wait for background execution to complete - await new Promise((r) => setTimeout(r, 100)); - - // InvocationRecord should be updated with status='failed' - const updateCalls = failDeps.invocationRecordStore.update.mock.calls; - const failedUpdate = updateCalls.find((c) => c.arguments[1]?.status === 'failed'); - assert.ok(failedUpdate, 'should mark InvocationRecord as failed'); - assert.ok(failedUpdate.arguments[1].error, 'should include error message'); - const retriable = failDeps.queue.list('t1', 'u1'); - assert.equal(retriable.length, 1, 'failed queued work with no durable message projection may roll back'); - assert.equal(retriable[0].id, entry.id); - assert.equal(retriable[0].status, 'queued'); - assert.deepEqual(retriable[0].queuedFailedByCatIds, ['opus']); - }); - - it('executeEntry failure CAS-terminalizes a record when the ordinary failed write throws', async () => { - let recordStatus = 'pending'; - const failDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - throw new Error('route boom'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ outcome: 'created', invocationId: 'inv-backstop' })), - update: mock.fn(async (_id, patch) => { - if (patch?.status === 'failed' && patch?.expectedStatus === undefined) { - throw new Error('Redis blip'); - } - if (patch?.status) recordStatus = patch.status; - return { invocationId: 'inv-backstop', status: recordStatus }; - }), - get: mock.fn(async (id) => (id === 'inv-backstop' ? { invocationId: id, status: recordStatus } : null)), - }, - }); - const failProcessor = new QueueProcessor(failDeps); - - const entry = enqueueEntry(failDeps.queue); - failDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await failProcessor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 100)); - - const updateCalls = failDeps.invocationRecordStore.update.mock.calls; - assert.ok( - updateCalls.some( - (call) => call.arguments[1]?.status === 'failed' && call.arguments[1]?.expectedStatus === undefined, - ), - 'the ordinary error path should attempt its unguarded failed write', - ); - assert.ok( - updateCalls.some( - (call) => call.arguments[1]?.status === 'failed' && call.arguments[1]?.expectedStatus === 'running', - ), - 'the finally backstop should CAS running to failed', - ); - assert.equal(recordStatus, 'failed'); - }); - - it('claim-lost loser does not terminalize the winning executor invocation', async () => { - let recordStatus = 'running'; - const claimLostDeps = stubDeps({ - invocationRecordStore: { - create: mock.fn(async () => ({ outcome: 'created', invocationId: 'inv-claim-winner' })), - update: mock.fn(async (_id, patch) => { - if (patch?.status === 'running' && patch?.expectedStatus === 'queued') { - return null; - } - if (patch?.status) recordStatus = patch.status; - return { invocationId: 'inv-claim-winner', status: recordStatus }; - }), - get: mock.fn(async (id) => (id === 'inv-claim-winner' ? { invocationId: id, status: recordStatus } : null)), - }, - }); - const claimLostProcessor = new QueueProcessor(claimLostDeps); - - enqueueEntry(claimLostDeps.queue); - const result = await claimLostProcessor.executeEntry(claimLostDeps.queue.markProcessing('t1', 'u1')); - - assert.equal(result.status, 'succeeded'); - assert.equal(claimLostDeps.router.routeExecution.mock.calls.length, 0); - assert.equal( - claimLostDeps.invocationRecordStore.update.mock.calls.filter( - (call) => call.arguments[1]?.status === 'failed' && call.arguments[1]?.expectedStatus === 'running', - ).length, - 0, - 'the claim-lost loser must leave terminal ownership with the winning executor', - ); - assert.equal(recordStatus, 'running'); - }); - - // ── F039 remaining bugfix: queue execution should include contentBlocks ── - - it('executeEntry passes contentBlocks from messageId to routeExecution', async () => { - const contentBlocks = [{ type: 'image', url: 'https://example.com/1.png' }]; - - deps.messageStore.getById = mock.fn(async (id) => { - if (id === 'm1') return { id: 'm1', contentBlocks }; - return null; - }); - - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'm1'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.ok(deps.router.routeExecution.mock.calls.length > 0); - const call = deps.router.routeExecution.mock.calls[0]; - const opts = call.arguments[6]; - assert.ok(opts && typeof opts === 'object', 'expected opts object'); - assert.deepEqual(opts.contentBlocks, contentBlocks); - }); - - it('executeEntry passes explicit A2A trigger id to routeExecution for agent queue entries', async () => { - const entry = enqueueEntry(deps.queue, { - source: 'agent', - sourceCategory: 'a2a', - a2aTriggerMessageId: 'msg-trigger', - }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-trigger'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.ok(deps.router.routeExecution.mock.calls.length > 0); - const call = deps.router.routeExecution.mock.calls[0]; - const opts = call.arguments[6]; - assert.ok(opts && typeof opts === 'object', 'expected opts object'); - assert.equal(opts.a2aTriggerMessageId, 'msg-trigger'); - }); - - it('executeEntry preserves strict owner authentication provenance across queue replay', async () => { - const entry = enqueueEntry(deps.queue, { - ownerAuthProvenance: 'strict', - }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-owner-auth'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - const options = deps.router.routeExecution.mock.calls[0].arguments[6]; - assert.equal(options.ownerAuthProvenance, 'strict'); - }); - - it('executeEntry binds an exact dispatch carrier independently for every A2A target', async () => { - const entry = enqueueEntry(deps.queue, { - source: 'agent', - sourceCategory: 'a2a', - callerCatId: 'codex-sol', - targetCats: ['opus', 'codex-terra'], - a2aTriggerMessageId: 'msg-dispatch', - }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-dispatch'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - const opts = deps.router.routeExecution.mock.calls[0].arguments[6]; - assert.equal(opts.a2aTriggerMessageId, 'msg-dispatch'); - assert.equal(opts.a2aCallerCatId, 'codex-sol'); - assert.deepEqual( - ['opus', 'codex-terra'].map((catId) => opts.turnCustodyWakeForCat(catId)), - ['opus', 'codex-terra'].map((catId) => ({ - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:t1', - holderCatId: catId, - handoff: { - sourceEventId: `route:msg-dispatch:${catId}`, - messageId: 'msg-dispatch', - fromCatId: 'codex-sol', - }, - })), - ); - }); - - it('executeEntry does not pass current user message id as A2A trigger for normal queue entries', async () => { - const entry = enqueueEntry(deps.queue, { source: 'user' }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-user'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.ok(deps.router.routeExecution.mock.calls.length > 0); - const call = deps.router.routeExecution.mock.calls[0]; - const opts = call.arguments[6]; - assert.ok(opts && typeof opts === 'object', 'expected opts object'); - assert.equal(opts.a2aTriggerMessageId, undefined); - }); - - it('degrades when messageStore.getById throws: still executes without contentBlocks', async () => { - deps.messageStore.getById = mock.fn(async () => { - throw new Error('redis down'); - }); - - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'm1'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.ok(deps.router.routeExecution.mock.calls.length > 0, 'should still execute'); - const call = deps.router.routeExecution.mock.calls[0]; - const opts = call.arguments[6]; - assert.ok(opts && typeof opts === 'object', 'expected opts object'); - assert.equal(opts.contentBlocks, undefined); - - const succeededUpdate = deps.invocationRecordStore.update.mock.calls.find( - (c) => c.arguments[1]?.status === 'succeeded', - ); - assert.ok(succeededUpdate, 'should mark InvocationRecord succeeded'); - - assert.ok(deps.log.warn.mock.calls.length > 0, 'should warn on messageStore failure'); - }); - - // ── F108: QueueProcessor slot-aware (AC-A7) ── - - describe('slot-aware mutex and dequeue (F108)', () => { - it('processing mutex is per-slot: different cats can execute concurrently in same thread', async () => { - // Enqueue opus and codex entries for same thread - const e1 = enqueueEntry(deps.queue, { content: 'opus task', targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', e1.id, 'msg-opus'); - const e2 = enqueueEntry(deps.queue, { content: 'codex task', targetCats: ['codex'] }); - deps.queue.backfillMessageId('t1', 'u1', e2.id, 'msg-codex'); - - // Complete opus slot → should dequeue opus entry - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 50)); - - // Now complete codex slot → should dequeue codex entry (not blocked by opus mutex) - await processor.onInvocationComplete('t1', 'codex', 'succeeded'); - await new Promise((r) => setTimeout(r, 50)); - - // Both entries should have been processed - assert.ok( - deps.invocationTracker.startAll.mock.calls.length >= 2, - `expected >=2 tracker.start calls, got ${deps.invocationTracker.startAll.mock.calls.length}`, - ); - }); - - it('slot completion does not affect pause state of different slot', async () => { - // Enqueue entries for both cats - enqueueEntry(deps.queue, { content: 'opus task', targetCats: ['opus'] }); - enqueueEntry(deps.queue, { content: 'codex task', targetCats: ['codex'] }); - - // Cancel opus slot — should pause opus, not codex - await processor.onInvocationComplete('t1', 'opus', 'canceled'); - - // opus slot should be paused - assert.equal(processor.isPaused('t1', 'opus'), true); - // codex slot should NOT be paused - assert.equal(processor.isPaused('t1', 'codex'), false); - }); - - it('clearPause is slot-specific', () => { - // Manually set both paused - processor.clearPause('t1', 'opus'); - // Should not throw, just noop - assert.equal(processor.isPaused('t1', 'opus'), false); - }); - - it('releaseSlot is slot-specific', async () => { - const slowDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - await new Promise((r) => setTimeout(r, 200)); - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const slowProcessor = new QueueProcessor(slowDeps); - - // Enqueue opus and codex - const e1 = enqueueEntry(slowDeps.queue, { content: 'opus slow', targetCats: ['opus'] }); - slowDeps.queue.backfillMessageId('t1', 'u1', e1.id, 'msg-1'); - const e2 = enqueueEntry(slowDeps.queue, { content: 'codex fast', targetCats: ['codex'] }); - slowDeps.queue.backfillMessageId('t1', 'u1', e2.id, 'msg-2'); - - // Start opus via processNext — takes mutex for opus slot - await slowProcessor.processNext('t1', 'u1'); - - // Release opus slot — should allow another opus entry to start - slowProcessor.releaseSlot('t1', 'opus'); - - // codex should still be startable (no mutex on codex slot) - const r2 = await slowProcessor.processNext('t1', 'u1'); - assert.equal(r2.started, true, 'codex entry should start since opus slot was released'); - }); - - it('onInvocationComplete requires catId parameter', async () => { - enqueueEntry(deps.queue); - - // New signature: onInvocationComplete(threadId, catId, status) - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - // Should not throw — catId is now required - }); - - it('tryExecuteNextAcrossUsers checks entryCat slot, not just completing cat slot (P1-2)', async () => { - // Scenario: opus completes, oldest queued entry targets codex, but codex is already running. - // Bug: code checks completing cat (opus) slot mutex, not the entry's cat (codex). - // Expected: should NOT start codex entry when codex slot is busy. - - // Make routeExecution hang so codex stays "in progress" - let resolveCodex; - deps.router.routeExecution = mock.fn(async function* () { - await new Promise((r) => { - resolveCodex = r; - }); - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }); - - const codexEntry = enqueueEntry(deps.queue, { targetCats: ['codex'] }); - deps.queue.backfillMessageId('t1', 'u1', codexEntry.id, 'msg-codex'); - - // Start codex — it hangs (slot is busy) - await processor.processNext('t1', 'u1'); - - // Enqueue another codex entry while the first is still running - const codexEntry2 = enqueueEntry(deps.queue, { targetCats: ['codex'] }); - deps.queue.backfillMessageId('t1', 'u1', codexEntry2.id, 'msg-codex2'); - - // Simulate opus completing — triggers auto-dequeue across users - // Oldest remaining queued entry is codex, but codex slot is busy - await processor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 50)); - - // routeExecution should only have been called once (for the first codex entry) - const routeCalls = deps.router.routeExecution.mock.calls; - assert.equal(routeCalls.length, 1, `should not double-start codex slot; got ${routeCalls.length} route calls`); - - // Cleanup: resolve the hanging codex execution - resolveCodex?.(); - }); - - it('tryExecuteNextForUser does not leave entry stuck in processing when slot is busy (P1-3)', async () => { - // Scenario: codex is already running, user sends another message targeting codex. - // Bug: markProcessing() called before mutex check, entry gets stuck as 'processing'. - // Expected: entry should remain 'queued' if slot is busy. - - // Make routeExecution hang so codex stays "in progress" - let resolveCodex; - deps.router.routeExecution = mock.fn(async function* () { - await new Promise((r) => { - resolveCodex = r; - }); - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }); - - const entry1 = enqueueEntry(deps.queue, { targetCats: ['codex'] }); - deps.queue.backfillMessageId('t1', 'u1', entry1.id, 'msg-1'); - - // Process entry1 — codex slot becomes busy (hangs) - await processor.processNext('t1', 'u1'); - - // Use different intent to prevent auto-merge with entry1 - const entry2res = deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - content: 'second message', - source: 'user', - ownerAuthProvenance: 'unknown', - targetCats: ['codex'], - intent: 'ideate', - }); - const entry2 = entry2res.entry; - deps.queue.backfillMessageId('t1', 'u1', entry2.id, 'msg-2'); - - // Try to process entry2 while codex slot is busy - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, false, 'should not start when slot is busy'); - - // Key assertion: entry2 should still be 'queued', not stuck as 'processing' - const list = deps.queue.list('t1', 'u1'); - const entry2Status = list.find((e) => e.id === entry2.id); - assert.ok(entry2Status, 'entry2 should still be in queue'); - assert.equal(entry2Status.status, 'queued', 'entry2 should remain queued, not stuck as processing'); - - // Cleanup - resolveCodex?.(); - }); - - it('broadcast messages carry invocationId (AC-A8)', async () => { - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await processor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 50)); - - const broadcastCalls = deps.socketManager.broadcastAgentMessage.mock.calls; - assert.ok(broadcastCalls.length > 0, 'should have broadcast at least one message'); - const msgArg = broadcastCalls[0].arguments[0]; - assert.equal(msgArg.invocationId, 'inv-stub', 'broadcast message should carry invocationId'); - }); - }); - - it('canReleaseSlotForUser rejects foreign tracker and pre-start queue owners', () => { - const ownershipDeps = stubDeps(); - ownershipDeps.invocationTracker.getUserId = mock.fn(() => null); - const ownershipProcessor = new QueueProcessor(ownershipDeps); - - const foreignPreStart = enqueueEntry(ownershipDeps.queue, { userId: 'user-b', targetCats: ['opus'] }); - ownershipDeps.queue.markProcessingById('t1', foreignPreStart.id); - - assert.equal(ownershipProcessor.canReleaseSlotForUser('t1', 'opus', 'user-a'), false); - assert.equal(ownershipProcessor.canReleaseSlotForUser('t1', 'opus', 'user-b'), true); - - ownershipDeps.queue.removeProcessedAcrossUsers('t1', foreignPreStart.id); - ownershipDeps.invocationTracker.has = mock.fn(() => true); - ownershipDeps.invocationTracker.getUserId = mock.fn(() => 'user-b'); - - assert.equal(ownershipProcessor.canReleaseSlotForUser('t1', 'opus', 'user-a'), false); - assert.equal(ownershipProcessor.canReleaseSlotForUser('t1', 'opus', 'user-b'), true); - }); - - // ── F122B: tryAutoExecute ── - - describe('tryAutoExecute (F122B agent auto-execute)', () => { - it('PR7 executes only the eligible sibling from a mixed failed-target carrier', async () => { - const routedTargetSets = []; - deps.router.routeExecution = mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - routedTargetSets.push([...targetCats]); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }); - processor = new QueueProcessor(deps); - const mixed = enqueueEntry(deps.queue, { - targetCats: ['opus', 'codex'], - source: 'agent', - sourceCategory: 'a2a', - autoExecute: true, - }); - deps.queue.markQueuedFailedForCatAcrossUsers( - mixed.threadId, - 'opus', - 'failed-opus', - new Set([mixed.id]), - 'invocation_failed', - ); - - await processor.tryAutoExecute('t1', { bypassNonAgentGate: true }); - - await waitFor(() => routedTargetSets.length === 1); - assert.deepEqual(routedTargetSets, [['codex']]); - }); - - it('immediately executes autoExecute entry when target cat slot is free', async () => { - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['opus'], - autoExecute: true, - callerCatId: 'codex', - }); - - await processor.tryAutoExecute('t1'); - // Give fire-and-forget a tick - await new Promise((r) => setTimeout(r, 50)); - - assert.ok(deps.invocationTracker.startAll.mock.calls.length > 0, 'should start execution'); - }); - - it('does not execute autoExecute entry when target cat slot is busy', async () => { - // Occupy opus slot - deps.invocationTracker.has = mock.fn(() => true); - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['opus'], - autoExecute: true, - callerCatId: 'codex', - }); - - await processor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 50)); - - // Entry stays queued, not executed - assert.equal(deps.invocationTracker.startAll.mock.calls.length, 0, 'should not start when slot busy'); - const queued = deps.queue.list('t1', 'system'); - assert.equal(queued.length, 1, 'entry should remain in queue'); - assert.equal(queued[0].status, 'queued', 'entry should still be queued'); - }); - - it('bypass mode scans only autoExecute entries', async () => { - enqueueEntry(deps.queue, { - userId: 'u1', - source: 'user', - targetCats: ['opus'], - // no autoExecute - }); - - await processor.tryAutoExecute('t1', { bypassNonAgentGate: true }); - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.invocationTracker.startAll.mock.calls.length, 0, 'should not execute user entries'); - }); - - it('executes old queued autoExecute entries older than threshold when the slot is free', async () => { - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['opus'], - autoExecute: true, - callerCatId: 'codex', - }); - // list() returns shallow-copied array with reference elements — mutating - // createdAt here reaches the real entry inside the queue (coupling on purpose). - const queued = deps.queue.list('t1', 'system'); - queued[0].createdAt = Date.now() - 120_000; - - await processor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(deps.invocationTracker.startAll.mock.calls.length, 1, 'old autoExecute entry must still start'); - assert.equal( - deps.queue.list('t1', 'system').length, - 0, - 'old autoExecute entry should be removed after execution', - ); - }); - - it('autoExecute entry bypasses pause state', async () => { - // Set up a paused state - enqueueEntry(deps.queue, { userId: 'u1', source: 'user' }); - await processor.onInvocationComplete('t1', 'opus', 'failed'); - assert.ok(processor.isPaused('t1', 'opus'), 'should be paused'); - - // Now enqueue an agent auto-execute entry - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['codex'], // different cat slot — not paused - autoExecute: true, - callerCatId: 'opus', - }); - - await processor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.ok( - deps.invocationTracker.startAll.mock.calls.length > 0, - 'should execute on free slot despite thread pause', - ); - }); - - it('skips busy-slot entry and executes next free-slot autoExecute entry (P2 scan)', async () => { - // Entry 1: opus slot busy - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['opus'], - autoExecute: true, - callerCatId: 'gemini', - }); - // Entry 2: codex slot free - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['codex'], - autoExecute: true, - callerCatId: 'gemini', - }); - - // Mock: opus is busy, codex is free - deps.invocationTracker.has = mock.fn((threadId, catId) => catId === 'opus'); - - await processor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 50)); - - // First start should be codex (skipped opus because slot is busy) - assert.ok(deps.invocationTracker.startAll.mock.calls.length >= 1, 'should start at least one'); - const firstStartCall = deps.invocationTracker.startAll.mock.calls[0]; - // startAll receives catIds[] as second arg - assert.deepEqual(firstStartCall.arguments[1], ['codex'], 'should start codex (free slot) first, not opus (busy)'); - }); - - it('starts multiple free-slot entries in a single tryAutoExecute call (parallel dispatch)', async () => { - // Enqueue 3 entries for 3 different cats — all slots free - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['opus'], - autoExecute: true, - callerCatId: 'gemini', - }); - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['codex'], - autoExecute: true, - callerCatId: 'gemini', - }); - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['gemini'], - autoExecute: true, - callerCatId: 'opus', - }); - - await processor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 100)); - - // All 3 should have been started (different cat slots, all free) - const startCalls = deps.invocationTracker.startAll.mock.calls; - assert.equal(startCalls.length, 3, 'should start all 3 entries in one call'); - // startAll receives catIds[] as second arg — flatten to get primary cats - const startedCats = startCalls.map((c) => c.arguments[1][0]); - assert.ok(startedCats.includes('opus'), 'opus should be started'); - assert.ok(startedCats.includes('codex'), 'codex should be started'); - assert.ok(startedCats.includes('gemini'), 'gemini should be started'); - }); - }); - - // ── Tracker guard: prevent duplicate execution for CLI-active cats ── - - describe('tracker guard on completion chain (tryExecuteNextAcrossUsers)', () => { - it('does NOT start queued entry when target cat has active CLI invocation', async () => { - // Simulate: opus is running via CLI (tracked in invocationTracker but NOT in processingSlots) - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - // invocationTracker reports opus is active (CLI invocation) - deps.invocationTracker.has = mock.fn((_tid, catId) => catId === 'opus'); - - // codex completes → triggers tryExecuteNextAcrossUsers which finds the opus entry - await processor.onInvocationComplete('t1', 'codex', 'succeeded'); - await new Promise((r) => setTimeout(r, 50)); - - // executeEntry must NOT have been called - assert.equal( - deps.invocationTracker.startAll.mock.calls.length, - 0, - 'must not call executeEntry (tracker.start not called)', - ); - assert.equal(deps.router.routeExecution.mock.calls.length, 0, 'must not call routeExecution'); - - // Entry must be rolled back to queued (not stuck as processing) - const queue = deps.queue.list('t1', 'u1'); - assert.equal(queue.length, 1); - assert.equal(queue[0].status, 'queued', 'entry must rollback to queued'); - }); - }); - - describe('tracker guard on processNext (tryExecuteNextForUser)', () => { - it('does NOT start queued entry when target cat has active CLI invocation', async () => { - const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - // invocationTracker reports opus is active (CLI invocation) - deps.invocationTracker.has = mock.fn((_tid, catId) => catId === 'opus'); - - const result = await processor.processNext('t1', 'u1'); - - assert.equal(result.started, false, 'must not start when tracker has active invocation'); - // executeEntry must NOT have been called - assert.equal( - deps.invocationTracker.startAll.mock.calls.length, - 0, - 'must not call executeEntry (tracker.start not called)', - ); - assert.equal(deps.router.routeExecution.mock.calls.length, 0, 'must not call routeExecution'); - - // Entry must still be queued (never marked processing since guard fires before markProcessing) - const queue = deps.queue.list('t1', 'u1'); - assert.equal(queue.length, 1); - assert.equal(queue[0].status, 'queued', 'entry must remain queued'); - }); - }); - - // ── F088 fix: OutboundDeliveryHook regression tests ── - - describe('outbound delivery via QueueProcessor (F088)', () => { - /** Poll until predicate returns true or timeout (deterministic, no fixed sleeps). */ - async function waitFor(predicate, timeoutMs = 5000, intervalMs = 10) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (predicate()) return; - await new Promise((r) => setTimeout(r, intervalMs)); - } - throw new Error(`waitFor timed out after ${timeoutMs}ms`); - } - - it('single-cat execution: outboundHook.deliver called once with correct catId + content', async () => { - const deliverCalls = []; - const outboundHook = { - deliver: mock.fn(async (threadId, content, catId, richBlocks, threadMeta) => { - deliverCalls.push({ threadId, content, catId, richBlocks, threadMeta }); - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - const threadMetaLookup = mock.fn(async () => ({ - threadShortId: 't1-short', - threadTitle: 'Test Thread', - deepLinkUrl: 'https://example.com/threads/t1', - })); - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'Hello from opus', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - threadMetaLookup, - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => deliverCalls.length >= 1); - - assert.equal(deliverCalls.length, 1, 'deliver should be called once for single-cat execution'); - assert.equal(deliverCalls[0].threadId, 't1'); - assert.equal(deliverCalls[0].catId, 'opus'); - assert.equal(deliverCalls[0].content, 'Hello from opus'); - assert.ok(deliverCalls[0].threadMeta, 'threadMeta should be provided'); - assert.equal(deliverCalls[0].threadMeta.threadTitle, 'Test Thread'); - - assert.ok(streamingHook.onStreamStart.mock.calls.length >= 1, 'onStreamStart should be called'); - assert.ok(streamingHook.onStreamEnd.mock.calls.length >= 1, 'onStreamEnd should be called'); - - await waitFor(() => streamingHook.cleanupPlaceholders.mock.calls.length >= 1); - assert.ok( - streamingHook.cleanupPlaceholders.mock.calls.length >= 1, - 'cleanupPlaceholders should be called on successful delivery', - ); - }); - - it('F254 Phase E: a superseded queued draft keeps the catch placeholder and sends no fallback answer', async () => { - const outboundHook = { deliver: mock.fn(async () => {}) }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - onClosureBlocked: mock.fn(async () => {}), - }; - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, _content, _threadId, _messageId, _cats, _intent, options) { - yield { type: 'text', catId: 'opus', content: 'known-stale answer', timestamp: Date.now() }; - options.persistenceContext.outputCommitDecisions = { - opus: { - kind: 'superseded_positive_stale', - closureId: 'closure-1', - requiredFrontierMessageId: 'msg-newer', - }, - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - }); - const hookProcessor = new QueueProcessor(hookDeps); - enqueueEntry(hookDeps.queue); - - await hookProcessor.processNext('t1', 'u1'); - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal(outboundHook.deliver.mock.calls.length, 0); - assert.equal(streamingHook.cleanupPlaceholders.mock.calls.length, 0); - assert.equal(streamingHook.onClosureBlocked.mock.calls.length, 0); - }); - - it('ADR-042 delivers a queued published-with-unseen answer instead of a catching-up placeholder', async () => { - const outboundHook = { deliver: mock.fn(async () => {}) }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - onClosureCatchingUp: mock.fn(async () => {}), - }; - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, _content, _threadId, _messageId, _cats, _intent, options) { - yield { type: 'text', catId: 'opus', content: 'published answer', timestamp: Date.now() }; - options.persistenceContext.outputCommitDecisions = { - opus: { - kind: 'published_with_unseen', - messageId: 'msg-published', - lineageId: 'msg-published', - offeredSupplementId: 'f254-supplement:msg-published:1', - requiredFrontierMessageId: 'msg-newer', - }, - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - }); - const hookProcessor = new QueueProcessor(hookDeps); - enqueueEntry(hookDeps.queue); - - await hookProcessor.processNext('t1', 'u1'); - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal(outboundHook.deliver.mock.calls.length, 1); - assert.equal(outboundHook.deliver.mock.calls[0].arguments[1], 'published answer'); - assert.ok(streamingHook.onStreamEnd.mock.calls.length >= 1); - assert.ok(streamingHook.cleanupPlaceholders.mock.calls.length >= 1); - assert.equal(streamingHook.onClosureCatchingUp.mock.calls.length, 0); - }); - - it('F254 Phase E: attributes connector closure projections to the decision owner cat', async () => { - const scenarios = [ - { - decision: { - kind: 'superseded_positive_stale', - closureId: 'closure-codex-stale', - requiredFrontierMessageId: 'msg-newer', - }, - expectedHook: 'onClosureCatchingUp', - }, - { - decision: { - kind: 'blocked_known_closure', - closureId: 'closure-codex-blocked', - reason: 'provider_failure', - }, - expectedHook: 'onClosureBlocked', - }, - ]; - - for (const scenario of scenarios) { - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - onClosureCatchingUp: mock.fn(async () => {}), - onClosureBlocked: mock.fn(async () => {}), - }; - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn( - async function* (_userId, _content, _threadId, _messageId, _cats, _intent, options) { - yield { type: 'text', catId: 'opus', content: 'primary answer', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'text', catId: 'codex', content: 'non-primary stale answer', timestamp: Date.now() }; - options.persistenceContext.outputCommitDecisions = { codex: scenario.decision }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - ), - ackCollectedCursors: mock.fn(async () => {}), - }, - streamingHook, - }); - const hookProcessor = new QueueProcessor(hookDeps); - enqueueEntry(hookDeps.queue, { targetCats: ['opus', 'codex'] }); - const processing = hookDeps.queue.markProcessing('t1', 'u1'); - - await hookProcessor.executeEntry(processing); - - const projectionCall = streamingHook[scenario.expectedHook].mock.calls[0]; - assert.ok(projectionCall, `${scenario.expectedHook} should be called`); - assert.equal(projectionCall.arguments[1], 'codex', 'projection must be parked under the closure owner'); - } - }); - - it('F254 Phase E: filtered stale turns keep original delivery indexes', async () => { - const deliverCalls = []; - const outboundHook = { - deliver: mock.fn(async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - onClosureCatchingUp: mock.fn(async () => {}), - }; - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, _content, _threadId, _messageId, _cats, _intent, options) { - yield { type: 'text', catId: 'opus', content: 'known-stale answer', timestamp: Date.now() }; - options.persistenceContext.outputCommitDecisions = { - opus: { - kind: 'superseded_positive_stale', - closureId: 'closure-opus-stale', - requiredFrontierMessageId: 'msg-newer', - }, - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'text', catId: 'codex', content: 'fresh answer', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - enqueueEntry(hookDeps.queue, { targetCats: ['opus', 'codex'] }); - const processing = hookDeps.queue.markProcessing('t1', 'u1'); - - await hookProcessor.executeEntry(processing); - - assert.deepEqual(deliverCalls, [{ threadId: 't1', content: 'fresh answer', catId: 'codex' }]); - }); - - it('replace-mode text overwrites server-side aggregated outbound and streaming content', async () => { - const deliverCalls = []; - const outboundHook = { - deliver: mock.fn(async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: '第一段。第二段。', timestamp: Date.now() }; - yield { - type: 'text', - catId: 'opus', - content: '第一段。插入一句。第二段。', - textMode: 'replace', - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => deliverCalls.length >= 1); - - assert.equal(deliverCalls[0].content, '第一段。插入一句。第二段。'); - const lastChunkCall = streamingHook.onStreamChunk.mock.calls.at(-1); - assert.ok(lastChunkCall, 'streaming hook should receive chunks'); - assert.equal(lastChunkCall.arguments[1], '第一段。插入一句。第二段。'); - const endCall = streamingHook.onStreamEnd.mock.calls.at(-1); - assert.ok(endCall, 'streaming hook should receive final end'); - assert.equal(endCall.arguments[1], '第一段。插入一句。第二段。'); - }); - - it('multi-cat execution: outboundHook.deliver called per-turn with each catId', async () => { - const deliverCalls = []; - const outboundHook = { - deliver: mock.fn(async (threadId, content, catId, richBlocks, threadMeta) => { - deliverCalls.push({ threadId, content, catId, richBlocks, threadMeta }); - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'Opus says hi. ', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'text', catId: 'codex', content: 'Codex chimes in.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue, { targetCats: ['opus'] }); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => deliverCalls.length >= 2); - - assert.equal(deliverCalls.length, 2, 'deliver should be called once per cat turn'); - assert.equal(deliverCalls[0].catId, 'opus', 'first deliver should be for opus'); - assert.equal(deliverCalls[0].content, 'Opus says hi. ', 'opus content should match'); - assert.equal(deliverCalls[1].catId, 'codex', 'second deliver should be for codex'); - assert.equal(deliverCalls[1].content, 'Codex chimes in.', 'codex content should match'); - }); - - it('BUG-5: multi-turn delivers per-turn (no merge needed, token reusable)', async () => { - const deliverCalls = []; - const outboundHook = { - deliver: mock.fn(async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'Opus says hi. ', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'text', catId: 'codex', content: 'Codex chimes in.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue, { targetCats: ['opus'] }); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => deliverCalls.length >= 2); - - assert.equal(deliverCalls.length, 2, 'Multi-turn delivers per-turn'); - assert.strictEqual(deliverCalls[0].catId, 'opus'); - assert.ok(deliverCalls[0].content.includes('Opus says hi.')); - assert.strictEqual(deliverCalls[1].catId, 'codex'); - assert.ok(deliverCalls[1].content.includes('Codex chimes in.')); - }); - - it('no outboundHook: execution completes normally without delivery', async () => { - const entry = enqueueEntry(deps.queue); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await processor.processNext('t1', 'u1'); - await waitFor(() => - deps.invocationRecordStore.update.mock.calls.some((c) => c.arguments[1]?.status === 'succeeded'), - ); - - const updateCalls = deps.invocationRecordStore.update.mock.calls; - const succeededUpdate = updateCalls.find((c) => c.arguments[1]?.status === 'succeeded'); - assert.ok(succeededUpdate, 'should succeed even without outboundHook'); - }); - - it('delivery failure: cleanupPlaceholders is NOT called after hard delivery failure (R5-P1)', async () => { - // R5-P1 design: when delivery fails, placeholder is preserved as fallback indicator - // for the next retry/invocation. Cleanup must NOT run on failure. - // F151: mid-loop delivery retries failed turns in the final phase, - // so use catId-based failure to ensure opus consistently fails. - const outboundHook = { - deliver: mock.fn(async (_threadId, _content, catId) => { - if (catId === 'opus') throw new Error('delivery failed'); - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'Turn 1. ', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - yield { type: 'text', catId: 'codex', content: 'Turn 2.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue, { targetCats: ['opus'] }); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - // F151: mid-loop delivers both, opus fails and retries in final phase = 3 calls total - await waitFor(() => outboundHook.deliver.mock.calls.length >= 3); - - assert.equal(outboundHook.deliver.mock.calls.length, 3, 'mid-loop (2) + final-phase retry (1)'); - - // Settle any pending allSettled callbacks - await new Promise((r) => setTimeout(r, 200)); - assert.equal( - streamingHook.cleanupPlaceholders.mock.calls.length, - 0, - 'cleanupPlaceholders must NOT be called when delivery fails (R5-P1: preserve placeholder as fallback)', - ); - }); - - it('all deliveries succeed: cleanupPlaceholders called', async () => { - const outboundHook = { - deliver: mock.fn(async () => {}), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'Success text', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => streamingHook.cleanupPlaceholders.mock.calls.length >= 1); - - assert.equal(outboundHook.deliver.mock.calls.length, 1, 'deliver called once'); - assert.ok( - streamingHook.cleanupPlaceholders.mock.calls.length >= 1, - 'cleanupPlaceholders should be called when all deliveries succeed', - ); - }); - - it('governance-blocked failure cleans up streaming placeholders', async () => { - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { - type: 'system_info', - catId: 'opus', - content: JSON.stringify({ - type: 'governance_blocked', - projectPath: '/home/user/projects/EchoAgent', - reasonKind: 'needs_bootstrap', - invocationId: 'inv-stub', - }), - timestamp: Date.now(), - }; - yield { - type: 'done', - catId: 'opus', - timestamp: Date.now(), - errorCode: 'GOVERNANCE_BOOTSTRAP_REQUIRED', - }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - streamingHook, - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => streamingHook.cleanupPlaceholders.mock.calls.length >= 1); - - assert.ok(streamingHook.onStreamStart.mock.calls.length >= 1, 'onStreamStart should be called'); - assert.ok( - streamingHook.onStreamEnd.mock.calls.length >= 1, - 'governance failure should finalize the streaming session', - ); - assert.equal( - streamingHook.cleanupPlaceholders.mock.calls.length, - 1, - 'governance failure should clean up the streaming placeholder exactly once', - ); - }); - - it('outboundHook set via late-bind setOutboundHook: deliver is called', async () => { - const lateDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'text', catId: 'opus', content: 'Late-bound delivery', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const lateProcessor = new QueueProcessor(lateDeps); - - const deliverCalls = []; - lateProcessor.setOutboundHook({ - deliver: mock.fn(async (threadId, content, catId) => { - deliverCalls.push({ threadId, content, catId }); - }), - }); - - const entry = enqueueEntry(lateDeps.queue); - lateDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await lateProcessor.processNext('t1', 'u1'); - await waitFor(() => deliverCalls.length >= 1); - - assert.equal(deliverCalls.length, 1, 'late-bound hook should be called'); - assert.equal(deliverCalls[0].content, 'Late-bound delivery'); - }); - - it('P2-1 regression: failed invocation still triggers notifyDeliveryBatchDone', async () => { - const batchDoneCalls = []; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - notifyDeliveryBatchDone: mock.fn(async (threadId, chainDone) => { - batchDoneCalls.push({ threadId, chainDone }); - }), - }; - - const hookDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - throw new Error('invocation crashed'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => batchDoneCalls.length >= 1); - - assert.equal(batchDoneCalls.length, 1, 'notifyDeliveryBatchDone must fire on failure'); - assert.equal(batchDoneCalls[0].threadId, 't1'); - assert.equal( - batchDoneCalls[0].chainDone, - false, - 'failed primary work was restored to Queue, so the delivery chain is still open', - ); - }); - - it('P3-P2: reject callback (executeEntry throws in finally) still triggers notifyDeliveryBatchDone', async () => { - const batchDoneCalls = []; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - notifyDeliveryBatchDone: mock.fn(async (threadId, chainDone) => { - batchDoneCalls.push({ threadId, chainDone }); - }), - }; - - // Make invocationTracker.complete throw in finally block → executeEntry rejects - const hookDeps = stubDeps({ - invocationTracker: { - start: mock.fn(() => new AbortController()), - complete: mock.fn(() => { - throw new Error('tracker.complete crashed'); - }), - has: mock.fn(() => false), - }, - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - streamingHook, - threadMetaLookup: mock.fn(async () => undefined), - }); - const hookProcessor = new QueueProcessor(hookDeps); - - const entry = enqueueEntry(hookDeps.queue); - hookDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await hookProcessor.processNext('t1', 'u1'); - await waitFor(() => batchDoneCalls.length >= 1); - - assert.equal(batchDoneCalls.length, 1, 'reject callback must also fire notifyDeliveryBatchDone'); - assert.equal(batchDoneCalls[0].threadId, 't1'); - }); - }); - - // ── R7: silent fallback late-success/failure cleanup ── - - describe('silent fallback late-success cleanup (R7)', () => { - /** Poll until predicate returns true or timeout. */ - async function waitFor(predicate, timeoutMs = 5000, intervalMs = 10) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (predicate()) return; - await new Promise((r) => setTimeout(r, intervalMs)); - } - throw new Error(`waitFor timed out after ${timeoutMs}ms`); - } - - it('silent fallback timeout + late-success → cleanupPlaceholders called', async () => { - // Silent invocation (only done, no text) → deliver times out → deliver - // later succeeds → cleanupPlaceholders must be called on late-success. - let resolveDeliver; - const deliverGate = new Promise((r) => { - resolveDeliver = r; - }); - const outboundHook = { - deliver: mock.fn(async () => { - await deliverGate; - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - // Silent router: only yields done, no text content. - // deliverTimeoutMs: 50 — short timeout so test doesn't wait 10s. - const silentDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', content: '', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - deliverTimeoutMs: 50, - threadMetaLookup: mock.fn(async () => undefined), - }); - const silentProcessor = new QueueProcessor(silentDeps); - - const entry = enqueueEntry(silentDeps.queue); - silentDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await silentProcessor.processNext('t1', 'u1'); - - // Timeout (50ms) has already fired; deliver still hanging on deliverGate - await new Promise((r) => setTimeout(r, 100)); - assert.equal( - streamingHook.cleanupPlaceholders.mock.calls.length, - 0, - 'cleanup must NOT run immediately after silent timeout', - ); - - // Late-success: deliver finally resolves - resolveDeliver(); - await waitFor(() => streamingHook.cleanupPlaceholders.mock.calls.length >= 1); - assert.equal( - streamingHook.cleanupPlaceholders.mock.calls.length, - 1, - 'cleanup must run after silent late-success delivery (R7)', - ); - }); - - it('silent fallback timeout + late-failure → cleanupPlaceholders NOT called', async () => { - // Silent invocation → deliver times out → deliver later rejects - // → cleanupPlaceholders must NOT be called (thinking card stays). - let rejectDeliver; - const deliverGate = new Promise((_, rej) => { - rejectDeliver = rej; - }); - const outboundHook = { - deliver: mock.fn(async () => { - await deliverGate; - }), - }; - const streamingHook = { - onStreamStart: mock.fn(async () => {}), - onStreamChunk: mock.fn(async () => {}), - onStreamEnd: mock.fn(async () => {}), - cleanupPlaceholders: mock.fn(async () => {}), - }; - - const silentDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: 'opus', content: '', timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - outboundHook, - streamingHook, - deliverTimeoutMs: 50, - threadMetaLookup: mock.fn(async () => undefined), - }); - const silentProcessor = new QueueProcessor(silentDeps); - - const entry = enqueueEntry(silentDeps.queue); - silentDeps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); - - await silentProcessor.processNext('t1', 'u1'); - - // Timeout (50ms) has already fired; deliver still hanging on deliverGate - await new Promise((r) => setTimeout(r, 100)); - assert.equal(streamingHook.cleanupPlaceholders.mock.calls.length, 0, 'cleanup must NOT run after silent timeout'); - - // Late-failure: deliver rejects - rejectDeliver(new Error('connector down')); - await new Promise((r) => setTimeout(r, 100)); - assert.equal( - streamingHook.cleanupPlaceholders.mock.calls.length, - 0, - 'cleanup must NOT run after silent hard failure (R7: preserve placeholder)', - ); - }); - }); - - // ── F175 Task 5: user-message batching at dequeue ── - - describe('user-message batching (F175)', () => { - async function waitForQueue(queue, threadId, userId, predicate, timeoutMs = 2000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (predicate(queue.list(threadId, userId))) return; - await new Promise((r) => setTimeout(r, 10)); - } - throw new Error(`waitForQueue timed out after ${timeoutMs}ms`); - } - - it('combines adjacent user entries into single routeExecution call', async () => { - enqueueEntry(deps.queue, { content: 'msg-a' }); - enqueueEntry(deps.queue, { content: 'msg-b' }); - enqueueEntry(deps.queue, { content: 'msg-c' }); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', (q) => deps.router.routeExecution.mock.calls.length >= 1); - - assert.equal(deps.router.routeExecution.mock.calls.length, 1, 'should call routeExecution once'); - const calledContent = deps.router.routeExecution.mock.calls[0].arguments[1]; - assert.equal(calledContent, 'msg-a\nmsg-b\nmsg-c', 'content should be combined'); - }); - - it('#1291 exact reservation executes selected A+B once without absorbing adjacent C', async () => { - const a = enqueueEntry(deps.queue, { content: 'msg-a', ownerAuthProvenance: 'strict' }); - const b = enqueueEntry(deps.queue, { content: 'msg-b', ownerAuthProvenance: 'strict' }); - enqueueEntry(deps.queue, { content: 'msg-c', ownerAuthProvenance: 'strict' }); - const reserved = deps.queue.reserveExactUserBatch('t1', 'u1', [a.id, b.id]); - assert.equal(reserved.outcome, 'reserved'); - assert.equal(deps.queue.beginExactSteerPreemption('t1', 'u1', reserved.reservationId), true); - assert.equal(deps.queue.activateExactSteerReservation('t1', 'u1', reserved.reservationId), true); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.router.routeExecution.mock.calls.length >= 1); - - assert.equal(deps.router.routeExecution.mock.calls[0].arguments[1], 'msg-a\nmsg-b'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.router.routeExecution.mock.calls.length >= 2); - assert.equal( - deps.router.routeExecution.mock.calls[1].arguments[1], - 'msg-c', - 'unselected C may run next but is never absorbed into the selected invocation', - ); - }); - - it('#1291 restart fallback never lets persisted Steer intent absorb an unselected neighbor', async () => { - const a = enqueueEntry(deps.queue, { content: 'msg-a', ownerAuthProvenance: 'strict' }); - const b = enqueueEntry(deps.queue, { content: 'msg-b', ownerAuthProvenance: 'strict' }); - enqueueEntry(deps.queue, { content: 'msg-c', ownerAuthProvenance: 'strict' }); - - // exactSteerBatch is deliberately process-local. After restart, durable - // custody restores the selected entries' Steer intent without the group - // marker; fail closed to separate invocations instead of widening to C. - assert.equal(deps.queue.markSteering('t1', 'u1', a.id, 'opus'), true); - assert.equal(deps.queue.markSteering('t1', 'u1', b.id, 'opus'), true); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.router.routeExecution.mock.calls.length >= 3); - - assert.deepEqual( - deps.router.routeExecution.mock.calls.slice(0, 3).map((call) => call.arguments[1]), - ['msg-a', 'msg-b', 'msg-c'], - ); - }); - - it('#1291 exact reservation rolls every selected member back together on provider failure', async () => { - const failDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - throw new Error('provider unavailable'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const failProcessor = new QueueProcessor(failDeps); - const a = enqueueEntry(failDeps.queue, { content: 'a', ownerAuthProvenance: 'strict' }); - const b = enqueueEntry(failDeps.queue, { content: 'b', ownerAuthProvenance: 'strict' }); - const c = enqueueEntry(failDeps.queue, { content: 'c', ownerAuthProvenance: 'strict' }); - const reserved = failDeps.queue.reserveExactUserBatch('t1', 'u1', [a.id, b.id]); - assert.equal(reserved.outcome, 'reserved'); - assert.equal(failDeps.queue.beginExactSteerPreemption('t1', 'u1', reserved.reservationId), true); - assert.equal(failDeps.queue.activateExactSteerReservation('t1', 'u1', reserved.reservationId), true); - - await failProcessor.processNext('t1', 'u1'); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const byId = new Map(failDeps.queue.list('t1', 'u1').map((entry) => [entry.id, entry])); - assert.equal(byId.get(a.id).status, 'queued'); - assert.equal(byId.get(b.id).status, 'queued'); - assert.equal(byId.get(a.id).exactSteerBatch.reservationId, reserved.reservationId); - assert.equal(byId.get(b.id).exactSteerBatch.reservationId, reserved.reservationId); - assert.equal(byId.get(c.id).exactSteerBatch, undefined); - }); - - it('#1291 exact reservation restores every selected member when processing custody cannot persist', async () => { - const persistEntry = mock.fn(async () => { - throw new Error('custody unavailable'); - }); - const persistDeps = stubDeps({ queueCustodyCoordinator: { persistEntry } }); - const persistProcessor = new QueueProcessor(persistDeps); - const a = enqueueEntry(persistDeps.queue, { content: 'a', ownerAuthProvenance: 'strict' }); - const b = enqueueEntry(persistDeps.queue, { content: 'b', ownerAuthProvenance: 'strict' }); - const reserved = persistDeps.queue.reserveExactUserBatch('t1', 'u1', [a.id, b.id]); - assert.equal(reserved.outcome, 'reserved'); - assert.equal(persistDeps.queue.beginExactSteerPreemption('t1', 'u1', reserved.reservationId), true); - assert.equal(persistDeps.queue.activateExactSteerReservation('t1', 'u1', reserved.reservationId), true); - - const result = await persistProcessor.processNext('t1', 'u1'); - - assert.equal(result.started, false); - assert.equal(persistDeps.router.routeExecution.mock.calls.length, 0); - const selected = persistDeps.queue.list('t1', 'u1').filter((entry) => entry.id === a.id || entry.id === b.id); - assert.equal(selected.length, 2); - assert.ok(selected.every((entry) => entry.status === 'queued')); - }); - - it('never lets fallback content ride a strict owner invocation', async () => { - enqueueEntry(deps.queue, { content: 'strict-owner-message', ownerAuthProvenance: 'strict' }); - enqueueEntry(deps.queue, { - content: 'fallback-owner-message', - ownerAuthProvenance: 'compatibility_fallback', - }); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.router.routeExecution.mock.calls.length >= 1); - - const firstCall = deps.router.routeExecution.mock.calls[0]; - assert.equal(firstCall.arguments[1], 'strict-owner-message'); - assert.equal(firstCall.arguments[6].ownerAuthProvenance, 'strict'); - }); - - it('does not merge a failed-target carrier into an eligible sibling provider call', async () => { - const batchDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - for (const catId of targetCats) { - yield { type: 'done', catId, timestamp: Date.now() }; - } - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const batchProcessor = new QueueProcessor(batchDeps); - enqueueEntry(batchDeps.queue, { - content: 'eligible-primary', - targetCats: ['opus', 'codex'], - }); - const failedSecondary = enqueueEntry(batchDeps.queue, { - content: 'failed-secondary-must-stay-out', - targetCats: ['opus', 'codex'], - }); - batchDeps.queue.markQueuedFailedForCatAcrossUsers( - 't1', - 'opus', - 'inv-failed-secondary', - new Set([failedSecondary.id]), - ); - - await batchProcessor.processNext('t1', 'u1'); - await waitForQueue(batchDeps.queue, 't1', 'u1', () => batchDeps.router.routeExecution.mock.calls.length >= 1); - - const firstCall = batchDeps.router.routeExecution.mock.calls[0]; - assert.equal(firstCall.arguments[1], 'eligible-primary'); - assert.deepEqual(firstCall.arguments[4], ['opus', 'codex']); - await waitForQueue(batchDeps.queue, 't1', 'u1', () => batchDeps.router.routeExecution.mock.calls.length >= 2); - const siblingCall = batchDeps.router.routeExecution.mock.calls[1]; - assert.equal(siblingCall.arguments[1], 'failed-secondary-must-stay-out'); - assert.deepEqual(siblingCall.arguments[4], ['codex']); - }); - - it('marks all batched entries as processing', async () => { - enqueueEntry(deps.queue, { content: 'a' }); - enqueueEntry(deps.queue, { content: 'b' }); - - await processor.processNext('t1', 'u1'); - - const remaining = deps.queue.list('t1', 'u1').filter((e) => e.status === 'queued'); - assert.equal(remaining.length, 0, 'no queued entries should remain after batch'); - }); - - it('does not batch connector entries', async () => { - enqueueEntry(deps.queue, { content: 'conn-a', source: 'connector' }); - enqueueEntry(deps.queue, { content: 'conn-b', source: 'connector' }); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.router.routeExecution.mock.calls.length >= 1); - - const calledContent = deps.router.routeExecution.mock.calls[0].arguments[1]; - assert.equal(calledContent, 'conn-a', 'connector entries should not be batched'); - // After auto-dequeue settles, conn-b should be processed separately (not batched with conn-a) - await new Promise((r) => setTimeout(r, 100)); - assert.equal(deps.router.routeExecution.mock.calls.length, 2, 'each connector entry processed separately'); - assert.equal(deps.router.routeExecution.mock.calls[1].arguments[1], 'conn-b'); - }); - - it('stops batch at different intent', async () => { - enqueueEntry(deps.queue, { content: 'exec-a', intent: 'execute' }); - enqueueEntry(deps.queue, { content: 'search-b', intent: 'search' }); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.router.routeExecution.mock.calls.length >= 1); - - const calledContent = deps.router.routeExecution.mock.calls[0].arguments[1]; - assert.equal(calledContent, 'exec-a', 'should only include matching-intent entries'); - }); - - it('removes all batched entries after successful execution', async () => { - enqueueEntry(deps.queue, { content: 'a' }); - enqueueEntry(deps.queue, { content: 'b' }); - enqueueEntry(deps.queue, { content: 'c' }); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', (q) => q.length === 0); - - const all = deps.queue.list('t1', 'u1'); - assert.equal(all.length, 0, 'all batched entries should be removed after completion'); - }); - - it('P1: failed execution rolls back batched entries instead of dropping them', async () => { - const failDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* () { - throw new Error('CLI spawn failed'); - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const failProcessor = new QueueProcessor(failDeps); - - enqueueEntry(failDeps.queue, { content: 'primary' }); - enqueueEntry(failDeps.queue, { content: 'batched-a' }); - enqueueEntry(failDeps.queue, { content: 'batched-b' }); - - await failProcessor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 100)); - - const remaining = failDeps.queue.list('t1', 'u1'); - const queued = remaining.filter((e) => e.status === 'queued'); - assert.ok(queued.length >= 2, `batched entries should be rolled back to queued, got ${queued.length}`); - const contents = queued.map((e) => e.content); - assert.ok(contents.includes('batched-a'), 'batched-a should be preserved'); - assert.ok(contents.includes('batched-b'), 'batched-b should be preserved'); - assert.ok( - queued.every((entry) => entry.queuedFailedByCatIds?.includes('opus')), - 'every batched Queue body supplied to the failed invocation must hydrate as failed', - ); - }); - - it('P1-1: batched entries messageIds are markDelivered-ed', async () => { - deps.messageStore.markDelivered = mock.fn(async (id) => ({ - id, - threadId: 't1', - content: 'c', - catId: null, - timestamp: Date.now(), - mentions: [], - userId: 'u1', - deliveryStatus: 'delivered', - deliveredAt: Date.now(), - deliveryTransitioned: true, - })); - - const e1 = enqueueEntry(deps.queue, { content: 'first' }); - deps.queue.backfillMessageId('t1', 'u1', e1.id, 'm1'); - const e2 = enqueueEntry(deps.queue, { content: 'second' }); - deps.queue.backfillMessageId('t1', 'u1', e2.id, 'm2'); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.messageStore.markDelivered.mock.calls.length >= 2); - - const deliveredIds = deps.messageStore.markDelivered.mock.calls.map((c) => c.arguments[0]); - assert.ok(deliveredIds.includes('m1'), 'primary entry messageId should be delivered'); - assert.ok(deliveredIds.includes('m2'), 'batched entry messageId should be delivered'); - }); - - it('P1-2: connector entry is NOT absorbed into user batch', async () => { - enqueueEntry(deps.queue, { content: 'user-msg', source: 'user' }); - enqueueEntry(deps.queue, { content: 'connector-msg', source: 'connector' }); - - await processor.processNext('t1', 'u1'); - await waitForQueue(deps.queue, 't1', 'u1', () => deps.router.routeExecution.mock.calls.length >= 1); - - const calledContent = deps.router.routeExecution.mock.calls[0].arguments[1]; - assert.equal(calledContent, 'user-msg', 'connector entry must not be batched into user content'); - // After auto-dequeue settles, connector entry should be processed separately - await new Promise((r) => setTimeout(r, 100)); - assert.equal(deps.router.routeExecution.mock.calls.length, 2, 'connector entry processed separately'); - assert.equal(deps.router.routeExecution.mock.calls[1].arguments[1], 'connector-msg'); - }); - - it('P2: urgent entry for busy slot does not block lower-priority entry for free slot', async () => { - const slowDeps = stubDeps({ - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn((tid, catId) => catId === 'codex'), - }, - }); - const slowProcessor = new QueueProcessor(slowDeps); - - // urgent entry for codex (slot busy), normal entry for opus (slot free) - enqueueEntry(slowDeps.queue, { content: 'urgent-codex', targetCats: ['codex'], priority: 'urgent' }); - enqueueEntry(slowDeps.queue, { content: 'normal-opus', targetCats: ['opus'], priority: 'normal' }); - - // Trigger across-users chain (simulates codex slot completing, then scanning queue) - // codex is still busy (has() returns true), opus is free - await slowProcessor.onInvocationComplete('t1', 'opus', 'succeeded'); - await new Promise((r) => setTimeout(r, 100)); - - // opus entry should execute despite urgent codex being first in sort order - const routeCalls = slowDeps.router.routeExecution.mock.calls; - assert.ok(routeCalls.length >= 1, 'should execute free-slot entry'); - const calledContent = routeCalls[0].arguments[1]; - assert.equal(calledContent, 'normal-opus', 'should execute opus entry, skipping busy codex'); - - // codex entry should remain queued - const codexEntries = slowDeps.queue.list('t1', 'u1').filter((e) => e.content === 'urgent-codex'); - assert.equal(codexEntries.length, 1, 'codex entry should remain'); - assert.equal(codexEntries[0].status, 'queued', 'codex entry should still be queued'); - }); - - it('P1: duplicate primary does not mark batched entries as processing', async () => { - let callCount = 0; - const dupeDeps = stubDeps({ - invocationRecordStore: { - create: mock.fn(async () => { - callCount++; - if (callCount === 1) return { outcome: 'duplicate', invocationId: 'inv-dupe' }; - return { outcome: 'created', invocationId: `inv-${callCount}` }; - }), - update: mock.fn(async () => {}), - }, - }); - const dupeProcessor = new QueueProcessor(dupeDeps); - - enqueueEntry(dupeDeps.queue, { content: 'a' }); - enqueueEntry(dupeDeps.queue, { content: 'b' }); - enqueueEntry(dupeDeps.queue, { content: 'c' }); - - await dupeProcessor.processNext('t1', 'u1'); - await new Promise((r) => setTimeout(r, 100)); - - // Entry 'a' hits duplicate → returns early. With the fix, b and c are NOT - // marked processing on the duplicate path. The chain then dequeues b (non-duplicate), - // which batches c. So routeExecution sees b+c content, not a+b+c. - const routeCalls = dupeDeps.router.routeExecution.mock.calls; - assert.ok(routeCalls.length >= 1, 'chain should process remaining entries'); - const calledContent = routeCalls[0].arguments[1]; - assert.ok(!calledContent.includes('a'), 'duplicate entry content must not appear in batched execution'); - }); - }); - - // ── F185 AC-7: tryAutoExecute fairness gate ── - - describe('tryAutoExecute fairness gate (F185 AC-7)', () => { - it('dispatches the protected user entry before returning from the agent fairness gate', async () => { - let releaseUser; - const userGate = new Promise((resolve) => { - releaseUser = resolve; - }); - const routeCalls = []; - const fairnessDeps = stubDeps({ - router: { - routeExecution: mock.fn(async function* (...args) { - routeCalls.push(args); - await userGate; - yield { type: 'done', catId: args[4][0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - const fairnessProcessor = new QueueProcessor(fairnessDeps); - // User entry queued (non-agent) - enqueueEntry(fairnessDeps.queue, { - userId: 'u1', - source: 'user', - targetCats: ['opus'], - }); - // Agent autoExecute entry queued - enqueueEntry(fairnessDeps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['codex'], - autoExecute: true, - callerCatId: 'opus', - }); - - await fairnessProcessor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.equal(routeCalls.length, 1, 'the fairness gate must actively dispatch the protected work'); - assert.deepEqual(routeCalls[0][4], ['opus']); - const agentEntries = fairnessDeps.queue.list('t1', 'system'); - assert.equal(agentEntries.length, 1, 'agent entry should remain queued'); - assert.equal(agentEntries[0].status, 'queued'); - - releaseUser(); - await waitFor(() => routeCalls.length === 2); - }); - - it('AC-11: A2A chain + connector entry → connector dispatches before autoExecute', async () => { - // Connector entry queued first - enqueueEntry(deps.queue, { - userId: 'u1', - source: 'connector', - targetCats: ['opus'], - }); - // Agent A2A chain entry queued after - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['codex'], - autoExecute: true, - callerCatId: 'opus', - }); - - await processor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.ok(deps.invocationTracker.startAll.mock.calls.length > 0, 'the protected connector must be dispatched'); - assert.deepEqual( - deps.invocationTracker.startAll.mock.calls[0].arguments[1], - ['opus'], - 'the agent entry must not jump ahead of the connector', - ); - }); - - it('allows auto-execute when only agent entries are queued', async () => { - enqueueEntry(deps.queue, { - userId: 'system', - source: 'agent', - targetCats: ['codex'], - autoExecute: true, - callerCatId: 'opus', - }); - - await processor.tryAutoExecute('t1'); - await new Promise((r) => setTimeout(r, 50)); - - assert.ok( - deps.invocationTracker.startAll.mock.calls.length > 0, - 'should auto-execute when only agent entries are queued', - ); - }); - }); - - // ── F216 c3: supersede tombstone guard + immediate restart regression ── - - describe('F216 c3: pre-start window supersede tombstone', () => { - it('FIRST never reaches routeExecution and SECOND restarts immediately (no 10s pause)', async () => { - // This test catches the exact bug from review R3: if the tombstone guard returns - // plain 'canceled' instead of 'canceled_by_user', onInvocationComplete pauses the - // slot for 10s and SECOND doesn't start promptly. 22/22 existing tests were green - // on that broken commit — THIS test would have caught it. - - const routedContents = []; - let createResolve; - const createPromise = new Promise((resolve) => { - createResolve = resolve; - }); - - const deps = stubDeps({ - invocationRecordStore: { - // Delayed create() — simulates the pre-start window (markProcessing → startAll gap) - create: mock.fn(async () => { - await createPromise; // blocks until we manually resolve - return { outcome: 'created', invocationId: 'inv-supersede-test' }; - }), - update: mock.fn(async () => {}), - }, - router: { - routeExecution: mock.fn(async function* (_userId, content, _threadId, _messageId, targetCats) { - routedContents.push(content); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - }); - - const processor = new QueueProcessor(deps); - - // 1. Enqueue FIRST and trigger execution — it will block at create() - const first = enqueueEntry(deps.queue, { - content: 'FIRST: do task X', - source: 'agent', - targetCats: ['antig-opus'], - autoExecute: true, - }); - deps.queue.backfillMessageId('t1', 'u1', first.id, 'msg-first'); - await processor.tryAutoExecute('t1'); - - // At this point: FIRST is marked processing, executeEntry is awaiting createPromise. - // Verify FIRST is processing (slot taken). - assert.equal( - deps.queue.list('t1', 'u1').some((e) => e.id === first.id && e.status === 'processing'), - true, - 'FIRST should be processing (pre-start window open)', - ); - - // 2. Simulate supersede: remove FIRST (tombstone) + enqueue SECOND (follow-up) - deps.queue.removeProcessed('t1', 'u1', first.id); - deps.queue.enqueue({ - threadId: 't1', - userId: 'u1', - content: 'SECOND: answer 3 questions first', - source: 'agent', - ownerAuthProvenance: 'unknown', - targetCats: ['antig-opus'], - intent: 'execute', - autoExecute: true, - }); - - // 3. Release the create() — executeEntry continues to startAll → tombstone guard fires - createResolve(); - - // Wait for the full chain: startAll → guard → return 'canceled_by_user' → .then → - // processingSlots.delete → onInvocationComplete → tryAutoExecute → SECOND starts - await new Promise((r) => setTimeout(r, 100)); - - // 4. FIRST must NOT have been routed - const firstRouted = routedContents.some((c) => c.includes('FIRST')); - assert.equal(firstRouted, false, 'FIRST must NOT reach routeExecution (tombstone guard)'); - - // 5. SECOND must have been routed (immediate restart, not 10s pause) - const secondRouted = routedContents.some((c) => c.includes('SECOND')); - assert.equal(secondRouted, true, 'SECOND must route promptly via immediate restart (not 10s pause)'); - - // 6. No paused slots remain (regression: 'canceled' would leave a paused slot) - assert.equal(processor.getPauseReason('t1', 'antig-opus'), undefined, 'no stale pause on the slot'); - - // 7. Queue should be empty (both entries consumed) - const remaining = deps.queue.list('t1', 'u1').filter((e) => e.status === 'queued'); - assert.equal(remaining.length, 0, 'queue should be empty after supersede lifecycle'); - }); - }); - - describe('F222 P1: frustrationAutoIssueEligible source whitelist', () => { - for (const { source, expected, label } of [ - { source: 'user', expected: true, label: 'user source → eligible=true' }, - { source: 'agent', expected: false, label: 'agent source → eligible=false' }, - { source: 'connector', expected: false, label: 'connector source → eligible=false' }, - ]) { - it(label, async () => { - let capturedEligible; - let capturedDispositionOrigin; - deps.router.routeExecution = mock.fn( - async function* (_userId, _content, _threadId, _messageId, _targetCats, _intent, options) { - capturedEligible = options?.frustrationAutoIssueEligible; - capturedDispositionOrigin = options?.humanDispositionInvocationOrigin; - yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; - }, - ); - - enqueueEntry(deps.queue, { source }); - const result = await processor.processNext('t1', 'u1'); - assert.equal(result.started, true); - await new Promise((resolve) => setTimeout(resolve, 80)); - - assert.equal( - capturedEligible, - expected, - `source:'${source}' must pass frustrationAutoIssueEligible=${expected}`, - ); - assert.equal( - capturedDispositionOrigin, - 'queue_replay', - `source:'${source}' is queue replay even when the queued author is the owner`, - ); - }); - } + registry.registerPersistedSource(source, ['worker']); + + const callerWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', callerWake.entry.id) === null); + assert.match(observedPrompts[0], /process_start processGeneration=api:restart-test:42/); + assert.match(observedPrompts[0], new RegExp(`${source.id} → worker: executing`)); + assert.doesNotMatch(observedPrompts[0], /runtime_restart|recoveryRequired|old history/); + + const retryWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', retryWake.entry.id) === null); + assert.match(observedPrompts[1], /process_start processGeneration=api:restart-test:42/); + assert.match(observedPrompts[1], new RegExp(`${source.id} → worker: executing`)); + + const afterAckWake = await admitMessage(harness, { targetCats: ['caller'] }); + assert.equal((await harness.processor.processNext('thread-1', 'user-1')).started, true); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', afterAckWake.entry.id) === null); + assert.doesNotMatch(observedPrompts[2], /process_start|runtime_restart|recoveryRequired/); + assert.doesNotMatch(observedPrompts[2], new RegExp(`${source.id} → worker: executing`)); + assert.equal(harness.messageStore.getByThreadBefore.mock.calls.length, 0); + assert.equal(registry.list({ ownerId: 'user-1', threadId: 'thread-1', callerCatId: 'caller' }).length, 1); }); }); diff --git a/packages/api/test/queue-steer-mixed-target.test.js b/packages/api/test/queue-steer-mixed-target.test.js deleted file mode 100644 index 9e5388908a..0000000000 --- a/packages/api/test/queue-steer-mixed-target.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it, mock } from 'node:test'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); - -function deferred() { - let resolve; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -async function waitFor(predicate, timeoutMs = 2_000) { - const startedAt = Date.now(); - while (!predicate()) { - if (Date.now() - startedAt > timeoutMs) throw new Error('timed out waiting for Queue execution'); - await new Promise((resolve) => setTimeout(resolve, 5)); - } -} - -describe('QueueProcessor mixed-target exact Steer', () => { - for (const siblingState of ['failed', 'busy']) { - it(`keeps the reserved target as slot owner and excludes its ${siblingState} sibling`, async () => { - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const recordCreate = deferred(); - const routeExecution = mock.fn(async function* (_userId, _content, _threadId, _messageId, targetCats) { - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }); - const processor = new QueueProcessor({ - queue, - invocationTracker: tracker, - invocationRecordStore: { - create: mock.fn(async () => recordCreate.promise), - update: mock.fn(async () => ({})), - }, - router: { routeExecution, ackCollectedCursors: mock.fn(async () => {}) }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - messageStore: { - append: mock.fn(async () => ({ id: 'response-1' })), - getById: mock.fn(async () => null), - }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - }); - - const queued = queue.enqueue({ - threadId: 't1', - userId: 'user-a', - content: 'steer only the pending sibling', - source: 'user', - ownerAuthProvenance: 'strict', - targetCats: ['opus', 'codex'], - intent: 'execute', - messageId: 'message-1', - }); - assert.equal(queued.outcome, 'enqueued'); - assert.ok(queued.entry); - const sibling = - siblingState === 'busy' ? tracker.start('t1', 'opus', 'user-a', ['opus'], 'unrelated-sibling') : null; - if (siblingState === 'failed') - queue.markQueuedFailedForCatAcrossUsers('t1', 'opus', 'inv-opus', new Set([queued.entry.id])); - - const reservation = queue.reserveExactUserEntry('t1', 'user-a', queued.entry.id, 'codex'); - assert.equal(reservation.outcome, 'reserved'); - assert.equal(queue.beginExactSteerPreemption('t1', 'user-a', reservation.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'user-a', reservation.reservationId), true); - - const started = await processor.processExactSteerReservation( - 't1', - 'user-a', - queued.entry.id, - reservation.reservationId, - ); - assert.equal(started.started, true); - assert.equal(processor.isCatBusy('t1', 'codex'), true, 'the exact reservation target must own the slot'); - assert.equal(processor.isCatBusy('t1', 'opus'), siblingState === 'busy'); - - recordCreate.resolve({ outcome: 'created', invocationId: 'inv-codex' }); - await waitFor(() => routeExecution.mock.calls.length === 1); - assert.deepEqual(routeExecution.mock.calls[0].arguments[4], ['codex']); - if (sibling) assert.equal(sibling.signal.aborted, false, 'Steer cannot disturb the other target'); - }); - } -}); diff --git a/packages/api/test/queue-steer-prestart-batch.test.js b/packages/api/test/queue-steer-prestart-batch.test.js deleted file mode 100644 index 6b93ec30bd..0000000000 --- a/packages/api/test/queue-steer-prestart-batch.test.js +++ /dev/null @@ -1,403 +0,0 @@ -import assert from 'node:assert/strict'; -import { afterEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); -const { queueRoutes } = await import('../dist/routes/queue.js'); - -function asyncGate() { - let enter; - let release; - const entered = new Promise((resolve) => { - enter = resolve; - }); - const blocked = new Promise((resolve) => { - release = resolve; - }); - return { entered, blocked, enter, release }; -} - -function enqueue(queue, content, messageId) { - const result = queue.enqueue({ - threadId: 't1', - userId: 'user-a', - content, - source: 'user', - ownerAuthProvenance: 'strict', - targetCats: ['opus'], - intent: 'execute', - messageId, - }); - assert.equal(result.outcome, 'enqueued'); - return result.entry; -} - -async function waitFor(predicate, timeoutMs = 2000) { - const startedAt = Date.now(); - while (!predicate()) { - if (Date.now() - startedAt > timeoutMs) throw new Error('timed out waiting for queue transition'); - await new Promise((resolve) => setTimeout(resolve, 5)); - } -} - -describe('Steer trackerless exact-batch preemption', () => { - let app; - - afterEach(async () => { - if (app) await app.close(); - }); - - it('terminalizes the entire old batch before the new exact request becomes executable', async () => { - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const createGate = asyncGate(); - const durableEntries = new Map(); - const messages = new Map(); - const routedContents = []; - const log = { info: mock.fn(), warn: mock.fn(), error: mock.fn() }; - let createCount = 0; - - const messageStore = { - getByIdempotencyKey: mock.fn(async () => null), - getByThreadAfter: mock.fn(async () => []), - getByQueueExposure: mock.fn(async () => []), - getById: mock.fn(async (messageId) => messages.get(messageId) ?? null), - markCanceled: mock.fn(async (messageId) => { - const current = messages.get(messageId); - if (!current) return null; - const transitioned = current.deliveryStatus === 'queued'; - const next = { ...current, deliveryStatus: 'canceled', deliveryTransitioned: transitioned }; - messages.set(messageId, next); - return next; - }), - markDelivered: mock.fn(async (messageId) => { - const current = messages.get(messageId); - if (!current) return null; - const next = { ...current, deliveryStatus: 'delivered', deliveryTransitioned: true }; - messages.set(messageId, next); - return next; - }), - }; - const queueCustodyCoordinator = { - persistEntry: mock.fn(async (entry) => { - durableEntries.set(entry.id, structuredClone(entry)); - }), - withdrawEntry: mock.fn(async (entry) => durableEntries.delete(entry.id)), - markPrimaryTrigger: mock.fn(async () => true), - markReminderMissed: mock.fn(async () => true), - }; - const invocationRecordStore = { - create: mock.fn(async () => { - createCount += 1; - if (createCount === 1) { - createGate.enter(); - await createGate.blocked; - } - return { outcome: 'created', invocationId: createCount === 1 ? 'inv-old-batch' : 'inv-new-exact' }; - }), - update: mock.fn(async () => ({})), - }; - const socketManager = { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }; - const processor = new QueueProcessor({ - queue, - invocationTracker: tracker, - invocationRecordStore, - queueCustodyCoordinator, - messageStore, - socketManager, - router: { - routeExecution: mock.fn(async function* (_userId, content, _threadId, _messageId, targetCats) { - routedContents.push(content); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - log, - }); - - app = Fastify(); - await app.register(queueRoutes, { - threadStore: { get: mock.fn(async () => ({ id: 't1', title: 'test', createdBy: 'user-a' })) }, - invocationQueue: queue, - queueProcessor: processor, - invocationTracker: tracker, - invocationRecordStore, - queueCustodyCoordinator, - messageStore, - socketManager, - agentSessionMutex: { - forceReleaseByScope: mock.fn(() => ({ releasedHolders: 0, rejectedWaiters: 0, catIds: [] })), - }, - }); - await app.ready(); - - for (const [messageId, content] of [ - ['msg-a', 'old-a'], - ['msg-b', 'old-b'], - ]) { - messages.set(messageId, { - id: messageId, - threadId: 't1', - userId: 'user-a', - catId: null, - content, - mentions: ['opus'], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); - } - const a = enqueue(queue, 'old-a', 'msg-a'); - const b = enqueue(queue, 'old-b', 'msg-b'); - const c = enqueue(queue, 'new-c'); - for (const entry of [a, b, c]) await queueCustodyCoordinator.persistEntry(entry); - - const oldReservation = queue.reserveExactUserBatch('t1', 'user-a', [a.id, b.id]); - assert.equal(oldReservation.outcome, 'reserved'); - assert.equal(queue.beginExactSteerPreemption('t1', 'user-a', oldReservation.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'user-a', oldReservation.reservationId), true); - assert.equal( - ( - await processor.processExactSteerReservation( - 't1', - 'user-a', - oldReservation.primaryEntryId, - oldReservation.reservationId, - ) - ).started, - true, - ); - await createGate.entered; - - const steer = await app.inject({ - method: 'POST', - url: `/api/threads/t1/queue/${c.id}/steer`, - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: {}, - }); - assert.equal(steer.statusCode, 200); - assert.equal(steer.json().started, true); - assert.equal(queue.getEntrySnapshot('t1', 'user-a', a.id), null); - assert.equal(queue.getEntrySnapshot('t1', 'user-a', b.id), null); - assert.equal(durableEntries.has(a.id), false); - assert.equal(durableEntries.has(b.id), false); - - createGate.release(); - await waitFor(() => routedContents.includes('new-c')); - assert.deepEqual(routedContents, ['new-c'], 'only the second exact Steer may reach the provider'); - - const recoveredQueue = new InvocationQueue(); - for (const entry of durableEntries.values()) recoveredQueue.restoreDurableEntry(entry); - assert.equal(recoveredQueue.getEntrySnapshot('t1', 'user-a', a.id), null); - assert.equal(recoveredQueue.getEntrySnapshot('t1', 'user-a', b.id), null, 'batch sibling must not revive'); - }); - - it('external replacement retires every member of an exact pre-start batch', async () => { - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const durableEntries = new Map(); - const queueCustodyCoordinator = { - persistEntry: mock.fn(async (entry) => { - durableEntries.set(entry.id, structuredClone(entry)); - }), - withdrawEntry: mock.fn(async (entry) => durableEntries.delete(entry.id)), - }; - const processor = new QueueProcessor({ - queue, - invocationTracker: tracker, - queueCustodyCoordinator, - messageStore: { markCanceled: mock.fn(async () => null) }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - }); - const a = enqueue(queue, 'old-a'); - const b = enqueue(queue, 'old-b'); - durableEntries.set(a.id, structuredClone(a)); - durableEntries.set(b.id, structuredClone(b)); - - const reservation = queue.reserveExactUserBatch('t1', 'user-a', [a.id, b.id]); - assert.equal(reservation.outcome, 'reserved'); - assert.equal(queue.beginExactSteerPreemption('t1', 'user-a', reservation.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'user-a', reservation.reservationId), true); - assert.ok(queue.claimExactSteerReservation('t1', 'user-a', reservation.primaryEntryId, reservation.reservationId)); - processor.reserveProcessingSlot(JSON.stringify(['t1', 'opus']), a.id, 'user-a'); - - const replacement = await processor.acquireExternalExecution('t1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - assert.ok(replacement); - assert.equal(queue.getEntrySnapshot('t1', 'user-a', a.id), null); - assert.equal(queue.getEntrySnapshot('t1', 'user-a', b.id), null); - await waitFor(() => !durableEntries.has(a.id) && !durableEntries.has(b.id)); - assert.equal(tracker.has('t1', 'opus'), true, 'the replacement becomes the only slot owner'); - }); - - it('parks external replacement before retiring its old batch while manual seal owns the slot', async () => { - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const durableEntries = new Map(); - const withdrawalGate = asyncGate(); - let withdrawalStarted = false; - const queueCustodyCoordinator = { - persistEntry: mock.fn(async (entry) => { - durableEntries.set(entry.id, structuredClone(entry)); - }), - withdrawEntry: mock.fn(async (entry) => { - withdrawalStarted = true; - withdrawalGate.enter(); - await withdrawalGate.blocked; - return durableEntries.delete(entry.id); - }), - }; - const processor = new QueueProcessor({ - queue, - invocationTracker: tracker, - queueCustodyCoordinator, - messageStore: { markCanceled: mock.fn(async () => null) }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - }); - const a = enqueue(queue, 'old-a'); - const b = enqueue(queue, 'old-b'); - durableEntries.set(a.id, structuredClone(a)); - durableEntries.set(b.id, structuredClone(b)); - - const reservation = queue.reserveExactUserBatch('t1', 'user-a', [a.id, b.id]); - assert.equal(reservation.outcome, 'reserved'); - assert.equal(queue.beginExactSteerPreemption('t1', 'user-a', reservation.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'user-a', reservation.reservationId), true); - assert.ok(queue.claimExactSteerReservation('t1', 'user-a', reservation.primaryEntryId, reservation.reservationId)); - processor.reserveProcessingSlot(JSON.stringify(['t1', 'opus']), a.id, 'user-a'); - - const sealGuard = tracker.guardSessionSeal('t1', 'opus'); - assert.equal(sealGuard.acquired, true); - let settled = false; - const replacementPromise = processor - .acquireExternalExecution('t1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-replacement-after-seal', - }) - .then((replacement) => { - settled = true; - return replacement; - }); - await new Promise((resolve) => setImmediate(resolve)); - - const settledBeforeRelease = settled; - const withdrawalStartedBeforeRelease = withdrawalStarted; - const oldABeforeRelease = queue.getEntrySnapshot('t1', 'user-a', a.id); - const oldBBeforeRelease = queue.getEntrySnapshot('t1', 'user-a', b.id); - const durableBeforeRelease = [durableEntries.has(a.id), durableEntries.has(b.id)]; - sealGuard.release(); - await withdrawalGate.entered; - const racingSealGuard = tracker.guardSessionSeal('t1', 'opus'); - racingSealGuard.release(); - withdrawalGate.release(); - const replacement = await replacementPromise; - - assert.equal(settledBeforeRelease, false, 'replacement must wait before durable retirement begins'); - assert.equal(withdrawalStartedBeforeRelease, false, 'durable retirement must not start behind a manual seal'); - assert.equal(oldABeforeRelease?.status, 'processing'); - assert.equal(oldBBeforeRelease?.status, 'processing'); - assert.deepEqual(durableBeforeRelease, [true, true]); - assert.equal(racingSealGuard.acquired, false, 'execution admission must exclude a second seal during retirement'); - assert.ok(replacement); - assert.equal(queue.getEntrySnapshot('t1', 'user-a', a.id), null); - assert.equal(queue.getEntrySnapshot('t1', 'user-a', b.id), null); - await waitFor(() => !durableEntries.has(a.id) && !durableEntries.has(b.id)); - assert.equal(tracker.getExecutionId('t1', 'opus'), 'inv-replacement-after-seal'); - }); - - it('persists the occupied slot identity instead of the carrier target order', async () => { - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const persisted = []; - const queueCustodyCoordinator = { - persistEntry: mock.fn(async (entry) => persisted.push(structuredClone(entry))), - withdrawEntry: mock.fn(async () => true), - }; - const processor = new QueueProcessor({ - queue, - invocationTracker: tracker, - queueCustodyCoordinator, - messageStore: { markCanceled: mock.fn(async () => null) }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - }); - const enqueued = queue.enqueue({ - threadId: 't1', - userId: 'user-a', - content: 'multi-target', - source: 'user', - ownerAuthProvenance: 'strict', - targetCats: ['codex', 'opus'], - intent: 'execute', - }); - assert.equal(enqueued.outcome, 'enqueued'); - assert.equal(queue.markProcessingById('t1', enqueued.entry.id), true); - processor.reserveProcessingSlot(JSON.stringify(['t1', 'opus']), enqueued.entry.id, 'user-a'); - - const replacement = await processor.acquireExternalExecution('t1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-replacement', - }); - - assert.ok(replacement); - assert.equal(persisted[0].prestartRetirement.targetCatId, 'opus'); - }); - - it('external replacement fails closed before side effects when an exact batch is inconsistent', async () => { - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const ownershipValidated = mock.fn(); - const processor = new QueueProcessor({ - queue, - invocationTracker: tracker, - messageStore: { markCanceled: mock.fn(async () => null) }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - }); - const a = enqueue(queue, 'old-a'); - const b = enqueue(queue, 'old-b'); - const reservation = queue.reserveExactUserBatch('t1', 'user-a', [a.id, b.id]); - assert.equal(reservation.outcome, 'reserved'); - assert.equal(queue.beginExactSteerPreemption('t1', 'user-a', reservation.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'user-a', reservation.reservationId), true); - assert.ok(queue.claimExactSteerReservation('t1', 'user-a', reservation.primaryEntryId, reservation.reservationId)); - processor.reserveProcessingSlot(JSON.stringify(['t1', 'opus']), a.id, 'user-a'); - assert.ok(queue.removeProcessedAcrossUsers('t1', b.id), 'fixture creates the partial-batch corruption'); - - const replacement = await processor.acquireExternalExecution('t1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-replacement', - onOwnershipValidated: ownershipValidated, - }); - - assert.equal(replacement, null); - assert.equal(ownershipValidated.mock.callCount(), 0, 'no cancellation side effect may cross the failed preflight'); - assert.equal(queue.getEntrySnapshot('t1', 'user-a', a.id)?.status, 'processing'); - assert.equal(tracker.has('t1', 'opus'), false); - }); -}); diff --git a/packages/api/test/queue-steer-prestart-restart-fixture.js b/packages/api/test/queue-steer-prestart-restart-fixture.js index 6ae35fa14d..2ed4b3b25f 100644 --- a/packages/api/test/queue-steer-prestart-restart-fixture.js +++ b/packages/api/test/queue-steer-prestart-restart-fixture.js @@ -9,6 +9,7 @@ export async function restartHarness(h) { const queue = new InvocationQueue(); const tracker = new InvocationTracker(); const routedContents = []; + const log = { info: mock.fn(), warn: mock.fn(), error: mock.fn() }; const invocationRecordStore = { scanByStatus: mock.fn(async () => []), get: mock.fn(async () => null), @@ -19,17 +20,18 @@ export async function restartHarness(h) { queue, invocationTracker: tracker, invocationRecordStore, - queueCustodyCoordinator: h.queueCustodyCoordinator, messageStore: h.messageStore, socketManager: h.socketManager, router: { + resolveExplicitTargets: mock.fn(async (requestedCatIds) => [...requestedCatIds]), + resolveConversationTargetsAtAdmission: mock.fn(async (requestedCatIds) => [...requestedCatIds]), routeExecution: mock.fn(async function* (_userId, content, _threadId, _messageId, targetCats) { routedContents.push(content); yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; }), ackCollectedCursors: mock.fn(async () => {}), }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, + log, }); const reconciler = new StartupReconciler({ invocationRecordStore, @@ -41,5 +43,5 @@ export async function restartHarness(h) { resumeQueue: (threadId, userId) => processor.processNext(threadId, userId), }); await reconciler.reconcileOrphans(); - return { queue, tracker, processor, reconciler, routedContents, invocationRecordStore }; + return { queue, tracker, processor, reconciler, routedContents, invocationRecordStore, log }; } diff --git a/packages/api/test/queue-steer-prestart-terminal-failure.test.js b/packages/api/test/queue-steer-prestart-terminal-failure.test.js deleted file mode 100644 index 7294de55ae..0000000000 --- a/packages/api/test/queue-steer-prestart-terminal-failure.test.js +++ /dev/null @@ -1,412 +0,0 @@ -import assert from 'node:assert/strict'; -import { afterEach, describe, it, mock } from 'node:test'; -import Fastify from 'fastify'; - -const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); -const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); -const { createInitialQueuedMessageCustody } = await import( - '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js' -); -const { queueRoutes } = await import('../dist/routes/queue.js'); -const { restartHarness } = await import('./queue-steer-prestart-restart-fixture.js'); - -function asyncGate() { - let enter; - let release; - const entered = new Promise((resolve) => { - enter = resolve; - }); - const blocked = new Promise((resolve) => { - release = resolve; - }); - return { entered, blocked, enter, release }; -} - -function enqueue(queue, content, messageId) { - const result = queue.enqueue({ - threadId: 't1', - userId: 'user-a', - content, - source: 'user', - ownerAuthProvenance: 'strict', - targetCats: ['opus'], - intent: 'execute', - messageId, - }); - assert.equal(result.outcome, 'enqueued'); - return result.entry; -} - -async function waitFor(predicate, timeoutMs = 2000) { - const startedAt = Date.now(); - while (!predicate()) { - if (Date.now() - startedAt > timeoutMs) throw new Error('timed out waiting for queue transition'); - await new Promise((resolve) => setTimeout(resolve, 5)); - } -} - -async function createHarness() { - const queue = new InvocationQueue(); - const tracker = new InvocationTracker(); - const createGate = asyncGate(); - const durableEntries = new Map(); - const messages = new Map(); - const failedWithdrawals = new Set(); - const failedCancellations = new Set(); - const routedContents = []; - const log = { info: mock.fn(), warn: mock.fn(), error: mock.fn() }; - let createCount = 0; - - const messageStore = { - getByIdempotencyKey: mock.fn(async () => null), - getByThreadAfter: mock.fn(async () => []), - getByQueueExposure: mock.fn(async () => []), - getById: mock.fn(async (messageId) => messages.get(messageId) ?? null), - scanByDeliveryStatus: mock.fn(async (status) => - [...messages.values()].filter((message) => message.deliveryStatus === status).map((message) => message.id), - ), - initializeQueueCustody: mock.fn(async (messageId, custody) => { - const current = messages.get(messageId); - if (!current) return { kind: 'not_found' }; - if (current.deliveryStatus !== 'queued') return { kind: 'not_queued' }; - if (current.queueCustody) return { kind: 'existing', message: current }; - const next = { ...current, queueCustody: structuredClone(custody) }; - messages.set(messageId, next); - return { kind: 'initialized', message: next }; - }), - transitionQueueCustody: mock.fn(async (messageId, input) => { - const current = messages.get(messageId); - if (!current?.queueCustody) return { kind: 'not_found' }; - if (current.queueCustody.revision !== input.expectedRevision) { - return { kind: 'revision_mismatch', actualRevision: current.queueCustody.revision }; - } - const next = { - ...current, - queueCustody: structuredClone(input.next), - ...(input.deliveredAt === undefined ? {} : { deliveryStatus: 'delivered', deliveredAt: input.deliveredAt }), - }; - messages.set(messageId, next); - return { kind: 'updated', message: next, deliveryTransitioned: input.deliveredAt !== undefined }; - }), - markCanceled: mock.fn(async (messageId) => { - if (failedCancellations.has(messageId)) throw new Error(`cancel failed: ${messageId}`); - const current = messages.get(messageId); - if (!current) return null; - const deliveryTransitioned = current.deliveryStatus === 'queued'; - if (current.queueCustody) durableEntries.delete(current.queueCustody.entryId); - const { queueCustody: _queueCustody, ...stableCurrent } = current; - const next = { ...stableCurrent, deliveryStatus: 'canceled', deliveryTransitioned }; - messages.set(messageId, next); - return next; - }), - markDelivered: mock.fn(async (messageId) => { - const current = messages.get(messageId); - if (!current) return null; - const next = { ...current, deliveryStatus: 'delivered', deliveryTransitioned: true }; - messages.set(messageId, next); - return next; - }), - }; - const queueCustodyCoordinator = { - persistEntry: mock.fn(async (entry) => { - durableEntries.set(entry.id, structuredClone(entry)); - if (!entry.messageId) return []; - const current = messages.get(entry.messageId); - if (!current) return []; - const base = current.queueCustody ?? createInitialQueuedMessageCustody(entry); - const nextCustody = { - ...base, - revision: current.queueCustody ? base.revision + 1 : base.revision, - status: entry.status, - ...(entry.processingStartedAt === undefined ? {} : { processingStartedAt: entry.processingStartedAt }), - ...(entry.prestartRetirement ? { prestartRetirement: structuredClone(entry.prestartRetirement) } : {}), - updatedAt: Date.now(), - }; - messages.set(entry.messageId, { ...current, queueCustody: nextCustody }); - return [entry.messageId]; - }), - withdrawEntry: mock.fn(async (entry) => { - if (failedWithdrawals.has(entry.id)) throw new Error(`withdraw failed: ${entry.id}`); - return durableEntries.delete(entry.id); - }), - markPrimaryTrigger: mock.fn(async () => true), - markReminderMissed: mock.fn(async () => true), - }; - const invocationRecordStore = { - create: mock.fn(async () => { - createCount += 1; - if (createCount === 1) { - createGate.enter(); - await createGate.blocked; - } - return { outcome: 'created', invocationId: createCount === 1 ? 'inv-old-batch' : `inv-${createCount}` }; - }), - update: mock.fn(async () => ({})), - listRunningByThread: mock.fn(async () => []), - }; - const socketManager = { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }; - const processor = new QueueProcessor({ - queue, - invocationTracker: tracker, - invocationRecordStore, - queueCustodyCoordinator, - messageStore, - socketManager, - router: { - routeExecution: mock.fn(async function* (_userId, content, _threadId, _messageId, targetCats) { - routedContents.push(content); - yield { type: 'done', catId: targetCats[0], timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - log, - }); - - for (const [messageId, content] of [ - ['msg-a', 'old-a'], - ['msg-b', 'old-b'], - ['msg-c', 'new-c'], - ]) { - messages.set(messageId, { - id: messageId, - threadId: 't1', - userId: 'user-a', - catId: null, - content, - mentions: ['opus'], - timestamp: Date.now(), - deliveryStatus: 'queued', - }); - } - const a = enqueue(queue, 'old-a', 'msg-a'); - const b = enqueue(queue, 'old-b', 'msg-b'); - const c = enqueue(queue, 'new-c', 'msg-c'); - for (const entry of [a, b, c]) await queueCustodyCoordinator.persistEntry(entry); - const reservation = queue.reserveExactUserBatch('t1', 'user-a', [a.id, b.id]); - assert.equal(reservation.outcome, 'reserved'); - assert.equal(queue.beginExactSteerPreemption('t1', 'user-a', reservation.reservationId), true); - assert.equal(queue.activateExactSteerReservation('t1', 'user-a', reservation.reservationId), true); - assert.equal( - ( - await processor.processExactSteerReservation( - 't1', - 'user-a', - reservation.primaryEntryId, - reservation.reservationId, - ) - ).started, - true, - ); - await createGate.entered; - - return { - queue, - tracker, - createGate, - durableEntries, - messages, - failedWithdrawals, - failedCancellations, - routedContents, - messageStore, - queueCustodyCoordinator, - invocationRecordStore, - socketManager, - processor, - a, - b, - c, - }; -} - -describe('pre-start exact-batch durable retirement failure', () => { - let app; - - afterEach(async () => { - if (app) await app.close(); - }); - - it('force-reset retires a recordless processing owner and fences its late cleanup from a replacement', async () => { - const h = await createHarness(); - app = Fastify(); - await app.register(queueRoutes, { - threadStore: { get: mock.fn(async () => ({ id: 't1', title: 'test', createdBy: 'user-a' })) }, - invocationQueue: h.queue, - queueProcessor: h.processor, - invocationTracker: h.tracker, - invocationRecordStore: h.invocationRecordStore, - queueCustodyCoordinator: h.queueCustodyCoordinator, - messageStore: h.messageStore, - socketManager: h.socketManager, - agentSessionMutex: { - forceReleaseByScope: mock.fn(() => ({ releasedHolders: 0, rejectedWaiters: 0, catIds: [] })), - }, - }); - await app.ready(); - - assert.equal(h.tracker.has('t1', 'opus'), false, 'the reproducer must remain before tracker admission'); - assert.equal(h.invocationRecordStore.listRunningByThread.mock.calls.length, 0); - assert.equal(h.queue.getEntrySnapshot('t1', 'user-a', h.a.id)?.status, 'processing'); - - const reset = await app.inject({ - method: 'POST', - url: '/api/threads/t1/force-reset', - headers: { 'x-cat-cafe-user': 'user-a' }, - }); - - assert.equal(reset.statusCode, 200, reset.body); - assert.equal(h.queue.getEntrySnapshot('t1', 'user-a', h.a.id), null, 'exact processing anchor must retire'); - assert.equal(h.queue.getEntrySnapshot('t1', 'user-a', h.b.id), null, 'the whole processing group must retire'); - assert.equal(h.durableEntries.has(h.a.id), false); - assert.equal(h.durableEntries.has(h.b.id), false); - assert.equal(h.messages.get('msg-a').deliveryStatus, 'canceled'); - assert.equal(h.messages.get('msg-b').deliveryStatus, 'canceled'); - assert.ok( - h.socketManager.emitToUser.mock.calls.some( - ({ arguments: [userId, event, payload] }) => - userId === 'user-a' && - event === 'queue_updated' && - payload.action === 'force_reset' && - payload.queue.length === 1, - ), - 'force-reset must publish the remaining queue after retiring the processing group', - ); - - const replacement = await h.processor.acquireExternalExecution('t1', ['opus'], 'user-a', { - mode: 'non_preemptive', - executionId: 'inv-replacement-after-reset', - }); - assert.ok(replacement, 'a replacement may acquire the now-terminal slot'); - assert.equal(h.tracker.has('t1', 'opus'), true); - - h.createGate.release(); - await waitFor(() => h.invocationRecordStore.update.mock.calls.length > 0); - - assert.deepEqual(h.routedContents, [], 'the late recordless coroutine must not start the canceled provider'); - assert.equal(h.tracker.has('t1', 'opus'), true, 'late cleanup must not release the replacement owner'); - assert.equal(replacement.signal.aborted, false, 'late cleanup must not cancel the replacement invocation'); - assert.deepEqual( - await h.processor.retireThreadPrestartProcessingGroups('t1', 'user-a'), - { outcome: 'none', retiredCatIds: [] }, - 'a live external owner without a Queue row is outside Queue-group retirement', - ); - h.tracker.completeAll('t1', ['opus'], replacement); - }); - - it('keeps an unresolved Steer batch recoverable and starts C only after a successful retry', async () => { - const h = await createHarness(); - h.failedCancellations.add('msg-b'); - app = Fastify(); - await app.register(queueRoutes, { - threadStore: { get: mock.fn(async () => ({ id: 't1', title: 'test', createdBy: 'user-a' })) }, - invocationQueue: h.queue, - queueProcessor: h.processor, - invocationTracker: h.tracker, - invocationRecordStore: h.invocationRecordStore, - queueCustodyCoordinator: h.queueCustodyCoordinator, - messageStore: h.messageStore, - socketManager: h.socketManager, - agentSessionMutex: { - forceReleaseByScope: mock.fn(() => ({ releasedHolders: 0, rejectedWaiters: 0, catIds: [] })), - }, - }); - await app.ready(); - - const first = await app.inject({ - method: 'POST', - url: `/api/threads/t1/queue/${h.c.id}/steer`, - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: {}, - }); - assert.equal(first.statusCode, 503); - assert.equal(first.json().code, 'PRESTART_TERMINALIZATION_FAILED'); - assert.equal(h.messages.get('msg-a').deliveryStatus, 'canceled', 'A must terminalize before B fails'); - assert.equal(h.durableEntries.has(h.a.id), false, 'A custody must already be withdrawn'); - assert.equal(h.queue.getEntrySnapshot('t1', 'user-a', h.b.id)?.status, 'processing'); - assert.equal(h.durableEntries.has(h.b.id), true, 'failed durable member must remain represented'); - assert.deepEqual(h.messages.get('msg-b').queueCustody.prestartRetirement.entryIds, [h.a.id, h.b.id]); - assert.deepEqual(h.routedContents, [], 'neither old nor new provider may start after refusal'); - - const restarted = await restartHarness(h); - assert.equal(restarted.queue.getEntrySnapshot('t1', 'user-a', h.b.id)?.status, 'processing'); - assert.equal(restarted.queue.getEntrySnapshot('t1', 'user-a', h.c.id)?.status, 'queued'); - assert.deepEqual(restarted.routedContents, [], 'restart must not dispatch the surviving subset or C'); - - h.failedCancellations.delete('msg-b'); - const restartedApp = Fastify(); - await restartedApp.register(queueRoutes, { - threadStore: { get: mock.fn(async () => ({ id: 't1', title: 'test', createdBy: 'user-a' })) }, - invocationQueue: restarted.queue, - queueProcessor: restarted.processor, - invocationTracker: restarted.tracker, - invocationRecordStore: restarted.invocationRecordStore, - queueCustodyCoordinator: h.queueCustodyCoordinator, - messageStore: h.messageStore, - socketManager: h.socketManager, - agentSessionMutex: { - forceReleaseByScope: mock.fn(() => ({ releasedHolders: 0, rejectedWaiters: 0, catIds: [] })), - }, - }); - await restartedApp.ready(); - const retry = await restartedApp.inject({ - method: 'POST', - url: `/api/threads/t1/queue/${h.c.id}/steer`, - headers: { 'x-cat-cafe-user': 'user-a', 'content-type': 'application/json' }, - payload: {}, - }); - assert.equal(retry.statusCode, 200); - await waitFor(() => restarted.routedContents.includes('new-c')); - h.createGate.release(); - await waitFor(() => h.invocationRecordStore.update.mock.calls.length > 0); - assert.deepEqual(restarted.routedContents, ['new-c']); - assert.equal(restarted.queue.getEntrySnapshot('t1', 'user-a', h.a.id), null); - assert.equal(restarted.queue.getEntrySnapshot('t1', 'user-a', h.b.id), null); - assert.equal(h.durableEntries.has(h.a.id), false); - assert.equal(h.durableEntries.has(h.b.id), false); - - const secondRestart = await restartHarness(h); - assert.equal(secondRestart.queue.getEntrySnapshot('t1', 'user-a', h.a.id), null); - assert.equal(secondRestart.queue.getEntrySnapshot('t1', 'user-a', h.b.id), null); - await restartedApp.close(); - }); - - it('refuses external replacement until the same durable retirement can finish', async () => { - const h = await createHarness(); - h.failedCancellations.add('msg-b'); - - const first = await h.processor.acquireExternalExecution('t1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-replacement-1', - }); - assert.equal(first, null); - assert.equal(h.tracker.has('t1', 'opus'), false, 'replacement provider owner must not start'); - assert.equal(h.messages.get('msg-a').deliveryStatus, 'canceled', 'A must terminalize before B fails'); - assert.equal(h.durableEntries.has(h.a.id), false, 'A custody must already be withdrawn'); - assert.equal(h.queue.getEntrySnapshot('t1', 'user-a', h.b.id)?.status, 'processing'); - assert.equal(h.durableEntries.has(h.b.id), true); - - const restarted = await restartHarness(h); - assert.equal(restarted.queue.getEntrySnapshot('t1', 'user-a', h.b.id)?.status, 'processing'); - assert.equal(restarted.tracker.has('t1', 'opus'), false); - assert.deepEqual(restarted.routedContents, []); - - h.failedCancellations.delete('msg-b'); - const retry = await restarted.processor.acquireExternalExecution('t1', ['opus'], 'user-a', { - mode: 'replacement', - executionId: 'inv-replacement-2', - }); - assert.ok(retry); - assert.equal(restarted.tracker.has('t1', 'opus'), true); - assert.equal(restarted.queue.getEntrySnapshot('t1', 'user-a', h.a.id), null); - assert.equal(restarted.queue.getEntrySnapshot('t1', 'user-a', h.b.id), null); - assert.equal(h.durableEntries.has(h.a.id), false); - assert.equal(h.durableEntries.has(h.b.id), false); - assert.deepEqual(h.routedContents, [], 'the superseded old provider never runs'); - h.createGate.release(); - }); -}); diff --git a/packages/api/test/read-latest-endpoint.test.js b/packages/api/test/read-latest-endpoint.test.js index c7811b9fcb..0f4b0d896a 100644 --- a/packages/api/test/read-latest-endpoint.test.js +++ b/packages/api/test/read-latest-endpoint.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('POST /api/threads/:id/read/latest', () => { let app; @@ -83,22 +84,26 @@ describe('POST /api/threads/:id/read/latest', () => { it('acks to the latest message in thread', async () => { const thread = threadStore.create('alice', 'Thread with messages'); - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'first', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); - const msg2 = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'second (latest)', - mentions: [], - timestamp: 2000, - threadId: thread.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'first', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); + const msg2 = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'second (latest)', + mentions: [], + timestamp: 2000, + threadId: thread.id, + }), + ); const res = await app.inject({ method: 'POST', @@ -114,60 +119,34 @@ describe('POST /api/threads/:id/read/latest', () => { it("does not advance a foreign viewer through another owner's terminal managed hold", async () => { const thread = threadStore.create('system', 'Shared managed-hold thread'); - const anchor = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'alice-visible anchor', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); - const custody = { - version: 1, - entryId: 'entry-foreign-managed-hold', - revision: 1, - ownerUserId: 'bob', - intent: 'managed command wake', - status: 'queued', - allTargetCats: ['opus5'], - pendingTargetCats: ['opus5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: 2000, - updatedAt: 2000, - }; - const terminal = messageStore.append({ - userId: 'scheduler', - catId: null, - content: 'bob command result', - mentions: [], - timestamp: 2000, - threadId: thread.id, - deliveryStatus: 'queued', - queueCustody: custody, - source: { - connector: 'hold-ball', - label: '持球结果', - icon: '🏓', - meta: { taskId: 'task-foreign', threadId: thread.id, catId: 'opus5', wakeWhen: true }, - }, - }); - messageStore.transitionQueueCustody(terminal.id, { - expectedRevision: 1, - next: { - ...custody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - failedByCatIds: ['opus5'], - updatedAt: 2100, - }, - deliveredAt: 2100, - }); + const anchor = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'alice-visible anchor', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); + const terminal = messageStore.append( + canonicalTestMessageInput({ + userId: 'bob', + catId: null, + from: { kind: 'system', service: 'hold-ball' }, + content: 'bob command result', + mentions: [], + timestamp: 2000, + threadId: thread.id, + source: { + connector: 'hold-ball', + label: '持球结果', + icon: '🏓', + meta: { taskId: 'task-foreign', threadId: thread.id, catId: 'opus5', wakeWhen: true }, + }, + }), + ); + await messageStore.markDelivered(terminal.id, 2100); const res = await app.inject({ method: 'POST', @@ -181,15 +160,17 @@ describe('POST /api/threads/:id/read/latest', () => { it('acks a queued cat-authored message already published to the timeline', async () => { const thread = threadStore.create('alice', 'Thread with source-cat seed'); - const seed = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - content: 'published source-cat seed', - mentions: ['opus'], - timestamp: 1000, - threadId: thread.id, - deliveryStatus: 'queued', - }); + const seed = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + content: 'published source-cat seed', + mentions: ['opus'], + timestamp: 1000, + threadId: thread.id, + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'POST', @@ -210,23 +191,27 @@ describe('POST /api/threads/:id/read/latest', () => { // getLatestVisibleCursor must return Q (later visibilitySeq), not A. it('mixed thread: queued cat speech Q after ordinary A — acks Q as latest', async () => { const thread = threadStore.create('alice', 'Mixed: ordinary + queued'); - const a = messageStore.append({ - userId: 'alice', - catId: null, - content: 'ordinary message A', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); - const q = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - content: 'queued cat speech Q', - mentions: ['opus'], - timestamp: 2000, - threadId: thread.id, - deliveryStatus: 'queued', - }); + const a = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'ordinary message A', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); + const q = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + content: 'queued cat speech Q', + mentions: ['opus'], + timestamp: 2000, + threadId: thread.id, + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'POST', @@ -244,25 +229,29 @@ describe('POST /api/threads/:id/read/latest', () => { it('does not durably ack a mutable stream until its final delivery transition', async () => { const thread = threadStore.create('alice', 'Mutable stream read boundary'); - const earlier = messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'earlier durable speech', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); - const stream = messageStore.append({ - userId: 'alice', - catId: 'codex-sol', - content: 'partial stream', - mentions: [], - timestamp: 2000, - threadId: thread.id, - deliveryStatus: 'queued', - origin: 'stream', - extra: { stream: { invocationId: 'inv-d4', turnInvocationId: 'turn-d4' } }, - }); + const earlier = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'earlier durable speech', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); + const stream = messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'codex-sol', + content: 'partial stream', + mentions: [], + timestamp: 2000, + threadId: thread.id, + deliveryStatus: 'queued', + origin: 'stream', + extra: { stream: { invocationId: 'inv-d4', turnInvocationId: 'turn-d4' } }, + }), + ); const partialAck = await app.inject({ method: 'POST', @@ -291,14 +280,16 @@ describe('POST /api/threads/:id/read/latest', () => { it('is idempotent — second call returns advanced=false', async () => { const thread = threadStore.create('alice', 'Thread'); - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); // First call const res1 = await app.inject({ @@ -323,14 +314,16 @@ describe('POST /api/threads/:id/read/latest', () => { // The frontend must NOT clear unread suppression in this case. it('returns caughtUp=false when stored cursor does not match latest', async () => { const thread = threadStore.create('alice', 'Stale cursor thread'); - messageStore.append({ - userId: 'alice', - catId: 'opus', - content: 'visible message', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: 'opus', + content: 'visible message', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); // Simulate a stale cursor that's lex-greater than latest (CAS rejects, // stored != latest → caughtUp=false). Using a v2 with seq=9999... which diff --git a/packages/api/test/reconcileZombies.test.js b/packages/api/test/reconcileZombies.test.js index 0f4726eec0..2eb06d5fa1 100644 --- a/packages/api/test/reconcileZombies.test.js +++ b/packages/api/test/reconcileZombies.test.js @@ -10,6 +10,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { reconcileZombies } = await import('../dist/domains/cats/services/agents/invocation/reconcileZombies.js'); const { InvocationRecordStore } = await import('../dist/domains/cats/services/stores/ports/InvocationRecordStore.js'); @@ -511,30 +512,36 @@ describe('F194 reconcileZombies — cleanup pathway', () => { store.update(parent.invocationId, { status: 'running', userMessageId: 'msg-parent' }); // 2) Its queue entry is claimed -> `processing`. This is the entry that goes stale. - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: '@codex go', - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - messageId: 'msg-parent', - }); - const claimed = queue.markProcessing('t1', 'u1'); - assert.equal(claimed?.messageId, 'msg-parent', 'precondition: parent entry is processing'); + const parentAdmission = queue.enqueueDurableNow( + canonicalTestQueueInput({ + kind: 'message_wake', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: '@codex go', + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + messageId: 'msg-parent', + }), + ); + const claimed = await queue.markProcessingByIdDurable('t1', parentAdmission.entry.id, 'opus'); + assert.equal(claimed?.payload.messageId, 'msg-parent', 'precondition: parent entry is claimed'); // 3) A later USER `@codex` message queues BEHIND the processing entry. - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't1', - userId: 'u1', - content: '@codex please', - source: 'user', - targetCats: ['codex'], - intent: 'execute', - messageId: 'msg-user', - }); + queue.enqueueDurableNow( + canonicalTestQueueInput({ + kind: 'conversation_input', + ownerAuthProvenance: 'unknown', + threadId: 't1', + userId: 'u1', + content: '@codex please', + source: 'user', + targetCats: ['codex'], + intent: 'execute', + messageId: 'msg-user', + }), + ); // 4) Parent is judged a zombie and swept. const terminalEvents = []; @@ -554,7 +561,7 @@ describe('F194 reconcileZombies — cleanup pathway', () => { // #972: the stale `processing` entry must be converged, not left blocking the head. const remaining = queue.list('t1', 'u1'); assert.equal( - remaining.some((e) => e.messageId === 'msg-parent'), + remaining.some((e) => e.payload.messageId === 'msg-parent'), false, '#972: stale processing entry must be removed when its invocation is zombie-reaped', ); @@ -569,7 +576,7 @@ describe('F194 reconcileZombies — cleanup pathway', () => { // The actual user-facing symptom: the later user @codex work is now promotable. assert.equal( - queue.peekNextQueued('t1', 'u1')?.messageId, + queue.peekNextQueued('t1', 'u1')?.payload.messageId, 'msg-user', '#972: later user @codex must unblock after the dead parent is swept', ); @@ -587,26 +594,29 @@ describe('F194 reconcileZombies — cleanup pathway', () => { actionLeaseCarrier: { kind: 'none' }, }); store.update(parent.invocationId, { status: 'running', userMessageId: 'msg-parent' }); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't-race', - userId: 'u1', - content: '@codex go', - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - messageId: 'msg-parent', - }); - const claimed = queue.markProcessing('t-race', 'u1'); + const parentAdmission = queue.enqueueDurableNow( + canonicalTestQueueInput({ + kind: 'message_wake', + ownerAuthProvenance: 'unknown', + threadId: 't-race', + userId: 'u1', + content: '@codex go', + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + messageId: 'msg-parent', + }), + ); + const claimed = await queue.markProcessingByIdDurable('t-race', parentAdmission.entry.id, 'opus'); assert.ok(claimed); const queueEvents = []; let terminalRecoveryCalls = 0; const racingQueue = { list: (threadId, userId) => queue.list(threadId, userId), - removeProcessed: (threadId, userId, entryId) => { - queue.removeProcessed(threadId, userId, entryId); // replacement path wins the race - return queue.removeProcessed(threadId, userId, entryId); // sweep exact-id retry loses safely + removeProcessedAcrossUsersDurable: async (threadId, entryId) => { + await queue.removeProcessedAcrossUsersDurable(threadId, entryId); // replacement path wins the race + return queue.removeProcessedAcrossUsersDurable(threadId, entryId); // sweep exact-id retry loses safely }, }; @@ -638,17 +648,20 @@ describe('F194 reconcileZombies — cleanup pathway', () => { }); store.update(parent.invocationId, { status: 'running', userMessageId: 'msg-parent' }); store.update(parent.invocationId, { status: 'failed', error: 'concurrent-zombie-detected' }); - queue.enqueue({ - ownerAuthProvenance: 'unknown', - threadId: 't-terminal', - userId: 'u1', - content: '@codex go', - source: 'agent', - targetCats: ['opus'], - intent: 'execute', - messageId: 'msg-parent', - }); - const claimed = queue.markProcessing('t-terminal', 'u1'); + const parentAdmission = queue.enqueueDurableNow( + canonicalTestQueueInput({ + kind: 'message_wake', + ownerAuthProvenance: 'unknown', + threadId: 't-terminal', + userId: 'u1', + content: '@codex go', + source: 'agent', + targetCats: ['opus'], + intent: 'execute', + messageId: 'msg-parent', + }), + ); + const claimed = await queue.markProcessingByIdDurable('t-terminal', parentAdmission.entry.id, 'opus'); assert.ok(claimed); const queueEvents = []; diff --git a/packages/api/test/redis-message-delivery-atomicity.test.js b/packages/api/test/redis-message-delivery-atomicity.test.js index 3ee47d9c8e..13ee6e198f 100644 --- a/packages/api/test/redis-message-delivery-atomicity.test.js +++ b/packages/api/test/redis-message-delivery-atomicity.test.js @@ -15,6 +15,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -70,15 +71,17 @@ describe('delivery-order transition atomicity (PR #1193)', { skip: redisIsolatio // ── Helper: create a queued message for testing ── const createQueued = (userId, threadId, ts) => - store.append({ - userId, - catId: null, - content: `queued-msg-${ts}`, - mentions: [], - timestamp: ts, - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: `queued-msg-${ts}`, + mentions: [], + timestamp: ts, + threadId, + deliveryStatus: 'queued', + }), + ); // ── Helper: assert hash/zset consistency invariant ── const assertConsistency = async (msgId, label) => { diff --git a/packages/api/test/redis-message-delivery-contracts.test.js b/packages/api/test/redis-message-delivery-contracts.test.js index 1cdc095097..31b67a939e 100644 --- a/packages/api/test/redis-message-delivery-contracts.test.js +++ b/packages/api/test/redis-message-delivery-contracts.test.js @@ -9,13 +9,13 @@ * reads + writes inside Redis's single-threaded Lua executor. * * The regression suite was observed RED against pre-intake Clowder AI main. - * Supplementary coverage: CAS idempotency, Lua receipts, TTL, custody publication, + * Supplementary coverage: CAS idempotency, Lua receipts, TTL, delivery publication, * and legacy/repaired reassignment ordering. */ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; -import { makeQueuedMessageCustody as makeCustody } from './helpers/queued-message-custody.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -71,15 +71,17 @@ describe('Redis delivery transition contracts (PR #1193)', { skip: redisIsolatio // ── Helper: create a queued message for testing ── const createQueued = (userId, threadId, ts) => - store.append({ - userId, - catId: null, - content: `queued-msg-${ts}`, - mentions: [], - timestamp: ts, - threadId, - deliveryStatus: 'queued', - }); + store.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: `queued-msg-${ts}`, + mentions: [], + timestamp: ts, + threadId, + deliveryStatus: 'queued', + }), + ); // ── Helper: assert hash/zset consistency invariant ── const assertConsistency = async (msgId, label) => { @@ -106,14 +108,16 @@ describe('Redis delivery transition contracts (PR #1193)', { skip: redisIsolatio const base = Date.now(); const threadId = 'thread-dlv-imm-9'; // Create a message WITHOUT deliveryStatus (= immediate/legacy) - const msg = await store.append({ - userId: 'userA', - catId: null, - content: 'immediate msg', - mentions: [], - timestamp: base, - threadId, - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: 'userA', + catId: null, + content: 'immediate msg', + mentions: [], + timestamp: base, + threadId, + }), + ); const result = await store.markCanceled(msg.id); assert.equal(result?.deliveryTransitioned, false, 'CAS no-op must report applied=false'); @@ -200,15 +204,17 @@ describe('Redis delivery transition contracts (PR #1193)', { skip: redisIsolatio // Create a store WITH ttlSeconds to exercise the EXPIRE branch const ttlStore = new RedisMessageStore(redis, { ttlSeconds: 60 }); - const msg = await ttlStore.append({ - userId: 'userA', - catId: null, - content: 'ttl-test-msg', - mentions: [], - timestamp: base, - threadId, - deliveryStatus: 'queued', - }); + const msg = await ttlStore.append( + canonicalTestMessageInput({ + userId: 'userA', + catId: null, + content: 'ttl-test-msg', + mentions: [], + timestamp: base, + threadId, + deliveryStatus: 'queued', + }), + ); await ttlStore.markDelivered(msg.id, base + 100); // Reassign to userF — EXPIRE should fire inside Lua @@ -225,49 +231,44 @@ describe('Redis delivery transition contracts (PR #1193)', { skip: redisIsolatio assert.equal(oldScore, null, 'old user must not have entry'); }); - // 14. Terminal custody is completed execution history, not an active delivery fence. - it('markDelivered publishes a queued message whose custody is already terminal', async () => { + // 14. A queued row without atomic conversation admission is first published at delivery. + it('markDelivered publishes unadmitted queued work at its delivery coordinate', async () => { const base = Date.now(); - const terminalCustody = makeCustody({ - status: 'terminal', - pendingTargetCats: [], - failedByCatIds: ['opus', 'codex'], - updatedAt: base, - }); - const msg = await store.append({ - userId: 'userA', - catId: null, - content: 'terminal custody is publishable', - mentions: ['opus', 'codex'], - timestamp: base, - threadId: 'thread-dlv-terminal-custody-14', - deliveryStatus: 'queued', - queueCustody: terminalCustody, - }); + const msg = await store.append( + canonicalTestMessageInput({ + userId: 'userA', + catId: null, + content: 'private queued work becomes publishable', + mentions: ['opus', 'codex'], + timestamp: base, + threadId: 'thread-dlv-terminal-custody-14', + deliveryStatus: 'queued', + }), + ); const result = await store.markDelivered(msg.id, base + 100); - assert.equal(result?.deliveryTransitioned, true, 'terminal custody must not block the delivery transition'); + assert.equal(result?.deliveryTransitioned, true, 'Queue state must not block the delivery transition'); assert.equal(result?.deliveryStatus, 'delivered'); assert.equal(result?.deliveredAt, base + 100); - assert.equal(result?.timelineOrderAt, base, 'custody-backed receipt must retain its authored timeline position'); - assert.deepEqual(result?.queueCustody, terminalCustody, 'terminal custody history must remain attached'); + assert.equal(result?.timelineOrderAt, base + 100, 'private queued work enters the timeline at delivery'); + assert.equal(result?.queueCustody, undefined, 'History must not mirror Queue ledger state'); assert.equal( await redis.zscore(MessageKeys.thread(msg.threadId), msg.id), - String(base), - 'thread receipt order must remain at author time', + String(base + 100), + 'thread receipt order must use delivery time', ); assert.equal( await redis.zscore(MessageKeys.TIMELINE, msg.id), - String(base), - 'global receipt order must remain at author time', + String(base + 100), + 'global receipt order must use delivery time', ); assert.equal( await redis.zscore(MessageKeys.user(msg.userId), msg.id), - String(base), - 'owner receipt order must remain at author time', + String(base + 100), + 'owner receipt order must use delivery time', ); - await assertConsistency(msg.id, 'terminal-custody-delivery'); + await assertConsistency(msg.id, 'queue-independent-delivery'); }); // 15. Legacy/repaired rows may have a canonical zset score without a hash projection. diff --git a/packages/api/test/redis-message-parsers-z9-turn-roundtrip.test.js b/packages/api/test/redis-message-parsers-z9-turn-roundtrip.test.js index aacd1cec90..621703a667 100644 --- a/packages/api/test/redis-message-parsers-z9-turn-roundtrip.test.js +++ b/packages/api/test/redis-message-parsers-z9-turn-roundtrip.test.js @@ -399,3 +399,33 @@ describe('F194 Phase Z9 hotfix — safeParseExtra preserves turnInvocationId', ( assert.equal(parsed?.memoryCue, undefined); }); }); + +describe('routing warning persistence', () => { + it('round-trips typed inline routing warnings', async () => { + const { serializeExtra, safeParseExtra } = await import( + '../dist/domains/cats/services/stores/redis/redis-message-parsers.js' + ); + const input = { + routingWarnings: [ + { kind: 'cat_not_found', mention: '@missing', alternatives: [] }, + { kind: 'target_not_in_thread', catId: 'cat-away', threadId: 'thread-1' }, + ], + }; + + assert.deepEqual(safeParseExtra(serializeExtra(input))?.routingWarnings, input.routingWarnings); + }); + + it('drops the entire routing warning carrier when one entry is malformed', async () => { + const { safeParseExtra } = await import('../dist/domains/cats/services/stores/redis/redis-message-parsers.js'); + const parsed = safeParseExtra( + JSON.stringify({ + routingWarnings: [ + { kind: 'cat_not_found', mention: '@missing', alternatives: [] }, + { kind: 'target_not_in_thread', catId: '', threadId: 'thread-1' }, + ], + }), + ); + + assert.equal(parsed?.routingWarnings, undefined); + }); +}); diff --git a/packages/api/test/redis-message-store.test.js b/packages/api/test/redis-message-store.test.js index afc9bbd054..ec5a0932fe 100644 --- a/packages/api/test/redis-message-store.test.js +++ b/packages/api/test/redis-message-store.test.js @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -13,6 +14,14 @@ import { const REDIS_URL = process.env.REDIS_URL; +function withFixtureProvenance(input) { + return canonicalTestMessageInput(input); +} + +function appendFixture(store, input) { + return store.append(withFixtureProvenance(input)); +} + function luaHash(fields) { return Object.entries(fields).flat(); } @@ -50,7 +59,7 @@ describe('RedisMessageStore message JSON Unicode boundary', () => { extra: { targetCats: [`codex${loneLowSurrogate}`] }, }; - const stored = await store.append(input); + const stored = await appendFixture(store, input); assert.equal(persistedFields.content, 'redis�message 😀'); assert.deepEqual(JSON.parse(persistedFields.extra).targetCats, ['codex�']); @@ -59,8 +68,43 @@ describe('RedisMessageStore message JSON Unicode boundary', () => { }); }); +describe('RedisMessageStore batch hydration sender identity', () => { + it('preserves MessageFrom through getByThread batch hydration', async () => { + const { RedisMessageStore } = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); + const hash = { + id: 'message-from-batch', + threadId: 'thread-from-batch', + userId: 'owner-1', + from: JSON.stringify({ kind: 'agent', catId: 'opus' }), + catId: 'opus', + content: 'sender survives batch hydration', + mentions: '[]', + timestamp: '1000', + deliveryStatus: 'delivered', + deliveredAt: '1001', + timelineOrderAt: '1001', + }; + const redis = { + options: {}, + zrevrange: async () => ['message-from-batch'], + multi: () => ({ + hgetall() { + return this; + }, + exec: async () => [[null, hash]], + }), + }; + const store = new RedisMessageStore(redis); + + const messages = await store.getByThread('thread-from-batch', 10, 'owner-1'); + + assert.equal(messages.length, 1); + assert.deepEqual(messages[0].from, { kind: 'agent', catId: 'opus' }); + }); +}); + describe('RedisMessageStore paw-feel source projections', () => { - it('reads bounded source-only HMGET batches without hydrating tool event payloads', async () => { + it('reads bounded source-only HMGET batches without hydrating rich history payloads', async () => { const { RedisMessageStore } = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); const ids = Array.from({ length: 101 }, (_, index) => `source-${String(index).padStart(3, '0')}`); const hashes = new Map( @@ -115,7 +159,7 @@ describe('RedisMessageStore paw-feel source projections', () => { const reads = await store.getPawFeelSourceProjections(ids); const first = reads.get(ids[0]); - assert.equal(batches.length, 2, '101 source IDs must be split into bounded batches'); + assert.equal(batches.length, 2); assert.ok(batches.every((batch) => batch.length <= 100)); assert.ok( batches.flat().every(({ fields }) => !fields.includes('toolEvents') && !fields.includes('contentBlocks')), @@ -138,7 +182,7 @@ describe('RedisMessageStore paw-feel source projections', () => { }); }); - it('keeps every requested source visible as a failed read when one HMGET batch rejects', async () => { + it('returns one failed outcome per source when a Redis batch rejects', async () => { const { RedisMessageStore } = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); let hgetallCalls = 0; const redis = { @@ -147,16 +191,14 @@ describe('RedisMessageStore paw-feel source projections', () => { hgetallCalls += 1; throw new Error('source projection must not fall back to HGETALL'); }, - pipeline() { - return { - hmget() { - return this; - }, - async exec() { - throw new Error('redis unavailable'); - }, - }; - }, + pipeline: () => ({ + hmget() { + return this; + }, + async exec() { + throw new Error('redis unavailable'); + }, + }), }; const store = new RedisMessageStore(redis); @@ -172,33 +214,39 @@ describe('RedisMessageStore paw-feel source projections', () => { ); }); - it('isolates malformed queue custody to one source without poisoning healthy rows or later batches', async () => { + it('isolates malformed narrow provenance to one source without poisoning later rows', async () => { const { RedisMessageStore } = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); - const ids = [ - 'source-good', - 'source-bad', - ...Array.from({ length: 98 }, (_, index) => `missing-${index}`), - 'source-later', - ]; - const sourceHash = (id) => ({ - id, - threadId: 'thread-source', - userId: 'user-source', - catId: 'codex-sol', - content: '[爪感差: source+projection]', - timestamp: '1700000000000', - }); const hashes = new Map([ - ['source-good', sourceHash('source-good')], - ['source-bad', { ...sourceHash('source-bad'), queueCustody: '{' }], - ['source-later', sourceHash('source-later')], + [ + 'source-good', + { id: 'source-good', threadId: 'thread-source', userId: 'u', catId: 'codex', content: 'ok', timestamp: '1' }, + ], + [ + 'source-bad', + { + id: 'source-bad', + threadId: 'thread-source', + userId: 'u', + catId: 'codex', + content: 'bad', + timestamp: '2', + extra: '{', + }, + ], + [ + 'source-later', + { + id: 'source-later', + threadId: 'thread-source', + userId: 'u', + catId: 'codex', + content: 'later', + timestamp: '3', + }, + ], ]); - const batches = []; const redis = { options: {}, - hgetall: async () => { - throw new Error('source projection must not fall back to HGETALL'); - }, pipeline() { const calls = []; return { @@ -207,10 +255,8 @@ describe('RedisMessageStore paw-feel source projections', () => { return this; }, async exec() { - batches.push(calls); return calls.map(({ key, fields }) => { - const id = key.slice(key.lastIndexOf(':') + 1); - const hash = hashes.get(id); + const hash = hashes.get(key.slice(key.lastIndexOf(':') + 1)); return [null, hash ? fields.map((field) => hash[field] ?? null) : []]; }); }, @@ -219,12 +265,11 @@ describe('RedisMessageStore paw-feel source projections', () => { }; const store = new RedisMessageStore(redis); - const reads = await store.getPawFeelSourceProjections(ids); + const reads = await store.getPawFeelSourceProjections(['source-good', 'source-bad', 'source-later']); - assert.equal(batches.length, 2); - assert.equal(reads.size, ids.length, 'every requested ID receives an outcome'); assert.equal(reads.get('source-good')?.kind, 'available'); - assert.deepEqual(reads.get('source-bad'), { kind: 'unavailable', reason: 'read_failed' }); + assert.equal(reads.get('source-bad')?.kind, 'available'); + assert.equal(reads.get('source-bad')?.message.extra, undefined); assert.equal(reads.get('source-later')?.kind, 'available'); }); }); @@ -601,7 +646,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); it('append() stores message and returns with id', async () => { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'user1', catId: null, content: 'hello', @@ -619,7 +664,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () clientMessageId: 'local-review-verdict-redis-1', reviewedHeadSha: 'a'.repeat(40), }; - const stored = await store.append({ + const stored = await appendFixture(store, { userId: 'user-f167-reviewer', catId: 'opus5', content: 'REQUEST_CHANGES — typed verdict presentation', @@ -636,7 +681,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('F167 rehydrates the managed-command action lease generation from the real Redis hash', async () => { const actionLeaseRef = { leaseId: 'lease-managed-review-redis-1', generation: 7 }; - const stored = await store.append({ + const stored = await appendFixture(store, { userId: 'scheduler', catId: null, content: '[定时任务] review command completed', @@ -676,7 +721,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () candidateAction: 'merge', occurredAt: 1_785_600_000_000, }; - const stored = await store.append({ + const stored = await appendFixture(store, { userId: 'user-f287-billing', catId: null, content: 'GitHub CI failed before any source step ran.', @@ -695,7 +740,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('listOwnerMessagesInWindow returns the exact delivered owner timeline with inclusive boundaries and no page cap', async () => { const base = Date.now() - 10_000; const append = (overrides) => - store.append({ + appendFixture(store, { userId: 'owner-window', catId: null, content: 'candidate', @@ -723,13 +768,6 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () await append({ content: 'after', timestamp: base + 82 }); const firstRead = await store.listOwnerMessagesInWindow('owner-window', base, base + 81); - const bounded = await store.listOwnerMessageWindowSlice('owner-window', base, base + 81, 3); - assert.equal(bounded.hasMore, true, 'filtered queued rows must not conceal truncation'); - assert.ok(bounded.messages.length <= 3); - const complete = await store.listOwnerMessageWindowSlice('owner-window', base, base + 81, 500); - assert.equal(complete.hasMore, false); - assert.deepEqual(complete.messages, firstRead); - await assert.rejects(store.listOwnerMessageWindowSlice('owner-window', base, base + 81, 0), /limit/); assert.deepEqual( firstRead.map((message) => message.id), [lower.id, delivered.id, ...middle.map((message) => message.id), upper.id], @@ -745,9 +783,15 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('all Redis append entrypoints reject unsafe timestamps and transition-owned metadata before side effects', async () => { const appenders = [ - ['append', (target, message) => target.append(message)], - ['appendIfThreadFrontier', (target, message) => target.appendIfThreadFrontier(message, null)], - ['appendAndObservePriorFrontier', (target, message) => target.appendAndObservePriorFrontier(message)], + ['append', (target, message) => appendFixture(target, message)], + [ + 'appendIfThreadFrontier', + (target, message) => target.appendIfThreadFrontier(withFixtureProvenance(message), null), + ], + [ + 'appendAndObservePriorFrontier', + (target, message) => target.appendAndObservePriorFrontier(withFixtureProvenance(message)), + ], ]; let listenerCalls = 0; const admissionStore = new RedisMessageStore(redis, { ttlSeconds: 0, onAppend: () => listenerCalls++ }); @@ -770,6 +814,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () for (const metadata of [ { deliveredAt: undefined }, { timelineOrderAt: undefined }, + { timelinePublishedAtAppend: true }, { deliveredAt: 101, deliveryStatus: 'delivered' }, { deliveryStatus: 'delivered' }, { deliveryStatus: 'canceled' }, @@ -798,7 +843,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('append admits and rehydrates the sortable-ID-safe Date boundaries', async () => { const roundTripStore = new RedisMessageStore(redis, { ttlSeconds: 0 }); for (const timestamp of [0, 1, 8_640_000_000_000_000]) { - const stored = await roundTripStore.append({ + const stored = await appendFixture(roundTripStore, { userId: 'user1', catId: null, content: 'valid Date input', @@ -821,7 +866,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () for (const [index, deliveredAt] of invalidTimestamps.entries()) { const userId = `user-delivery-admission-${index}`; const threadId = `thread-delivery-admission-${index}`; - const queued = await admissionStore.append({ + const queued = await appendFixture(admissionStore, { userId, catId: null, content: `queued ${index}`, @@ -877,7 +922,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () for (const { suffix, deliveredAt, removeSourceScore } of cases) { const sourceUserId = `user-delivery-source-${suffix}`; const targetUserId = `user-delivery-target-${suffix}`; - const queued = await admissionStore.append({ + const queued = await appendFixture(admissionStore, { userId: sourceUserId, catId: null, content: suffix, @@ -903,14 +948,14 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const userId = 'user-cancel-owner'; const threadId = 'thread-cancel-owner'; const base = { userId, catId: null, mentions: [], threadId }; - const queued = await admissionStore.append({ + const queued = await appendFixture(admissionStore, { ...base, content: 'queued', timestamp: 100, deliveryStatus: 'queued', }); - const legacy = await admissionStore.append({ ...base, content: 'legacy', timestamp: 110 }); - const delivered = await admissionStore.append({ + const legacy = await appendFixture(admissionStore, { ...base, content: 'legacy', timestamp: 110 }); + const delivered = await appendFixture(admissionStore, { ...base, content: 'delivered', timestamp: 120, @@ -937,6 +982,40 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () assert.equal((await admissionStore.markCanceled(queued.id)).deliveryTransitioned, false); }); + it('keeps a canceled lifecycle response in the visible thread index', async () => { + const admissionStore = new RedisMessageStore(redis, { ttlSeconds: 0 }); + const threadId = 'thread-canceled-response-visible'; + const response = await appendFixture(admissionStore, { + userId: 'user-canceled-response-visible', + catId: 'codex', + content: '', + mentions: [], + timestamp: 100, + threadId, + deliveryStatus: 'queued', + lifecycle: { + kind: 'response', + orderKey: '100:inv-canceled-response-visible', + invocationId: 'inv-canceled-response-visible', + targetId: 'codex', + inputEntryIds: ['entry-canceled-response-visible'], + inputMessageIds: ['source-canceled-response-visible'], + status: 'canceled', + startedAt: 100, + completedAt: 101, + }, + }); + + await admissionStore.markCanceled(response.id); + + assert.deepEqual( + (await admissionStore.getByThreadAfter(threadId)).map((message) => message.id), + [response.id], + 'the member terminal bubble must remain visible after cancellation', + ); + assert.notEqual(await redis.zscore(`msg:thread:${threadId}`, response.id), null); + }); + it('legacy hydration preserves blank invalid evidence, fractions, infinities, and missing epoch fallback', async () => { const cases = [ { label: 'empty', timestamp: '', expected: Number.NaN }, @@ -990,7 +1069,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const earlier = fixture.earlierTimestamp === null ? null - : await store.append({ + : await appendFixture(store, { userId, catId: null, content: `earlier than ${fixture.label}`, @@ -1082,9 +1161,9 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getRecent() returns messages in chronological order', async () => { const now = Date.now(); - await store.append({ userId: 'u', catId: null, content: 'first', mentions: [], timestamp: now }); - await store.append({ userId: 'u', catId: 'opus', content: 'second', mentions: [], timestamp: now + 1 }); - await store.append({ userId: 'u', catId: null, content: 'third', mentions: [], timestamp: now + 2 }); + await appendFixture(store, { userId: 'u', catId: null, content: 'first', mentions: [], timestamp: now }); + await appendFixture(store, { userId: 'u', catId: 'opus', content: 'second', mentions: [], timestamp: now + 1 }); + await appendFixture(store, { userId: 'u', catId: null, content: 'third', mentions: [], timestamp: now + 2 }); const recent = await store.getRecent(10); assert.equal(recent.length, 3); @@ -1094,8 +1173,8 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getRecent() filters by userId', async () => { const now = Date.now(); - await store.append({ userId: 'alice', catId: null, content: 'alice msg', mentions: [], timestamp: now }); - await store.append({ userId: 'bob', catId: null, content: 'bob msg', mentions: [], timestamp: now + 1 }); + await appendFixture(store, { userId: 'alice', catId: null, content: 'alice msg', mentions: [], timestamp: now }); + await appendFixture(store, { userId: 'bob', catId: null, content: 'bob msg', mentions: [], timestamp: now + 1 }); const aliceOnly = await store.getRecent(10, 'alice'); assert.equal(aliceOnly.length, 1); @@ -1104,9 +1183,15 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getMentionsFor() returns messages mentioning a specific cat', async () => { const now = Date.now(); - await store.append({ userId: 'u', catId: null, content: 'hi opus', mentions: ['opus'], timestamp: now }); - await store.append({ userId: 'u', catId: null, content: 'hi codex', mentions: ['codex'], timestamp: now + 1 }); - await store.append({ + await appendFixture(store, { userId: 'u', catId: null, content: 'hi opus', mentions: ['opus'], timestamp: now }); + await appendFixture(store, { + userId: 'u', + catId: null, + content: 'hi codex', + mentions: ['codex'], + timestamp: now + 1, + }); + await appendFixture(store, { userId: 'u', catId: null, content: 'hi both', @@ -1122,7 +1207,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getMentionsFor() filters by threadId (#75)', async () => { const now = Date.now(); - await store.append({ + await appendFixture(store, { userId: 'u', catId: null, content: '@opus in tA', @@ -1130,7 +1215,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () timestamp: now, threadId: 'thread-A', }); - await store.append({ + await appendFixture(store, { userId: 'u', catId: null, content: '@opus in tB', @@ -1138,7 +1223,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () timestamp: now + 1, threadId: 'thread-B', }); - await store.append({ + await appendFixture(store, { userId: 'u', catId: null, content: '@opus in tA again', @@ -1159,9 +1244,9 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getBefore() returns messages before timestamp', async () => { const base = Date.now(); - await store.append({ userId: 'u', catId: null, content: 'old', mentions: [], timestamp: base }); - await store.append({ userId: 'u', catId: null, content: 'mid', mentions: [], timestamp: base + 100 }); - await store.append({ userId: 'u', catId: null, content: 'new', mentions: [], timestamp: base + 200 }); + await appendFixture(store, { userId: 'u', catId: null, content: 'old', mentions: [], timestamp: base }); + await appendFixture(store, { userId: 'u', catId: null, content: 'mid', mentions: [], timestamp: base + 100 }); + await appendFixture(store, { userId: 'u', catId: null, content: 'new', mentions: [], timestamp: base + 200 }); const before = await store.getBefore(base + 200, 10); assert.equal(before.length, 2); @@ -1172,7 +1257,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getBefore() respects limit', async () => { const base = Date.now(); for (let i = 0; i < 5; i++) { - await store.append({ userId: 'u', catId: null, content: `msg${i}`, mentions: [], timestamp: base + i }); + await appendFixture(store, { userId: 'u', catId: null, content: `msg${i}`, mentions: [], timestamp: base + i }); } const before = await store.getBefore(base + 5, 2); @@ -1185,7 +1270,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getByThreadBeforeBounded resumes from a real Redis rank cursor and proves exhaustion', async () => { const base = Date.now(); for (let index = 0; index < 510; index += 1) { - await store.append({ + await appendFixture(store, { userId: 'user-1', catId: null, content: `bounded ${index}`, @@ -1224,7 +1309,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); it('augmentStreamMetadata() persists stream-only metadata onto callback messages', async () => { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'u', catId: 'opus', content: 'callback canonical', @@ -1262,7 +1347,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); it('hardDelete clears toolEvents from returned object and Redis', async () => { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'u', catId: 'opus', content: 'tool msg', @@ -1289,7 +1374,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); it('hardDelete clears thinking from returned object and Redis (F045 security)', async () => { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'u', catId: 'opus', content: 'response with thinking', @@ -1313,7 +1398,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); it('message TTL is set', async () => { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'u', catId: null, content: 'ttl test', @@ -1325,8 +1410,8 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () assert.ok(ttl <= 60, `Expected TTL <= 60, got ${ttl}`); }); - it('appendIdempotent() reports and returns the persisted winner', async () => { - const firstResult = await store.appendIdempotent({ + it('append() with same idempotencyKey returns existing message', async () => { + const first = await appendFixture(store, { userId: 'u1', catId: null, content: 'kickoff', @@ -1335,9 +1420,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-idem', idempotencyKey: 'backlog:b1:attempt:a1', }); - const first = firstResult.message; - - const secondResult = await store.appendIdempotent({ + const second = await appendFixture(store, { userId: 'u1', catId: null, content: 'kickoff retried', @@ -1346,10 +1429,6 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-idem', idempotencyKey: 'backlog:b1:attempt:a1', }); - const second = secondResult.message; - - assert.equal(firstResult.idempotent, false); - assert.equal(secondResult.idempotent, true); assert.equal(second.id, first.id); assert.equal(second.content, 'kickoff'); @@ -1362,8 +1441,8 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const threadId = 'thread-concurrent-idem'; const timestamp = Date.now(); - const [firstResult, secondResult] = await Promise.all([ - store.appendIdempotent({ + const [first, second] = await Promise.all([ + appendFixture(store, { userId: 'u1', catId: null, content: 'concurrent', @@ -1372,7 +1451,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId, idempotencyKey: 'concurrent-idem', }), - store.appendIdempotent({ + appendFixture(store, { userId: 'u1', catId: null, content: 'concurrent', @@ -1382,15 +1461,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () idempotencyKey: 'concurrent-idem', }), ]); - const first = firstResult.message; - const second = secondResult.message; - assert.equal(first.id, second.id, 'both callers must observe the same winner'); - assert.deepEqual( - [firstResult.idempotent, secondResult.idempotent].sort(), - [false, true], - 'one atomic caller creates the message and the loser reports the durable winner', - ); assert.deepEqual(await redis.zrange(`msg:thread:${threadId}`, 0, -1), [first.id]); }); @@ -1413,8 +1484,8 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () idempotencyKey: 'redis-onappend', }; - const first = await watchedStore.append(input); - const replay = await watchedStore.append({ ...input, content: 'retry', timestamp: timestamp + 1 }); + const first = await appendFixture(watchedStore, input); + const replay = await appendFixture(watchedStore, { ...input, content: 'retry', timestamp: timestamp + 1 }); assert.equal(replay.id, first.id); assert.equal(calls, 1, 'only the committed winner may fire onAppend'); @@ -1434,8 +1505,8 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () idempotencyKey: 'empty-arrays', }; - const first = await store.append(input); - const replay = await store.append(input); + const first = await appendFixture(store, input); + const replay = await appendFixture(store, input); const hydrated = await store.getById(first.id); for (const message of [first, replay, hydrated]) { @@ -1451,7 +1522,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const idempotencyKey = 'stale-idem'; const redisKey = MessageKeys.idempotency(userId, threadId, idempotencyKey); const missingId = generateSortableId(Date.now() - 1); - const liveWinner = await store.append({ + const liveWinner = await appendFixture(store, { userId, catId: null, content: 'concurrent live winner', @@ -1481,7 +1552,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () let replay; try { - replay = await store.append({ + replay = await appendFixture(store, { userId, catId: null, content: 'must observe concurrent winner', @@ -1504,7 +1575,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const userId = 'u1'; const threadId = 'thread-vanished-winner'; const idempotencyKey = 'vanished-winner'; - const winner = await store.append({ + const winner = await appendFixture(store, { userId, catId: null, content: 'winner', @@ -1546,7 +1617,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () try { await assert.rejects( - watchedStore.append({ + appendFixture(watchedStore, { userId, catId: null, content: 'loser', @@ -1573,7 +1644,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () await redis.zadd(threadKey, Date.now() - 120_000, staleId); const ttlStore = new RedisMessageStore(redis, { ttlSeconds: 60 }); - const current = await ttlStore.append({ + const current = await appendFixture(ttlStore, { userId: 'u1', catId: null, content: 'keeps thread active', @@ -1587,7 +1658,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); it('F057-C2: mentionsUser round-trips through append/getById', async () => { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'u', catId: 'opus', content: '@co-creator 看看这个', @@ -1604,7 +1675,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('F057-C2: mentionsUser round-trips through hydrateMessages (getByThread)', async () => { const now = Date.now(); - await store.append({ + await appendFixture(store, { userId: 'u', catId: 'opus', content: '@user please check', @@ -1613,7 +1684,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-mention-hydrate', mentionsUser: true, }); - await store.append({ + await appendFixture(store, { userId: 'u', catId: null, content: 'normal message', @@ -1633,7 +1704,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const threadId = 'thread-score-deliver-557'; // msgA sent first (base), msgB sent second (base+100) — both queued - const msgA = await store.append({ + const msgA = await appendFixture(store, { userId: 'u', catId: null, content: 'msgA-sent-first', @@ -1642,7 +1713,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId, deliveryStatus: 'queued', }); - const msgB = await store.append({ + const msgB = await appendFixture(store, { userId: 'u', catId: null, content: 'msgB-sent-second', @@ -1673,7 +1744,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('markDelivered records actual delivery time without moving already-published cat speech', async () => { const base = Date.now(); const threadId = 'thread-published-cat-score'; - const speech = await store.append({ + const speech = await appendFixture(store, { userId: 'u', catId: 'codex-sol', content: 'already-published cat speech', @@ -1717,7 +1788,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const threadId = 'thread-cursor-deliver-557'; // agentReply at base (simulates invocation start time) — already delivered (no deliveryStatus) - const agentReply = await store.append({ + const agentReply = await appendFixture(store, { userId: 'u', catId: 'opus', content: 'agent-reply', @@ -1728,7 +1799,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () // queuedMsg sent BEFORE agent reply (base-10), queued — delivered AFTER (base+500). // Without zadd re-scoring, original timestamp (base-10) < cursor (base), so it would NOT // appear; only deliveredAt re-scoring (base+500 > base) makes it visible after cursor. - const queuedMsg = await store.append({ + const queuedMsg = await appendFixture(store, { userId: 'u', catId: null, content: 'queued-user-msg', @@ -1749,7 +1820,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); it('F148: origin=briefing survives append → getById round-trip', async () => { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'system', catId: null, content: 'briefing summary', @@ -1768,7 +1839,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('F148: origin=briefing survives hydrateMessages (getByThread)', async () => { const now = Date.now(); - await store.append({ + await appendFixture(store, { userId: 'system', catId: null, content: 'briefing card', @@ -1777,7 +1848,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-briefing-hydrate', origin: 'briefing', }); - await store.append({ + await appendFixture(store, { userId: 'u', catId: null, content: 'normal', @@ -1797,7 +1868,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('scanByDeliveryStatus returns IDs matching target status', async () => { const now = Date.now(); // Create messages with different delivery statuses - const m1 = await store.append({ + const m1 = await appendFixture(store, { userId: 'u1', catId: null, content: 'queued msg 1', @@ -1806,7 +1877,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-scan-1', deliveryStatus: 'queued', }); - const m2 = await store.append({ + const m2 = await appendFixture(store, { userId: 'u1', catId: null, content: 'delivered msg', @@ -1814,7 +1885,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () timestamp: now + 1, threadId: 'thread-scan-1', }); - const m3 = await store.append({ + const m3 = await appendFixture(store, { userId: 'u1', catId: null, content: 'queued msg 2', @@ -1836,7 +1907,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('scanByDeliveryStatus returns empty array when no matches', async () => { const now = Date.now(); - await store.append({ + await appendFixture(store, { userId: 'u1', catId: null, content: 'normal msg', @@ -1853,7 +1924,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const now = Date.now(); const created = []; for (let i = 0; i < 5; i++) { - const msg = await store.append({ + const msg = await appendFixture(store, { userId: 'u1', catId: null, content: `queued ${i}`, @@ -1876,7 +1947,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('scanByDeliveryStatus finds canceled messages', async () => { const now = Date.now(); - const m1 = await store.append({ + const m1 = await appendFixture(store, { userId: 'u1', catId: null, content: 'will be canceled', diff --git a/packages/api/test/redis-queue-ledger-reader.test.js b/packages/api/test/redis-queue-ledger-reader.test.js new file mode 100644 index 0000000000..aa2d97f7c2 --- /dev/null +++ b/packages/api/test/redis-queue-ledger-reader.test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { getRedisQueueLedgerEntriesByMessageIds, listRedisQueueLedgerEntries } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerReader.js' +); + +function row(id, messageId, target = 'opus') { + return { + version: 2, + id, + threadId: 'thread-1', + owner: { kind: 'user', userId: 'user-1' }, + kind: 'conversation_input', + from: { kind: 'user', userId: 'user-1' }, + targets: [target], + payload: { sourceRecordId: messageId, messageId, content: 'body' }, + execution: { intent: 'execute', ownerAuthProvenance: 'strict', autoExecute: false }, + delivery: {}, + status: 'queued', + enqueuedAt: 1, + priority: 'normal', + }; +} + +describe('Redis Queue ledger atomic readers', () => { + it('reads order and rows in one Redis script snapshot', async () => { + const expected = row('queue-1', 'message-1'); + const calls = []; + const redis = { + eval: async (...args) => { + calls.push(args); + return JSON.stringify([JSON.stringify(expected)]); + }, + lrange: () => assert.fail('split order read must not run'), + hmget: () => assert.fail('split row read must not run'), + }; + + assert.deepEqual(await listRedisQueueLedgerEntries(redis, 'thread-1'), [expected]); + assert.equal(calls.length, 1); + assert.equal(calls[0][1], 2); + }); + + it('reads message indexes and referenced rows in one Redis script snapshot', async () => { + const first = row('queue-1', 'message-1', 'opus'); + const second = row('queue-2', 'message-1', 'codex'); + const calls = []; + const redis = { + eval: async (...args) => { + calls.push(args); + return JSON.stringify({ 'message-1': [JSON.stringify(first), JSON.stringify(second)] }); + }, + hmget: () => assert.fail('split index or row read must not run'), + }; + + assert.deepEqual( + await getRedisQueueLedgerEntriesByMessageIds(redis, 'thread-1', ['message-1']), + new Map([['message-1', [first, second]]]), + ); + assert.equal(calls.length, 1); + assert.equal(calls[0][1], 2); + }); +}); diff --git a/packages/api/test/redis-read-state-store.test.js b/packages/api/test/redis-read-state-store.test.js index e2d9e2692e..7a9ed222e3 100644 --- a/packages/api/test/redis-read-state-store.test.js +++ b/packages/api/test/redis-read-state-store.test.js @@ -10,7 +10,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -175,30 +175,36 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() counts cat messages as unread', async () => { const tid = uniqueId('t'); // Cat messages share same userId as tenant — catId distinguishes them - const m1 = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: Date.now() - 3000, - threadId: tid, - }); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'world', - mentions: [], - timestamp: Date.now() - 2000, - threadId: tid, - }); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'test', - mentions: [], - timestamp: Date.now() - 1000, - threadId: tid, - }); + const m1 = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: Date.now() - 3000, + threadId: tid, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'world', + mentions: [], + timestamp: Date.now() - 2000, + threadId: tid, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'test', + mentions: [], + timestamp: Date.now() - 1000, + threadId: tid, + }), + ); await store.ack('user1', tid, m1.id); @@ -211,42 +217,50 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() counts published queued cat speech but not queued user or system work', async () => { const tid = uniqueId('t-published'); - const anchor = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'read anchor', - mentions: [], - timestamp: Date.now() - 4000, - threadId: tid, - }); - await messageStore.append({ - userId: 'user1', - catId: null, - content: 'queued user work', - mentions: ['opus'], - timestamp: Date.now() - 3000, - threadId: tid, - deliveryStatus: 'queued', - }); - await messageStore.append({ - userId: 'user1', - catId: 'codex-sol', - content: 'published source-cat seed', - mentions: ['opus'], - timestamp: Date.now() - 2000, - threadId: tid, - deliveryStatus: 'queued', - mentionsUser: true, - }); - await messageStore.append({ - userId: 'system', - catId: 'system', - content: 'queued internal system event', - mentions: [], - timestamp: Date.now() - 1000, - threadId: tid, - deliveryStatus: 'queued', - }); + const anchor = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'read anchor', + mentions: [], + timestamp: Date.now() - 4000, + threadId: tid, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'queued user work', + mentions: ['opus'], + timestamp: Date.now() - 3000, + threadId: tid, + deliveryStatus: 'queued', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'codex-sol', + content: 'published source-cat seed', + mentions: ['opus'], + timestamp: Date.now() - 2000, + threadId: tid, + deliveryStatus: 'queued', + mentionsUser: true, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: 'system', + content: 'queued internal system event', + mentions: [], + timestamp: Date.now() - 1000, + threadId: tid, + deliveryStatus: 'queued', + }), + ); await store.ack('user1', tid, anchor.id); const [summary] = await store.getUnreadSummaries('user1', [tid], messageStore); @@ -254,73 +268,47 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL assert.equal(summary.hasUserMention, true); }); - it('keeps a read published cat seed before replies when its execution custody terminalizes', async () => { + it('keeps a read published cat seed before replies when delivery terminalizes', async () => { const tid = uniqueId('t-published-cursor'); const base = Date.now(); - const seed = await messageStore.append({ - userId: 'user1', - catId: 'codex-sol', - content: 'published source-cat seed', - mentions: ['opus'], - timestamp: base, - threadId: tid, - deliveryStatus: 'queued', - queueCustody: makeQueuedMessageCustody({ - entryId: 'entry-published-cursor', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - createdAt: base, - updatedAt: base, + const seed = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'codex-sol', + content: 'published source-cat seed', + mentions: ['opus'], + timestamp: base, + threadId: tid, + deliveryStatus: 'queued', }), - }); + ); await store.ack('user1', tid, seed.id); - const firstReply = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'first reply', - mentions: [], - timestamp: base + 10, - threadId: tid, - }); - const secondReply = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'second reply', - mentions: [], - timestamp: base + 20, - threadId: tid, - }); - - const terminal = makeQueuedMessageCustody({ - entryId: 'entry-published-cursor', - revision: 2, - status: 'terminal', - allTargetCats: ['opus'], - pendingTargetCats: [], - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: 'inv-published-cursor' }, - bodyExposures: [{ targetCatId: 'opus', invocationId: 'inv-published-cursor', seenAt: base + 25 }], - handledByCatIds: ['opus'], - targetOutcomeByCatId: { - opus: { - invocationId: 'inv-published-cursor', - disposition: 'completed_with_turn', - evidenceRef: { kind: 'invocation_lineage', invocationId: 'inv-published-cursor' }, - handledAt: base + 50, - }, - }, - createdAt: base, - updatedAt: base + 50, - }); - const transitioned = await messageStore.transitionQueueCustody(seed.id, { - expectedRevision: 1, - next: terminal, - deliveredAt: base + 50, - }); - - assert.equal(transitioned.kind, 'updated'); - assert.equal(transitioned.message.deliveredAt, base + 50, 'execution delivery keeps its actual terminal time'); - assert.equal(transitioned.message.timelineOrderAt, base, 'publication order is persisted separately'); + const firstReply = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'first reply', + mentions: [], + timestamp: base + 10, + threadId: tid, + }), + ); + const secondReply = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'second reply', + mentions: [], + timestamp: base + 20, + threadId: tid, + }), + ); + + const transitioned = await messageStore.markDelivered(seed.id, base + 50); + + assert.equal(transitioned.deliveryTransitioned, true); + assert.equal(transitioned.deliveredAt, base + 50, 'execution delivery keeps its actual terminal time'); + assert.equal(transitioned.timelineOrderAt, base, 'publication order is persisted separately'); assert.equal( await redis.zscore(`msg:thread:${tid}`, seed.id), String(base), @@ -341,32 +329,38 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() excludes user own messages (catId=null)', async () => { const tid = uniqueId('t'); // Cat message (catId='opus') — should be counted - const m1 = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'cat reply', - mentions: [], - timestamp: Date.now() - 3000, - threadId: tid, - }); + const m1 = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'cat reply', + mentions: [], + timestamp: Date.now() - 3000, + threadId: tid, + }), + ); // User's own message (catId=null) — should NOT be counted - await messageStore.append({ - userId: 'user1', - catId: null, - content: 'my question', - mentions: [], - timestamp: Date.now() - 2000, - threadId: tid, - }); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'my question', + mentions: [], + timestamp: Date.now() - 2000, + threadId: tid, + }), + ); // Cat reply (catId='opus') — should be counted - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'cat reply 2', - mentions: [], - timestamp: Date.now() - 1000, - threadId: tid, - }); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'cat reply 2', + mentions: [], + timestamp: Date.now() - 1000, + threadId: tid, + }), + ); await store.ack('user1', tid, m1.id); @@ -377,30 +371,36 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() excludes deleted messages from count', async () => { const tid = uniqueId('t'); - const m1 = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: Date.now() - 3000, - threadId: tid, - }); - const m2 = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'to delete', - mentions: [], - timestamp: Date.now() - 2000, - threadId: tid, - }); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'keep', - mentions: [], - timestamp: Date.now() - 1000, - threadId: tid, - }); + const m1 = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: Date.now() - 3000, + threadId: tid, + }), + ); + const m2 = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'to delete', + mentions: [], + timestamp: Date.now() - 2000, + threadId: tid, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'keep', + mentions: [], + timestamp: Date.now() - 1000, + threadId: tid, + }), + ); await store.ack('user1', tid, m1.id); await messageStore.softDelete(m2.id, 'user1'); @@ -412,23 +412,27 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() detects mentionsUser', async () => { const tid = uniqueId('t'); - const m1 = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: Date.now() - 2000, - threadId: tid, - }); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: '@co-creator look', - mentions: [], - mentionsUser: true, - timestamp: Date.now() - 1000, - threadId: tid, - }); + const m1 = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: Date.now() - 2000, + threadId: tid, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: '@co-creator look', + mentions: [], + mentionsUser: true, + timestamp: Date.now() - 1000, + threadId: tid, + }), + ); await store.ack('user1', tid, m1.id); @@ -438,14 +442,16 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() returns 0 for fully read thread', async () => { const tid = uniqueId('t'); - const m1 = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: Date.now(), - threadId: tid, - }); + const m1 = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: Date.now(), + threadId: tid, + }), + ); await store.ack('user1', tid, m1.id); const summaries = await store.getUnreadSummaries('user1', [tid], messageStore); @@ -454,22 +460,26 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() treats no cursor as fully read (cold-start guard)', async () => { const tid = uniqueId('t'); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: Date.now() - 1000, - threadId: tid, - }); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'world', - mentions: [], - timestamp: Date.now(), - threadId: tid, - }); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: Date.now() - 1000, + threadId: tid, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'world', + mentions: [], + timestamp: Date.now(), + threadId: tid, + }), + ); // No ack → no cursor → should return 0 (not "all unread") // Pre-F069 threads have no cursor; treating them as all-unread @@ -482,30 +492,36 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() handles multiple threads (mixed cursor states)', async () => { const tA = uniqueId('t'); const tB = uniqueId('t'); - const mA1 = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'a1', - mentions: [], - timestamp: Date.now() - 2000, - threadId: tA, - }); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'a2', - mentions: [], - timestamp: Date.now() - 1000, - threadId: tA, - }); - await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'b', - mentions: [], - timestamp: Date.now(), - threadId: tB, - }); + const mA1 = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'a1', + mentions: [], + timestamp: Date.now() - 2000, + threadId: tA, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'a2', + mentions: [], + timestamp: Date.now() - 1000, + threadId: tA, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'b', + mentions: [], + timestamp: Date.now(), + threadId: tB, + }), + ); // Ack thread A at first message → 1 unread; thread B has no cursor → 0 (cold-start) await store.ack('user1', tA, mA1.id); diff --git a/packages/api/test/redis-unread-summary-visibility-cursor.test.js b/packages/api/test/redis-unread-summary-visibility-cursor.test.js index 37923bc0b0..1287598de0 100644 --- a/packages/api/test/redis-unread-summary-visibility-cursor.test.js +++ b/packages/api/test/redis-unread-summary-visibility-cursor.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -59,23 +60,27 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati async function appendVisibilityInversion(threadId, userId) { const baseTs = Date.now() - 10_000; - const c = await messageStore.append({ - userId, - catId: 'opus', - content: 'C: visible first with the later raw id', - mentions: [], - timestamp: baseTs + 200, - threadId, - }); - const q = await messageStore.append({ - userId, - catId: 'codex', - content: 'Q: visible second with the earlier raw id', - mentions: [], - timestamp: baseTs + 100, - threadId, - deliveryStatus: 'queued', - }); + const c = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: 'C: visible first with the later raw id', + mentions: [], + timestamp: baseTs + 200, + threadId, + }), + ); + const q = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'codex', + content: 'Q: visible second with the earlier raw id', + mentions: [], + timestamp: baseTs + 100, + threadId, + deliveryStatus: 'queued', + }), + ); assert.ok(q.id < c.id, 'fixture must invert raw-id order relative to visibility order'); const latest = await messageStore.getLatestVisibleCursor(threadId); @@ -84,55 +89,28 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati } async function appendTerminalManagedHold(threadId, { ownerUserId, hiddenTrigger = false, suffix, timestamp }) { - const custody = { - version: 1, - entryId: `entry-${suffix}`, - revision: 1, - ownerUserId, - intent: 'managed command wake', - status: 'queued', - allTargetCats: ['opus5'], - pendingTargetCats: ['opus5'], - notifiedByCatIds: [], - seenByCatIds: [], - seenInvocationIdByCatId: {}, - failedByCatIds: [], - handledByCatIds: [], - priority: 'normal', - createdAt: timestamp, - updatedAt: timestamp, - }; - const message = await messageStore.append({ - userId: 'scheduler', - catId: null, - content: `managed command ${suffix}`, - mentions: [], - timestamp, - threadId, - deliveryStatus: 'queued', - queueCustody: custody, - ...(hiddenTrigger ? { extra: { scheduler: { hiddenTrigger: true } } } : {}), - source: { - connector: 'hold-ball', - label: '持球结果', - icon: '🏓', - meta: { taskId: `task-${suffix}`, threadId, catId: 'opus5', wakeWhen: true }, - }, - }); - const transitioned = await messageStore.transitionQueueCustody(message.id, { - expectedRevision: 1, - next: { - ...custody, - revision: 2, - status: 'terminal', - pendingTargetCats: [], - failedByCatIds: ['opus5'], - updatedAt: timestamp + 1, - }, - deliveredAt: timestamp + 1, - }); - assert.equal(transitioned.kind, 'updated'); - return transitioned.message; + const message = await messageStore.append( + canonicalTestMessageInput({ + userId: ownerUserId, + catId: null, + from: { kind: 'system', service: 'hold-ball' }, + content: `managed command ${suffix}`, + mentions: [], + timestamp, + threadId, + deliveryStatus: 'queued', + ...(hiddenTrigger ? { extra: { scheduler: { hiddenTrigger: true } } } : {}), + source: { + connector: 'hold-ball', + label: '持球结果', + icon: '🏓', + meta: { taskId: `task-${suffix}`, threadId, catId: 'opus5', wakeWhen: true }, + }, + }), + ); + const transitioned = await messageStore.markDelivered(message.id, timestamp + 1); + assert.equal(transitioned?.deliveryTransitioned, true); + return transitioned; } async function createInvalidAnchorFixture(kind, suffix) { @@ -140,14 +118,16 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati const threadStore = new ThreadStore(); const thread = threadStore.create(userId, `managed-hold anchor ${suffix}`); const base = Date.now() - 20_000; - const primary = await messageStore.append({ - userId, - catId: 'opus', - content: `valid primary ${suffix}`, - mentions: [], - timestamp: base, - threadId: thread.id, - }); + const primary = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: `valid primary ${suffix}`, + mentions: [], + timestamp: base, + threadId: thread.id, + }), + ); const incoming = await appendTerminalManagedHold(thread.id, { ownerUserId: userId, suffix: `${suffix}-incoming`, @@ -172,14 +152,16 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati const threadStore = new ThreadStore(); const thread = threadStore.create(userId, `managed-hold valid anchor ${suffix}`); const base = Date.now() - 20_000; - const primary = await messageStore.append({ - userId, - catId: 'opus', - content: `valid legacy primary ${suffix}`, - mentions: [], - timestamp: base, - threadId: thread.id, - }); + const primary = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: `valid legacy primary ${suffix}`, + mentions: [], + timestamp: base, + threadId: thread.id, + }), + ); const incoming = await appendTerminalManagedHold(thread.id, { ownerUserId: userId, suffix: `${suffix}-incoming`, @@ -263,14 +245,16 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati assert.ok(latest?.cursor.startsWith('v2:'), 'fixture must expose a canonical v2 cursor'); assert.equal(await readStateStore.ack(userId, threadId, latest.cursor), true); - await messageStore.append({ - userId, - catId: 'opus', - content: 'D: genuinely unread after the v2 cursor', - mentions: [], - timestamp: baseTs + 300, - threadId, - }); + await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: 'D: genuinely unread after the v2 cursor', + mentions: [], + timestamp: baseTs + 300, + threadId, + }), + ); assert.deepEqual(await readStateStore.getUnreadSummaries(userId, [threadId], messageStore), [ { threadId, unreadCount: 1, hasUserMention: false }, @@ -280,14 +264,16 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati it('includes a late-visible message when the raw closure frontier is unchanged', async () => { const userId = 'user-closure'; const threadId = 'thread-closure-visibility-inversion'; - const origin = await messageStore.append({ - userId, - catId: null, - content: 'origin request', - mentions: ['codex-sol'], - timestamp: Date.now() - 20_000, - threadId, - }); + const origin = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: null, + content: 'origin request', + mentions: ['codex-sol'], + timestamp: Date.now() - 20_000, + threadId, + }), + ); const { c, q } = await appendVisibilityInversion(threadId, userId); const closure = createFreshnessClosure({ id: 'closure-redis-visibility-inversion', @@ -317,22 +303,26 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati const userId = 'user-stale-cursor'; const threadId = 'thread-stale-cursor'; - const msg = await messageStore.append({ - userId, - catId: 'opus', - content: 'message that will be pruned', - mentions: [], - timestamp: Date.now() - 5_000, - threadId, - }); - await messageStore.append({ - userId, - catId: 'codex', - content: 'later message still visible', - mentions: [], - timestamp: Date.now() - 1_000, - threadId, - }); + const msg = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: 'message that will be pruned', + mentions: [], + timestamp: Date.now() - 5_000, + threadId, + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'codex', + content: 'later message still visible', + mentions: [], + timestamp: Date.now() - 1_000, + threadId, + }), + ); // Persist the rollout-gated v1 primary plus its durable v2 anchor. const canonicalCursor = await messageStore.canonicalizeCursor(msg.id, threadId); @@ -356,28 +346,32 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati it('advances from a pruned primary by comparing against its durable anchor', async () => { const userId = 'user-pruned-primary-advance'; const threadId = 'thread-pruned-primary-advance'; - const first = await messageStore.append({ - userId, - catId: 'opus', - content: 'first read message', - mentions: [], - timestamp: Date.now() - 2_000, - threadId, - }); + const first = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: 'first read message', + mentions: [], + timestamp: Date.now() - 2_000, + threadId, + }), + ); const firstCursor = await messageStore.canonicalizeCursor(first.id, threadId); assert.equal(await readStateStore.ack(userId, threadId, first.id, firstCursor), true); await redis.del(`msg:${first.id}`); await redis.zrem(`msg:visibility:${threadId}`, first.id); - const later = await messageStore.append({ - userId, - catId: 'codex-sol', - content: 'later visible message', - mentions: [], - timestamp: Date.now(), - threadId, - }); + const later = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'codex-sol', + content: 'later visible message', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); const laterCursor = await messageStore.canonicalizeCursor(later.id, threadId); assert.equal(await readStateStore.ack(userId, threadId, later.id, laterCursor), true); @@ -390,14 +384,16 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati const userId = 'user-read-latest-repair'; const threadStore = new ThreadStore(); const thread = threadStore.create(userId, 'Reopened #1304 integration'); - const latest = await messageStore.append({ - userId, - catId: 'opus', - content: 'latest message the user opens', - mentions: [], - timestamp: Date.now() - 1_000, - threadId: thread.id, - }); + const latest = await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'opus', + content: 'latest message the user opens', + mentions: [], + timestamp: Date.now() - 1_000, + threadId: thread.id, + }), + ); assert.equal(await readStateStore.ack(userId, thread.id, '0000000000000001-pruned-legacy'), true); const app = Fastify(); @@ -417,14 +413,16 @@ describe('Redis unread summary visibility cursor contract', { skip: redisIsolati assert.equal(repaired.lastReadMessageId, latest.id); assert.ok(repaired.lastReadVisibilityCursor?.startsWith('v2:')); - await messageStore.append({ - userId, - catId: 'codex-sol', - content: 'genuinely new unread message', - mentions: [], - timestamp: Date.now(), - threadId: thread.id, - }); + await messageStore.append( + canonicalTestMessageInput({ + userId, + catId: 'codex-sol', + content: 'genuinely new unread message', + mentions: [], + timestamp: Date.now(), + threadId: thread.id, + }), + ); assert.deepEqual(await readStateStore.getUnreadSummaries(userId, [thread.id], messageStore), [ { threadId: thread.id, unreadCount: 1, hasUserMention: false }, ]); diff --git a/packages/api/test/reminder-template.test.js b/packages/api/test/reminder-template.test.js index 446d063bac..e8827f79d2 100644 --- a/packages/api/test/reminder-template.test.js +++ b/packages/api/test/reminder-template.test.js @@ -173,6 +173,7 @@ describe('reminderTemplate', () => { assert.deepEqual(triggerMock.trigger.mock.calls[0].arguments[6], { sourceCategory: 'scheduled', + priority: 'urgent', ownerAuthProvenance: 'strict', }); }); @@ -262,3 +263,47 @@ describe('reminderTemplate firePolicy activation guard (F167 Phase M — codex P assert.equal(spec.firePolicy, undefined); }); }); + +describe('reminderTemplate managed-hold terminal visibility', () => { + it('cancels the hidden timer wake and persists one visible status when urgent admission fails', async () => { + const delivered = []; + const cancelled = []; + const taskId = 'hold-ball-1748000000-trigger-failure'; + const spec = reminderTemplate.createSpec(taskId, { + trigger: { type: 'once', fireAt: 9_999_999_999_000 }, + params: { + message: 'continue after wait', + targetCatId: 'codex', + triggerUserId: 'user-1', + holdLifecycle: { mode: 'timer', status: 'active' }, + }, + deliveryThreadId: 'thread-1', + }); + + await spec.run.execute('continue after wait', 'thread-thread-1', { + assignedCatId: 'codex', + deliver: async (input) => { + delivered.push(input); + return delivered.length === 1 ? 'wake-message-1' : 'status-message-1'; + }, + cancelQueuedDelivery: async (messageId) => { + cancelled.push(messageId); + return true; + }, + invokeTrigger: { + async trigger() { + throw new Error('queue admission rejected'); + }, + }, + }); + + assert.deepEqual(cancelled, ['wake-message-1']); + assert.equal(delivered.length, 2); + assert.equal(delivered[0].deliveryStatus, 'queued'); + assert.equal(delivered[0].idempotencyKey, `hold-ball-wake:${taskId}`); + assert.equal(delivered[1].idempotencyKey, `hold-ball-wake-failed:${taskId}`); + assert.equal(delivered[1].source.meta.phase, 'status'); + assert.match(delivered[1].content, /唤醒入队失败/); + assert.match(delivered[1].content, /queue admission rejected/); + }); +}); diff --git a/packages/api/test/reply-to-threading.test.js b/packages/api/test/reply-to-threading.test.js index ce83651bd3..18c2692fac 100644 --- a/packages/api/test/reply-to-threading.test.js +++ b/packages/api/test/reply-to-threading.test.js @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('replyTo threading', () => { // ── StoredMessage persistence ── @@ -13,24 +14,28 @@ describe('replyTo threading', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const parent = store.append({ - userId: 'user-1', - catId: 'opus', - content: 'Original message', - mentions: [], - timestamp: 1000, - threadId: 'thread-1', - }); - - const reply = store.append({ - userId: 'user-1', - catId: 'codex', - content: 'Reply to original', - mentions: [], - timestamp: 2000, - threadId: 'thread-1', - replyTo: parent.id, - }); + const parent = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'Original message', + mentions: [], + timestamp: 1000, + threadId: 'thread-1', + }), + ); + + const reply = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'Reply to original', + mentions: [], + timestamp: 2000, + threadId: 'thread-1', + replyTo: parent.id, + }), + ); assert.equal(reply.replyTo, parent.id); const fetched = store.getById(reply.id); @@ -41,13 +46,15 @@ describe('replyTo threading', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const msg = store.append({ - userId: 'user-1', - catId: null, - content: 'No reply', - mentions: [], - timestamp: 1000, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'No reply', + mentions: [], + timestamp: 1000, + }), + ); assert.equal(msg.replyTo, undefined); }); @@ -56,24 +63,28 @@ describe('replyTo threading', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - const parent = store.append({ - userId: 'user-1', - catId: 'opus', - content: 'Parent', - mentions: [], - timestamp: 1000, - threadId: 'thread-1', - }); - - store.append({ - userId: 'user-1', - catId: 'codex', - content: 'Child', - mentions: [], - timestamp: 2000, - threadId: 'thread-1', - replyTo: parent.id, - }); + const parent = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'Parent', + mentions: [], + timestamp: 1000, + threadId: 'thread-1', + }), + ); + + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'Child', + mentions: [], + timestamp: 2000, + threadId: 'thread-1', + replyTo: parent.id, + }), + ); const messages = store.getByThread('thread-1'); const child = messages.find((m) => m.content === 'Child'); @@ -88,14 +99,16 @@ describe('replyTo threading', () => { ); const store = new MessageStore(); - const parent = store.append({ - userId: 'user-1', - catId: 'opus', - content: '这是一条很长的消息,需要被截断到八十个字符以内来显示预览内容,确保在引用气泡中不会太长影响阅读体验', - mentions: [], - timestamp: 1000, - threadId: 'thread-1', - }); + const parent = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '这是一条很长的消息,需要被截断到八十个字符以内来显示预览内容,确保在引用气泡中不会太长影响阅读体验', + mentions: [], + timestamp: 1000, + threadId: 'thread-1', + }), + ); const preview = await hydrateReplyPreview(store, parent.id); assert.ok(preview); @@ -110,14 +123,16 @@ describe('replyTo threading', () => { ); const store = new MessageStore(); - const parent = store.append({ - userId: 'user-1', - catId: 'opus', - content: 'Will be deleted', - mentions: [], - timestamp: 1000, - threadId: 'thread-1', - }); + const parent = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'Will be deleted', + mentions: [], + timestamp: 1000, + threadId: 'thread-1', + }), + ); store.softDelete(parent.id, 'user-1'); @@ -143,14 +158,16 @@ describe('replyTo threading', () => { ); const store = new MessageStore(); - const parent = store.append({ - userId: 'user-1', - catId: null, - content: 'User message', - mentions: [], - timestamp: 1000, - threadId: 'thread-1', - }); + const parent = store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'User message', + mentions: [], + timestamp: 1000, + threadId: 'thread-1', + }), + ); const preview = await hydrateReplyPreview(store, parent.id); assert.ok(preview); diff --git a/packages/api/test/reply-to-validation.test.js b/packages/api/test/reply-to-validation.test.js index d687662149..93f0f9d43e 100644 --- a/packages/api/test/reply-to-validation.test.js +++ b/packages/api/test/reply-to-validation.test.js @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, mock, test } from 'node:test'; import { catRegistry, createCatId } from '@cat-cafe/shared'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { InvocationRegistry } = await import('../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'); @@ -94,6 +95,7 @@ describe('POST /api/messages — replyTo validation', () => { clearPause: mock.fn(), onInvocationComplete: mock.fn(async () => {}), enqueueContinuation: mock.fn(() => ({ outcome: 'enqueued' })), + requestDrain: mock.fn(), }, threadStore, }; @@ -126,8 +128,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202, res.body); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'reply to ghost'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'invalid replyTo should be dropped'); @@ -138,43 +140,47 @@ describe('POST /api/messages — replyTo validation', () => { // POST /api/messages path must too, else hydrateReplyPreview leaks their raw content. test('silently drops replyTo referencing system message', async () => { const thread = await createThread(); - const sysMsg = messageStore.append({ - userId: 'system', - catId: null, - content: 'SYSTEM BADGE — internal', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); + const sysMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'system', + catId: null, + content: 'SYSTEM BADGE — internal', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); const res = await app.inject({ method: 'POST', url: '/api/messages', payload: { content: 'reply to system', threadId: thread.id, replyTo: sysMsg.id }, }); - assert.equal(res.statusCode, 200); - const sent = messageStore.getByThread(thread.id).find((m) => m.content === 'reply to system'); + assert.equal(res.statusCode, 202); + const sent = messageStore.getByThreadIncludingQueued(thread.id).find((m) => m.content === 'reply to system'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'system replyTo should be dropped (non-quotable)'); }); test('silently drops replyTo referencing briefing message', async () => { const thread = await createThread(); - const briefingMsg = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'TOP SECRET BRIEFING', - mentions: [], - timestamp: 1000, - threadId: thread.id, - origin: 'briefing', - }); + const briefingMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'TOP SECRET BRIEFING', + mentions: [], + timestamp: 1000, + threadId: thread.id, + origin: 'briefing', + }), + ); const res = await app.inject({ method: 'POST', url: '/api/messages', payload: { content: 'reply to briefing', threadId: thread.id, replyTo: briefingMsg.id }, }); - assert.equal(res.statusCode, 200); - const sent = messageStore.getByThread(thread.id).find((m) => m.content === 'reply to briefing'); + assert.equal(res.statusCode, 202); + const sent = messageStore.getByThreadIncludingQueued(thread.id).find((m) => m.content === 'reply to briefing'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'briefing replyTo should be dropped (non-quotable)'); }); @@ -183,14 +189,16 @@ describe('POST /api/messages — replyTo validation', () => { const thread1 = await createThread('Thread 1'); const thread2 = await createThread('Thread 2'); - const otherThreadMsg = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'message in thread 2', - mentions: [], - timestamp: 1000, - threadId: thread2.id, - }); + const otherThreadMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'message in thread 2', + mentions: [], + timestamp: 1000, + threadId: thread2.id, + }), + ); const res = await app.inject({ method: 'POST', @@ -202,8 +210,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread1.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread1.id); const sent = messages.find((m) => m.content === 'cross-thread reply'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'cross-thread replyTo should be dropped'); @@ -212,14 +220,16 @@ describe('POST /api/messages — replyTo validation', () => { test('silently drops replyTo referencing deleted message', async () => { const thread = await createThread(); - const deleted = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'will be deleted', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); + const deleted = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'will be deleted', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); messageStore.softDelete(deleted.id); const res = await app.inject({ @@ -232,8 +242,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'reply to deleted'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'deleted-message replyTo should be dropped'); @@ -242,15 +252,17 @@ describe('POST /api/messages — replyTo validation', () => { test('silently drops replyTo referencing queued (undelivered) message', async () => { const thread = await createThread(); - const queued = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'queued message', - mentions: [], - timestamp: 1000, - threadId: thread.id, - deliveryStatus: 'queued', - }); + const queued = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'queued message', + mentions: [], + timestamp: 1000, + threadId: thread.id, + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'POST', @@ -262,8 +274,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'reply to queued'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'queued-message replyTo should be dropped'); @@ -271,15 +283,17 @@ describe('POST /api/messages — replyTo validation', () => { test('preserves replyTo referencing queued cat speech already published to the timeline', async () => { const thread = await createThread(); - const published = messageStore.append({ - userId: 'default-user', - catId: 'codex', - content: 'published source-cat seed', - mentions: ['opus'], - timestamp: 1000, - threadId: thread.id, - deliveryStatus: 'queued', - }); + const published = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'codex', + content: 'published source-cat seed', + mentions: ['opus'], + timestamp: 1000, + threadId: thread.id, + deliveryStatus: 'queued', + }), + ); const res = await app.inject({ method: 'POST', @@ -291,9 +305,9 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 202); const sent = messageStore - .getByThread(thread.id) + .getByThreadIncludingQueued(thread.id) .find((message) => message.content === 'reply to published cat speech'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, published.id); @@ -302,15 +316,17 @@ describe('POST /api/messages — replyTo validation', () => { test('silently drops replyTo referencing canceled message', async () => { const thread = await createThread(); - const canceled = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'canceled message', - mentions: [], - timestamp: 1000, - threadId: thread.id, - deliveryStatus: 'queued', - }); + const canceled = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'canceled message', + mentions: [], + timestamp: 1000, + threadId: thread.id, + deliveryStatus: 'queued', + }), + ); messageStore.markCanceled(canceled.id); const res = await app.inject({ @@ -323,8 +339,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'reply to canceled'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'canceled-message replyTo should be dropped'); @@ -333,14 +349,16 @@ describe('POST /api/messages — replyTo validation', () => { test('preserves valid replyTo referencing message in same thread', async () => { const thread = await createThread(); - const target = messageStore.append({ - userId: 'default-user', - catId: null, - content: 'original message', - mentions: [], - timestamp: 1000, - threadId: thread.id, - }); + const target = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'original message', + mentions: [], + timestamp: 1000, + threadId: thread.id, + }), + ); const res = await app.inject({ method: 'POST', @@ -352,8 +370,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'valid reply'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, target.id, 'valid replyTo should be preserved'); @@ -364,16 +382,18 @@ describe('POST /api/messages — replyTo validation', () => { test('silently drops replyTo when public message quotes a whisper', async () => { const thread = await createThread(); - const whisperMsg = messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'secret whisper content', - mentions: [], - timestamp: 1000, - threadId: thread.id, - visibility: 'whisper', - whisperTo: ['codex'], - }); + const whisperMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'secret whisper content', + mentions: [], + timestamp: 1000, + threadId: thread.id, + visibility: 'whisper', + whisperTo: ['codex'], + }), + ); const res = await app.inject({ method: 'POST', @@ -385,8 +405,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'public reply to whisper'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'public reply to whisper should drop replyTo'); @@ -396,16 +416,18 @@ describe('POST /api/messages — replyTo validation', () => { const thread = await createThread(); // Parent whispered only to codex - const whisperMsg = messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'private to codex only', - mentions: [], - timestamp: 1000, - threadId: thread.id, - visibility: 'whisper', - whisperTo: ['codex'], - }); + const whisperMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'private to codex only', + mentions: [], + timestamp: 1000, + threadId: thread.id, + visibility: 'whisper', + whisperTo: ['codex'], + }), + ); // Reply whispered to codex AND gemini — gemini can't see parent const res = await app.inject({ @@ -420,8 +442,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'wider whisper reply'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, undefined, 'wider-audience whisper reply should drop replyTo'); @@ -430,16 +452,18 @@ describe('POST /api/messages — replyTo validation', () => { test('preserves replyTo when whisper replies to whisper with same recipients', async () => { const thread = await createThread(); - const whisperMsg = messageStore.append({ - userId: 'default-user', - catId: 'opus', - content: 'whisper to codex', - mentions: [], - timestamp: 1000, - threadId: thread.id, - visibility: 'whisper', - whisperTo: ['codex'], - }); + const whisperMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: 'opus', + content: 'whisper to codex', + mentions: [], + timestamp: 1000, + threadId: thread.id, + visibility: 'whisper', + whisperTo: ['codex'], + }), + ); // Same-audience whisper reply — safe, codex already saw the parent const res = await app.inject({ @@ -454,8 +478,8 @@ describe('POST /api/messages — replyTo validation', () => { }, }); - assert.equal(res.statusCode, 200); - const messages = messageStore.getByThread(thread.id); + assert.equal(res.statusCode, 202); + const messages = messageStore.getByThreadIncludingQueued(thread.id); const sent = messages.find((m) => m.content === 'same-audience whisper reply'); assert.ok(sent, 'message should be stored'); assert.equal(sent.replyTo, whisperMsg.id, 'same-audience whisper replyTo should be preserved'); diff --git a/packages/api/test/review-feedback-router.test.js b/packages/api/test/review-feedback-router.test.js index d8477dfda5..b2603cbe1f 100644 --- a/packages/api/test/review-feedback-router.test.js +++ b/packages/api/test/review-feedback-router.test.js @@ -86,7 +86,7 @@ describe('ReviewFeedbackRouter F280 typed waits', () => { assert.equal(result.kind, 'notified'); assert.match(result.content, /review pending → CHANGES_REQUESTED/); assert.equal(result.content.includes('SOURCE_BODY_SHOULD_NEVER_RENDER'), false); - assert.equal(messageStore.getByThread('thread_1').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 1); }); test('ordinary conversation activity advances facts but never wakes a head-only waiter', async () => { @@ -109,7 +109,7 @@ describe('ReviewFeedbackRouter F280 typed waits', () => { { taskId: task.id }, ); assert.equal(result.kind, 'skipped'); - assert.equal(messageStore.getByThread('thread_1').length, 0); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 0); assert.equal((await taskStore.get(task.id)).automationState.review.lastConversationCommentCursor, 21); }); @@ -127,11 +127,11 @@ describe('ReviewFeedbackRouter F280 typed waits', () => { const first = await router.route(clean, { taskId: task.id }); assert.equal(first.kind, 'notified'); assert.match(first.content, /RESULT_AVAILABLE/); - assert.equal(messageStore.getByThread('thread_1').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 1); const replay = await router.route(clean, { taskId: task.id }); assert.equal(replay.kind, 'skipped'); - assert.equal(messageStore.getByThread('thread_1').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 1); }); test('terminal PR truth observed by the review collector consumes an active wait', async () => { @@ -148,7 +148,7 @@ describe('ReviewFeedbackRouter F280 typed waits', () => { assert.equal(result.kind, 'notified'); assert.match(result.content, /PR state: merged/); - assert.equal(messageStore.getByThread('thread_1').length, 1); + assert.equal(messageStore.getByThreadIncludingQueued('thread_1').length, 1); const stored = await taskStore.get(task.id); assert.equal(stored.status, 'done'); assert.equal(stored.automationState.await, undefined); diff --git a/packages/api/test/rich-block-interactive.test.js b/packages/api/test/rich-block-interactive.test.js index 7feff5b06e..8e578e4de6 100644 --- a/packages/api/test/rich-block-interactive.test.js +++ b/packages/api/test/rich-block-interactive.test.js @@ -116,21 +116,23 @@ import { MessageStore } from '../dist/domains/cats/services/stores/ports/Message describe('F096: MessageStore.updateExtra', () => { it('T11: updates extra.rich block state', () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'u1', - catId: 'opus', - content: 'hello', - mentions: [], - timestamp: Date.now(), - extra: { - rich: { - v: 1, - blocks: [ - { id: 'i1', kind: 'interactive', v: 1, interactiveType: 'select', options: [{ id: 'o1', label: 'A' }] }, - ], + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: Date.now(), + extra: { + rich: { + v: 1, + blocks: [ + { id: 'i1', kind: 'interactive', v: 1, interactiveType: 'select', options: [{ id: 'o1', label: 'A' }] }, + ], + }, }, - }, - }); + }), + ); const updated = store.updateExtra(msg.id, { rich: { @@ -162,17 +164,19 @@ describe('F096: MessageStore.updateExtra', () => { it('T13: preserves other extra fields (regression)', () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'u1', - catId: 'opus', - content: 'hi', - mentions: [], - timestamp: Date.now(), - extra: { - rich: { v: 1, blocks: [] }, - stream: { invocationId: 'inv-1' }, - }, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'u1', + catId: 'opus', + content: 'hi', + mentions: [], + timestamp: Date.now(), + extra: { + rich: { v: 1, blocks: [] }, + stream: { invocationId: 'inv-1' }, + }, + }), + ); const updated = store.updateExtra(msg.id, { rich: { v: 1, blocks: [{ id: 'b1', kind: 'card', v: 1, title: 'Test' }] }, @@ -235,21 +239,23 @@ describe('F096: PATCH /block-state route guards (P1-1, P2-2)', () => { it('T19: returns 403 for wrong userId', async () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'owner', - catId: 'opus', - content: 'pick', - mentions: [], - timestamp: Date.now(), - extra: { - rich: { - v: 1, - blocks: [ - { id: 'i1', kind: 'interactive', v: 1, interactiveType: 'select', options: [{ id: 'o1', label: 'A' }] }, - ], + const msg = store.append( + canonicalTestMessageInput({ + userId: 'owner', + catId: 'opus', + content: 'pick', + mentions: [], + timestamp: Date.now(), + extra: { + rich: { + v: 1, + blocks: [ + { id: 'i1', kind: 'interactive', v: 1, interactiveType: 'select', options: [{ id: 'o1', label: 'A' }] }, + ], + }, }, - }, - }); + }), + ); const app = await createApp(store); const res = await app.inject({ method: 'PATCH', @@ -261,14 +267,16 @@ describe('F096: PATCH /block-state route guards (P1-1, P2-2)', () => { it('T20: returns 400 for non-interactive block', async () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'owner', - catId: 'opus', - content: 'card msg', - mentions: [], - timestamp: Date.now(), - extra: { rich: { v: 1, blocks: [{ id: 'c1', kind: 'card', v: 1, title: 'Card' }] } }, - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'owner', + catId: 'opus', + content: 'card msg', + mentions: [], + timestamp: Date.now(), + extra: { rich: { v: 1, blocks: [{ id: 'c1', kind: 'card', v: 1, title: 'Card' }] } }, + }), + ); const app = await createApp(store); const res = await app.inject({ method: 'PATCH', @@ -280,21 +288,23 @@ describe('F096: PATCH /block-state route guards (P1-1, P2-2)', () => { it('T21: returns 200 and calls updateExtra for valid owner + interactive block', async () => { const store = new MessageStore(); - const msg = store.append({ - userId: 'owner', - catId: 'opus', - content: 'pick', - mentions: [], - timestamp: Date.now(), - extra: { - rich: { - v: 1, - blocks: [ - { id: 'i1', kind: 'interactive', v: 1, interactiveType: 'select', options: [{ id: 'o1', label: 'A' }] }, - ], + const msg = store.append( + canonicalTestMessageInput({ + userId: 'owner', + catId: 'opus', + content: 'pick', + mentions: [], + timestamp: Date.now(), + extra: { + rich: { + v: 1, + blocks: [ + { id: 'i1', kind: 'interactive', v: 1, interactiveType: 'select', options: [{ id: 'o1', label: 'A' }] }, + ], + }, }, - }, - }); + }), + ); // Spy on updateExtra to verify persistence path is exercised let updateExtraCalled = false; const origUpdateExtra = store.updateExtra.bind(store); @@ -315,6 +325,7 @@ describe('F096: PATCH /block-state route guards (P1-1, P2-2)', () => { }); import { extractRichFromText } from '../dist/domains/cats/services/agents/routing/rich-block-extract.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('F096: extractRichFromText — interactive', () => { it('T14: extracts interactive block from cc_rich fence', () => { diff --git a/packages/api/test/route-parallel-a2a-failure-report.test.js b/packages/api/test/route-parallel-a2a-failure-report.test.js new file mode 100644 index 0000000000..bf9e3ed66e --- /dev/null +++ b/packages/api/test/route-parallel-a2a-failure-report.test.js @@ -0,0 +1,122 @@ +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +function failingService(catId) { + return { + async *invoke() { + yield { + type: 'error', + catId, + content: 'configured model unavailable', + error: 'configured model unavailable', + timestamp: Date.now(), + }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +function depsFor(services) { + let invocation = 0; + return { + services, + invocationDeps: { + registry: { + create: () => ({ invocationId: `inv-${++invocation}`, callbackToken: `tok-${invocation}` }), + verify: async () => ({ ok: false, reason: 'unknown_invocation' }), + }, + sessionManager: { + get: async () => undefined, + getOrCreate: async () => ({}), + resolveWorkingDirectory: () => '/tmp/test', + }, + threadStore: { + get: async () => null, + getParticipantsWithActivity: async () => [], + updateParticipantActivity: async () => {}, + }, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (input) => ({ id: `unbound-${invocation}`, ...input }), + getById: async () => null, + getRecent: () => [], + getMentionsFor: () => [], + getRecentMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + }, + draftStore: { delete: async () => {}, touch: async () => {}, upsert: async () => {} }, + socketManager: { broadcastToRoom() {} }, + }; +} + +describe('routeParallel failed A2A reporting', () => { + test('each failed lifecycle response reports durably to the exact predecessor', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const reports = []; + const services = { + bengal: failingService('bengal'), + lihua: failingService('lihua'), + }; + + for await (const _event of routeParallel( + depsFor(services), + ['bengal', 'lihua'], + 'ideate from Fable', + 'owner-1', + 'thread-a2a-parallel-failure', + { + ownerAuthProvenance: 'strict', + a2aTriggerMessageId: 'source-from-fable', + a2aCallerCatId: 'fable', + parentInvocationId: 'parent-invocation', + onLifecycleInvocationStarted: async ({ catId, invocationId, startedAt }) => ({ + responseMessageId: `response-${catId}`, + priorFrontierMessageId: null, + activeRun: { + threadId: 'thread-a2a-parallel-failure', + targetId: catId, + invocationId, + responseMessageId: `response-${catId}`, + inputEntryIds: [], + inputMessageIds: ['source-from-fable'], + privateInputEntryIds: [], + startedAt, + }, + }), + commitFailedA2AReport: async (input) => { + reports.push(input); + return { + id: input.responseMessageId, + ...input.message, + lifecycle: { kind: 'response', ...input.terminal }, + }; + }, + }, + )) { + // exhaust both failed children + } + + assert.deepEqual( + reports + .map(({ reporterCatId, predecessorCatId, responseMessageId }) => ({ + reporterCatId, + predecessorCatId, + responseMessageId, + })) + .sort((left, right) => left.reporterCatId.localeCompare(right.reporterCatId)), + [ + { reporterCatId: 'bengal', predecessorCatId: 'fable', responseMessageId: 'response-bengal' }, + { reporterCatId: 'lihua', predecessorCatId: 'fable', responseMessageId: 'response-lihua' }, + ], + ); + for (const report of reports) { + assert.equal(report.terminal.status, 'failed'); + assert.equal(report.message.content, 'configured model unavailable'); + } + }); +}); diff --git a/packages/api/test/route-parallel-freshness-commit.test.js b/packages/api/test/route-parallel-freshness-commit.test.js index 1ce9cd0095..cf0faf6dd3 100644 --- a/packages/api/test/route-parallel-freshness-commit.test.js +++ b/packages/api/test/route-parallel-freshness-commit.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -66,27 +67,31 @@ describe('F254 Phase E — route-parallel output commit', () => { const deliveryCursorStore = new DeliveryCursorStore(); const prompts = []; let invocationSequence = 0; - const source = await messageStore.append({ - userId: 'user-1', - catId: null, - content: '@codex-sol @fable-5 think independently', - mentions: ['codex-sol', 'fable-5'], - timestamp: 100, - threadId: 'thread-1', - }); - const sibling = await messageStore.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'SOL COMPLETE PARALLEL BODY sentinel-tail', - mentions: [], - origin: 'stream', - timestamp: 150, - threadId: 'thread-1', - extra: { - stream: { parallelBatchId: 'original-parallel-batch' }, - causal: { kind: 'invocation_reply', triggerMessageId: source.id }, - }, - }); + const source = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@codex-sol @fable-5 think independently', + mentions: ['codex-sol', 'fable-5'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + const sibling = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex-sol', + content: 'SOL COMPLETE PARALLEL BODY sentinel-tail', + mentions: [], + origin: 'stream', + timestamp: 150, + threadId: 'thread-1', + extra: { + stream: { parallelBatchId: 'original-parallel-batch' }, + causal: { kind: 'invocation_reply', triggerMessageId: source.id }, + }, + }), + ); const deps = { services: { 'fable-5': { @@ -130,14 +135,16 @@ describe('F254 Phase E — route-parallel output commit', () => { } assert.ok(!prompts[0].includes(sibling.content)); - const synthesis = await messageStore.append({ - userId: 'user-1', - catId: null, - content: '@fable-5 synthesize the two parallel answers', - mentions: ['fable-5'], - timestamp: 200, - threadId: 'thread-1', - }); + const synthesis = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '@fable-5 synthesize the two parallel answers', + mentions: ['fable-5'], + timestamp: 200, + threadId: 'thread-1', + }), + ); for await (const _event of routeParallel(deps, ['fable-5'], synthesis.content, 'user-1', 'thread-1', { currentUserMessageId: synthesis.id, thinkingMode: 'play', @@ -151,23 +158,27 @@ describe('F254 Phase E — route-parallel output commit', () => { it('publishes a known-stale parallel answer and queues one typed supplement', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - const unseen = await messageStore.append({ - userId: 'user-1', - catId: 'codex-sol', - content: 'late visible cat analysis', - mentions: [], - origin: 'stream', - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex-sol', + content: 'late visible cat analysis', + mentions: [], + origin: 'stream', + timestamp: 150, + threadId: 'thread-1', + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const enqueued = []; const deps = { @@ -223,22 +234,26 @@ describe('F254 Phase E — route-parallel output commit', () => { it('keeps a stale parallel answer published when the supplement queue is full', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const deps = { services: { opus: service('opus', 'stale parallel answer') }, @@ -286,23 +301,27 @@ describe('F254 Phase E — route-parallel output commit', () => { it('commits an adopted parallel supplement as a reply in its exact lineage', async () => { const messageStore = new MessageStore(); - const original = await messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'published original', - mentions: [], - timestamp: 100, - threadId: 'thread-1', - extra: { freshness: { kind: 'fresh', priorFrontierMessageId: null } }, - }); - const required = await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const original = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'published original', + mentions: [], + timestamp: 100, + threadId: 'thread-1', + extra: { freshness: { kind: 'fresh', priorFrontierMessageId: null } }, + }), + ); + const required = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const offered = await closureStore.offerSupplement({ lineageId: original.id, @@ -368,22 +387,26 @@ describe('F254 Phase E — route-parallel output commit', () => { it('publishes a side-effecting parallel answer and carries its replay fence into the supplement', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const enqueued = []; const deps = { @@ -448,22 +471,26 @@ describe('F254 Phase E — route-parallel output commit', () => { it('publishes a tool-only audit record and offers a read-only supplement', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - await messageStore.append({ - userId: 'user-1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const closureStore = new InMemoryFreshnessClosureStore(); const enqueued = []; const deps = { diff --git a/packages/api/test/route-parallel-mention-suppression.test.js b/packages/api/test/route-parallel-mention-suppression.test.js index a7a947bc25..422428b06e 100644 --- a/packages/api/test/route-parallel-mention-suppression.test.js +++ b/packages/api/test/route-parallel-mention-suppression.test.js @@ -107,13 +107,15 @@ describe('F167 L2: routeParallel mention suppression', () => { // Every persisted agent message in parallel mode must carry mentions=[]. // Otherwise MessageStore.getMentionsFor / getRecentMentionsFor will surface parallel @ messages. - const agentAppends = appendCalls.filter((c) => c.catId && c.origin === 'stream'); + // F117: persisted identity is the MessageFrom contract — agent sender lives in + // `from: { kind: 'agent', catId }` (route-parallel.ts stream append), not a top-level catId. + const agentAppends = appendCalls.filter((c) => c.from?.kind === 'agent' && c.origin === 'stream'); assert.ok(agentAppends.length >= 2, 'expected at least two agent append calls'); for (const call of agentAppends) { assert.deepStrictEqual( call.mentions, [], - `parallel-mode agent message must persist mentions=[], got ${JSON.stringify(call.mentions)} for catId=${call.catId}`, + `parallel-mode agent message must persist mentions=[], got ${JSON.stringify(call.mentions)} for catId=${call.from?.catId}`, ); } }); diff --git a/packages/api/test/route-parallel-parent-invocation-id.test.js b/packages/api/test/route-parallel-parent-invocation-id.test.js index c1aa127c6a..5b833c737a 100644 --- a/packages/api/test/route-parallel-parent-invocation-id.test.js +++ b/packages/api/test/route-parallel-parent-invocation-id.test.js @@ -201,7 +201,9 @@ describe('F194 Phase Z2: route-parallel must propagate parentInvocationId to inv yielded.push(message); } - const agentAppends = appendCalls.filter((call) => call.catId && call.origin === 'stream'); + // F117: agent sender identity is persisted as `from: { kind: 'agent', catId }` + // (MessageFrom contract), not a top-level catId field. + const agentAppends = appendCalls.filter((call) => call.from?.kind === 'agent' && call.origin === 'stream'); assert.equal(agentAppends.length, 2, 'one persisted message per cat'); for (const call of agentAppends) { assert.equal( diff --git a/packages/api/test/route-parallel-warning-persistence.test.js b/packages/api/test/route-parallel-warning-persistence.test.js index ada0207ede..e46563458f 100644 --- a/packages/api/test/route-parallel-warning-persistence.test.js +++ b/packages/api/test/route-parallel-warning-persistence.test.js @@ -10,6 +10,7 @@ function createTextWithWarningService(catId) { catId, content: JSON.stringify({ type: 'warning', + presentation: 'user_action_required', message: '当前 opencode/CodeAgent 适配器未返回 token 用量,自动 handoff 无法按上下文比例触发。', }), timestamp: Date.now(), @@ -27,6 +28,7 @@ function createWarningOnlyService(catId) { catId, content: JSON.stringify({ type: 'warning', + presentation: 'user_action_required', message: '当前 opencode/CodeAgent 适配器未返回 token 用量,自动 handoff 无法按上下文比例触发。', }), timestamp: Date.now(), @@ -118,12 +120,17 @@ describe('route-parallel warning persistence (issue #1208 P2)', () => { assert.ok(yieldedTypes.includes('text'), 'text must reach the live stream'); assert.ok(yieldedTypes.includes('system_info'), 'warning must reach the live stream'); - assert.ok(appendCalls.some((msg) => msg.catId === 'opus' && msg.content === 'Parallel result.')); + assert.ok( + appendCalls.some( + (msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus' && msg.content === 'Parallel result.', + ), + ); const warningAppend = appendCalls.find((msg) => msg.source?.connector === 'system-warning'); assert.ok(warningAppend, 'warning must survive refresh in parallel mode'); assert.equal(warningAppend.userId, 'system'); - assert.equal(warningAppend.catId, null); + assert.deepEqual(warningAppend.from, { kind: 'system', service: 'system-info-warning' }); + assert.equal(warningAppend.catId, undefined); assert.match(warningAppend.content, /未返回 token 用量/); assert.equal(warningAppend.source.meta.presentation, 'system_notice'); assert.equal(warningAppend.source.meta.noticeTone, 'warning'); diff --git a/packages/api/test/route-serial-a2a-tracker.test.js b/packages/api/test/route-serial-a2a-tracker.test.js deleted file mode 100644 index 026e38d482..0000000000 --- a/packages/api/test/route-serial-a2a-tracker.test.js +++ /dev/null @@ -1,502 +0,0 @@ -import './helpers/setup-cat-registry.js'; -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -function createMockDeps(services) { - let counter = 0; - return { - services, - invocationDeps: { - registry: { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { - get: async () => undefined, - getOrCreate: async () => ({}), - resolveWorkingDirectory: () => '/tmp/test', - }, - apiUrl: 'http://127.0.0.1:3004', - }, - messageStore: { - append: async (msg) => ({ - id: `msg-${++counter}`, - userId: msg.userId ?? '', - catId: msg.catId ?? null, - content: msg.content ?? '', - mentions: [], - timestamp: msg.timestamp ?? Date.now(), - }), - getById: async () => null, - getRecent: async () => [], - getMentionsFor: async () => [], - getBefore: async () => [], - getByThread: async () => [], - getByThreadAfter: async () => [], - getByThreadBefore: async () => [], - }, - }; -} - -describe('routeSerial A2A tracker bridge', () => { - it('uses route-terminal completion to release a canceled dynamic A2A seal fence', async () => { - const { createA2ASlotTrackingBridge } = await import( - '../dist/domains/cats/services/agents/routing/route-helpers.js' - ); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const tracker = new InvocationTracker(); - const parent = tracker.startAll('thread-a2a-cancel', ['opus'], 'user-a', 'inv-a2a'); - const bridge = createA2ASlotTrackingBridge(tracker, parent, 'inv-a2a'); - assert.equal(bridge.trackA2ASlot('thread-a2a-cancel', 'codex', 'user-a', parent), true); - tracker.cancel('thread-a2a-cancel', 'codex', 'user-a', 'user_cancel'); - assert.equal(tracker.guardSessionSeal('thread-a2a-cancel', 'codex').acquired, false); - - bridge.completeA2ASlots('thread-a2a-cancel', ['codex'], parent); - - const guard = tracker.guardSessionSeal('thread-a2a-cancel', 'codex'); - assert.equal(guard.acquired, true, 'route-finally must prove teardown for dynamic A2A tombstones'); - guard.release(); - }); - - it('keeps thread-level invocation tracking active after first cat hands off to A2A target', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - - const threadId = 'thread-a2a-tracker'; - const userId = 'user-a'; - const tracker = new InvocationTracker(); - const controller = tracker.startAll(threadId, ['opus'], userId); - let sawOpusDone = false; - - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '@codex\n请接手继续', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - yield { type: 'text', catId: 'codex', content: '我接到了', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - for await (const msg of routeSerial(deps, ['opus'], 'start', userId, threadId, { - signal: controller.signal, - invocationController: controller, - trackA2ASlot: (tid, catId, uid, ctrl) => { - return tracker.trackExternalSlot(tid, catId, ctrl, uid, [catId]); - }, - completeA2ASlots: (tid, catIds, ctrl) => { - for (const catId of catIds) tracker.completeSlot(tid, catId, ctrl); - }, - })) { - if (msg.type === 'done' && msg.catId) { - tracker.completeSlot(threadId, msg.catId, controller); - if (msg.catId === 'opus') { - sawOpusDone = true; - assert.equal( - tracker.has(threadId), - true, - 'A2A child slot must keep the thread busy after the parent cat completes', - ); - assert.equal(tracker.has(threadId, 'codex'), true, 'A2A target slot must be tracked before it executes'); - } - } - } - - assert.equal(sawOpusDone, true, 'test must exercise the handoff point'); - assert.equal(tracker.has(threadId), false, 'all slots must be cleaned up after the chain finishes'); - }); - - it('re-registers an A2A target when the same cat is enqueued again after completion', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - - const threadId = 'thread-a2a-repeated-target'; - const userId = 'user-a'; - const tracker = new InvocationTracker(); - const controller = tracker.startAll(threadId, ['opus'], userId); - let opusDoneCount = 0; - let codexTrackCount = 0; - - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '@codex\n请继续', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - yield { type: 'text', catId: 'codex', content: '@opus\n请复核', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - for await (const msg of routeSerial(deps, ['opus'], 'start', userId, threadId, { - signal: controller.signal, - invocationController: controller, - maxA2ADepth: 3, - trackA2ASlot: (tid, catId, uid, ctrl) => { - if (catId === 'codex') codexTrackCount++; - return tracker.trackExternalSlot(tid, catId, ctrl, uid, [catId]); - }, - completeA2ASlots: (tid, catIds, ctrl) => { - for (const catId of catIds) tracker.completeSlot(tid, catId, ctrl); - }, - })) { - if (msg.type === 'done' && msg.catId) { - tracker.completeSlot(threadId, msg.catId, controller); - if (msg.catId === 'opus') { - opusDoneCount++; - assert.equal(tracker.has(threadId), true, `thread must stay busy after opus completion #${opusDoneCount}`); - assert.equal(tracker.has(threadId, 'codex'), true, 'next codex slot must be tracked before it runs'); - } - } - } - - assert.equal(opusDoneCount, 2, 'test must exercise a repeated opus→codex handoff'); - assert.equal(codexTrackCount, 2, 'codex must be re-tracked for its second worklist entry'); - assert.equal(tracker.has(threadId), false, 'all repeated A2A slots must be cleaned up after the chain finishes'); - }); - - it('tracks callback-pushed A2A targets when the parent turn emits no text', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { pushToWorklist } = await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - - const threadId = 'thread-a2a-callback-no-text'; - const userId = 'user-a'; - const tracker = new InvocationTracker(); - const controller = tracker.startAll(threadId, ['opus'], userId); - let sawOpusDone = false; - - const deps = createMockDeps({ - opus: { - async *invoke() { - const result = pushToWorklist(threadId, ['codex'], 'opus'); - assert.deepEqual(result.added, ['codex'], 'callback A2A push must add codex to the active worklist'); - yield { - type: 'tool_use', - catId: 'opus', - toolName: 'cat_cafe_post_message', - toolInput: { targetCats: ['codex'] }, - timestamp: Date.now(), - }; - yield { type: 'tool_result', catId: 'opus', content: 'queued codex', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - yield { type: 'text', catId: 'codex', content: 'callback handoff received', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - for await (const msg of routeSerial(deps, ['opus'], 'start', userId, threadId, { - signal: controller.signal, - invocationController: controller, - trackA2ASlot: (tid, catId, uid, ctrl) => { - return tracker.trackExternalSlot(tid, catId, ctrl, uid, [catId]); - }, - completeA2ASlots: (tid, catIds, ctrl) => { - for (const catId of catIds) tracker.completeSlot(tid, catId, ctrl); - }, - })) { - if (msg.type === 'done' && msg.catId) { - tracker.completeSlot(threadId, msg.catId, controller); - if (msg.catId === 'opus') { - sawOpusDone = true; - assert.equal( - tracker.has(threadId), - true, - 'callback A2A child slot must keep the thread busy after a no-text parent completes', - ); - assert.equal(tracker.has(threadId, 'codex'), true, 'callback A2A target slot must be tracked before it runs'); - } - } - } - - assert.equal(sawOpusDone, true, 'test must exercise the callback-only handoff point'); - assert.equal(tracker.has(threadId), false, 'callback A2A slot must be cleaned up after the chain finishes'); - }); - - it('defers an A2A return instead of invoking a cat owned by another active route', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - - const threadId = 'thread-a2a-active-external-owner'; - const userId = 'user-a'; - const tracker = new InvocationTracker(); - const directOwnerBatch = tracker.startAll(threadId, ['codex'], userId, 'direct-sol'); - const routeBatch = tracker.startAll(threadId, ['opus'], userId, 'queued-opus'); - const directOwnerController = tracker.getController(threadId, 'codex'); - const queue = new InvocationQueue(); - let codexInvocations = 0; - - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '@codex\n复核完成,回球给你', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - codexInvocations++; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - for await (const msg of routeSerial(deps, ['opus'], 'review', userId, threadId, { - signal: routeBatch.signal, - invocationController: routeBatch, - deferA2AEnqueue: (entry) => queue.enqueue(entry), - trackA2ASlot: (tid, catId, uid, ctrl) => tracker.trackExternalSlot(tid, catId, ctrl, uid, [catId], 'queued-opus'), - completeA2ASlots: (tid, catIds, ctrl) => { - for (const catId of catIds) tracker.completeSlot(tid, catId, ctrl); - }, - })) { - if (msg.type === 'done' && msg.catId === 'opus') { - tracker.completeSlot(threadId, 'opus', routeBatch); - } - } - - assert.equal(codexInvocations, 0, 'the A2A return must not start a second Codex invocation'); - const deferred = queue.list(threadId, userId); - assert.equal(deferred.length, 1, 'the A2A return must be durably deferred instead of dropped'); - assert.deepEqual(deferred[0].targetCats, ['codex']); - assert.equal(deferred[0].sourceCategory, 'a2a'); - assert.equal(deferred[0].autoExecute, true); - assert.equal(deferred[0].messageId, 'msg-2', 'the persisted review reply must remain the queue trigger'); - assert.equal(deferred[0].a2aTriggerMessageId, 'msg-2'); - assert.equal( - tracker.getController(threadId, 'codex'), - directOwnerController, - 'the original direct invocation must keep ownership of its slot', - ); - assert.equal(directOwnerBatch.signal.aborted, false, 'deferring the return must not abort the original invocation'); - }); - - it('fails closed when a route ingress omits the A2A slot-admission bridge', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - - let codexInvocations = 0; - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '@codex\n复核完成,回球给你', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - codexInvocations++; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - await assert.rejects(async () => { - for await (const _ of routeSerial(deps, ['opus'], 'review', 'user-a', 'thread-a2a-bridge-missing', {})) { - // drain - } - }, /A2A slot admission unavailable: route bridge missing/); - assert.equal(codexInvocations, 0, 'a missing bridge must never degrade to an untracked second invocation'); - }); - - it('fails the route when an occupied-slot handoff has no durable queue', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - - const controller = new AbortController(); - let codexInvocations = 0; - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '@codex\n复核完成,回球给你', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - codexInvocations++; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - await assert.rejects(async () => { - for await (const _ of routeSerial(deps, ['opus'], 'review', 'user-a', 'thread-a2a-custody-rejected', { - signal: controller.signal, - invocationController: controller, - trackA2ASlot: () => false, - })) { - // drain - } - }, /durable A2A custody unavailable/); - assert.equal(codexInvocations, 0, 'custody failure must fail closed before a second Codex invocation'); - }); - - it('fails the route when durable enqueue explicitly rejects an occupied-slot handoff', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - - const controller = new AbortController(); - let codexInvocations = 0; - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '@codex\n复核完成,回球给你', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - codexInvocations++; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - await assert.rejects(async () => { - for await (const _ of routeSerial(deps, ['opus'], 'review', 'user-a', 'thread-a2a-custody-full', { - signal: controller.signal, - invocationController: controller, - deferA2AEnqueue: () => ({ outcome: 'full' }), - trackA2ASlot: () => false, - })) { - // drain - } - }, /durable A2A custody unavailable.*enqueue outcome full/); - assert.equal(codexInvocations, 0, 'rejected custody must not invoke the occupied target'); - }); - - it('fails the route when a callback-pushed occupied target has no recoverable trigger content', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const { pushToWorklist } = await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - - const controller = new AbortController(); - let enqueueCalls = 0; - let codexInvocations = 0; - const deps = createMockDeps({ - opus: { - async *invoke() { - const result = pushToWorklist('thread-a2a-custody-no-content', ['codex'], 'opus'); - assert.deepEqual(result.added, ['codex']); - yield { - type: 'tool_use', - catId: 'opus', - toolName: 'cat_cafe_post_message', - toolInput: { targetCats: ['codex'] }, - timestamp: Date.now(), - }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - codexInvocations++; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - - await assert.rejects(async () => { - for await (const _ of routeSerial(deps, ['opus'], 'review', 'user-a', 'thread-a2a-custody-no-content', { - signal: controller.signal, - invocationController: controller, - deferA2AEnqueue: () => { - enqueueCalls++; - return { outcome: 'enqueued' }; - }, - trackA2ASlot: () => false, - })) { - // drain - } - }, /durable A2A custody unavailable/); - assert.equal(enqueueCalls, 0, 'missing trigger content must not enqueue an unusable placeholder'); - assert.equal(codexInvocations, 0, 'missing trigger content must fail closed before invoking the occupied target'); - }); - - it('trackExternalSlot purges a canceled tombstone instead of preserving its aborted controller', async () => { - const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); - - const threadId = 'thread-tombstone-retrack'; - const userId = 'user-a'; - const tracker = new InvocationTracker(); - - // Prior turn: codex started then cancelled → 'canceled' tombstone. getController() intentionally - // still returns the now-ABORTED controller (pre-invoke cancel semantics, InvocationTracker.ts:290). - tracker.startAll(threadId, ['codex'], userId); - tracker.cancel(threadId, 'codex', userId, 'user_cancel'); - assert.equal( - tracker.getController(threadId, 'codex')?.signal.aborted, - true, - 'precondition: a canceled tombstone exposes an aborted controller', - ); - - // A2A handoff re-tracks the target via trackExternalSlot — the exact bridge route-serial uses - // (trackA2ASlot → trackExternalSlot). The passed controller is the new turn's batch gate. - const batchGate = new AbortController(); - tracker.trackExternalSlot(threadId, 'codex', batchGate, userId, ['codex']); - - // BUG: trackExternalSlot returns idempotently on the un-expired tombstone, so getController keeps - // handing route-serial the aborted signal → the worklist loop skips codex at the top (route-serial - // line 429). After the fix, re-track must replace the tombstone with a fresh active slot. - assert.equal( - tracker.getController(threadId, 'codex')?.signal.aborted, - false, - 'A2A re-track must replace the canceled tombstone with a fresh active slot (controller not aborted)', - ); - }); - - it('ball.handed 记 original routed target + A2A target(云端 P1-1:不只 A2A handoff)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const recorded = []; - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '@codex\n请接手', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: { - async *invoke() { - yield { type: 'text', catId: 'codex', content: '我接到了', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }, - }); - // ball-custody ingest spy - deps.ballCustody = { - record: async (event) => { - recorded.push(event); - }, - }; - - // currentUserMessageId 提供 original target 的 messageId 来源(用户消息 id)。 - const controller = new AbortController(); - for await (const _ of routeSerial(deps, ['opus'], 'start', 'user-a', 'thread-handed-p1', { - currentUserMessageId: 'user-msg-1', - invocationController: controller, - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - })) { - // drain - } - - const handedTo = recorded.filter((e) => e.kind === 'ball.handed').map((e) => e.payload.toCatId); - // 修复前:emit 只在 A2A handoff loop(`if (wi < targetCats.length) continue` skip original)→ original - // opus 漏记 ball.handed。修复后:worklist 主循环接球时刻统一 emit → opus(original user→cat)+ - // codex(A2A cat→cat)都记。 - assert.ok(handedTo.includes('opus'), 'original routed target opus 必须记 ball.handed(P1-1 根因)'); - assert.ok(handedTo.includes('codex'), 'A2A target codex 也记 ball.handed'); - }); -}); diff --git a/packages/api/test/route-serial-callback-dedup.test.js b/packages/api/test/route-serial-callback-dedup.test.js index 77af54f3b1..e33cf1bde7 100644 --- a/packages/api/test/route-serial-callback-dedup.test.js +++ b/packages/api/test/route-serial-callback-dedup.test.js @@ -432,8 +432,8 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { } assert.equal( duplicateTargetInvocations, - 1, - 'failed callback admission must leave the line-start target eligible for the serial carrier', + 0, + 'failed callback admission must not resurrect the removed recursive serial carrier', ); } finally { catRegistry.reset(); @@ -451,7 +451,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { yielded.push(msg); } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'replace_final should keep the callback as the sole durable response'); }); @@ -465,7 +467,11 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((message) => message.origin === 'stream' && message.catId === 'opus'); + // F117: AppendMessageInput carries the sender as MessageFrom (`from`), not a + // top-level `catId` projection — ef94412a5 "make MessageFrom the sender truth". + const streamAppends = appendCalls.filter( + (message) => message.origin === 'stream' && message.from?.kind === 'agent' && message.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'the persisted terminal ACK must remain the sole durable response'); assert.deepEqual( persistenceContext.persistedOutputMessageIds, @@ -483,7 +489,11 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((message) => message.origin === 'stream' && message.catId === 'opus'); + // F117: AppendMessageInput carries the sender as MessageFrom (`from`), not a + // top-level `catId` projection — ef94412a5 "make MessageFrom the sender truth". + const streamAppends = appendCalls.filter( + (message) => message.origin === 'stream' && message.from?.kind === 'agent' && message.from.catId === 'opus', + ); assert.equal(streamAppends.length, 1, 'an unproven terminal ACK must fail open and preserve the provider final'); }); @@ -498,7 +508,11 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((message) => message.origin === 'stream' && message.catId === 'opus'); + // F117: AppendMessageInput carries the sender as MessageFrom (`from`), not a + // top-level `catId` projection — ef94412a5 "make MessageFrom the sender truth". + const streamAppends = appendCalls.filter( + (message) => message.origin === 'stream' && message.from?.kind === 'agent' && message.from.catId === 'opus', + ); assert.equal(streamAppends.length, 1, 'the callback must not suppress a later independent final answer'); assert.equal(streamAppends[0].content, 'Detailed final answer that must remain durable after the callback.'); assert.ok( @@ -524,7 +538,11 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { yielded.push(msg); } - const streamAppends = appendCalls.filter((message) => message.origin === 'stream' && message.catId === 'opus'); + // F117: AppendMessageInput carries the sender as MessageFrom (`from`), not a + // top-level `catId` projection — ef94412a5 "make MessageFrom the sender truth". + const streamAppends = appendCalls.filter( + (message) => message.origin === 'stream' && message.from?.kind === 'agent' && message.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'replace_final must not persist an empty tool-only stream record'); assert.equal( yielded.filter((message) => message.type === 'system_info' && message.content?.includes('silent_completion')) @@ -573,7 +591,11 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((message) => message.origin === 'stream' && message.catId === 'opus'); + // F117: AppendMessageInput carries the sender as MessageFrom (`from`), not a + // top-level `catId` projection — ef94412a5 "make MessageFrom the sender truth". + const streamAppends = appendCalls.filter( + (message) => message.origin === 'stream' && message.from?.kind === 'agent' && message.from.catId === 'opus', + ); assert.equal(streamAppends.length, 1, 'replacement without a durable callback id must fail open to the final'); assert.equal(streamAppends[0].content, 'Provider fallback must remain durable.'); }); @@ -610,7 +632,11 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((message) => message.origin === 'stream' && message.catId === 'opus'); + // F117: AppendMessageInput carries the sender as MessageFrom (`from`), not a + // top-level `catId` projection — ef94412a5 "make MessageFrom the sender truth". + const streamAppends = appendCalls.filter( + (message) => message.origin === 'stream' && message.from?.kind === 'agent' && message.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'error finalization must not append a second empty stream record'); assert.equal(augmentCalls.length, 1, 'error-path tool metadata should attach to the canonical callback'); assert.equal(augmentCalls[0].id, 'callback-before-error'); @@ -629,7 +655,11 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((message) => message.origin === 'stream' && message.catId === 'opus'); + // F117: AppendMessageInput carries the sender as MessageFrom (`from`), not a + // top-level `catId` projection — ef94412a5 "make MessageFrom the sender truth". + const streamAppends = appendCalls.filter( + (message) => message.origin === 'stream' && message.from?.kind === 'agent' && message.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'the later matched replace_final callback must suppress the provider final'); assert.deepEqual( persistenceContext.persistedOutputMessageIds, @@ -654,7 +684,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'callback path must remain the only user-visible bubble'); assert.equal(augmentCalls.length, 1, 'callback message should receive stream-only metadata'); @@ -698,7 +730,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'prefixed callback success must not create a duplicate stream bubble'); assert.equal(augmentCalls.length, 1, 'prefixed callback result should still augment callback message'); @@ -725,7 +759,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal( streamAppends.length, 0, @@ -743,7 +779,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { yielded.push(msg); } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 1, 'should persist stream output normally when no callback post'); assert.ok(streamAppends[0].content.includes('Normal reply'), 'persisted content should match stream text'); }); @@ -786,7 +824,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 1, 'should persist stream output when callback failed'); }); @@ -825,7 +865,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'unrelated tool_result must not clear pending callback confirmation'); }); @@ -871,7 +913,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 1, 'unrelated ok tool_result must not suppress stream persistence'); }); @@ -917,7 +961,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 0, 'unlabeled callback result should suppress duplicate stream persistence'); }); @@ -963,7 +1009,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal( streamAppends.length, 1, @@ -1013,7 +1061,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal( streamAppends.length, 1, @@ -1063,7 +1113,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal( streamAppends.length, 1, @@ -1113,7 +1165,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal(streamAppends.length, 1, 'ambiguous ok tool_result must not suppress stream persistence'); }); @@ -1154,7 +1208,9 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { // drain } - const streamAppends = appendCalls.filter((m) => m.origin === 'stream' && m.catId === 'opus'); + const streamAppends = appendCalls.filter( + (m) => m.origin === 'stream' && m.from?.kind === 'agent' && m.from.catId === 'opus', + ); assert.equal( streamAppends.length, 1, diff --git a/packages/api/test/route-serial-error-persistence.test.js b/packages/api/test/route-serial-error-persistence.test.js index 312eff3637..6eed608240 100644 --- a/packages/api/test/route-serial-error-persistence.test.js +++ b/packages/api/test/route-serial-error-persistence.test.js @@ -21,6 +21,17 @@ function createErrorService(catId, errorMsg) { }; } +function createMultipleErrorService(catId, errorMessages) { + return { + async *invoke() { + for (const error of errorMessages) { + yield { type: 'error', catId, error, timestamp: Date.now() }; + } + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + /** F212 Phase B (云端 codex P2-8): emit error with structured cliDiagnostics on metadata. */ function createCliErrorWithDiagnosticsService(catId, errorMsg, cliDiagnostics) { return { @@ -60,6 +71,7 @@ function createMockDeps(services, appendCalls) { }, sessionManager: { getOrCreate: async () => ({}), + get: async () => null, resolveWorkingDirectory: () => '/tmp/test', }, threadStore: null, @@ -82,6 +94,7 @@ function createMockDeps(services, appendCalls) { getRecent: () => [], getMentionsFor: () => [], getBefore: () => [], + getById: async () => null, getByThread: () => [], getByThreadAfter: () => [], getByThreadBefore: () => [], @@ -90,6 +103,170 @@ function createMockDeps(services, appendCalls) { } describe('route-serial error persistence (F5 reload)', () => { + async function lifecycleResponseStoreFor(input) { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const response = await store.append({ + from: { kind: 'agent', catId: input.catId }, + userId: input.userId, + content: '', + mentions: [], + origin: 'stream', + timestamp: input.startedAt, + threadId: input.threadId, + lifecycle: { + kind: 'response', + orderKey: `${input.startedAt}:${input.invocationId}`, + invocationId: input.invocationId, + targetId: input.catId, + inputEntryIds: ['entry-1'], + inputMessageIds: [], + status: 'processing', + startedAt: input.startedAt, + }, + }); + return { store, response }; + } + + for (const strategy of ['serial', 'parallel']) { + it(`${strategy}: persists an owned provider failure in the fixed member response without a system error row`, async () => { + const route = + strategy === 'serial' + ? (await import('../dist/domains/cats/services/agents/routing/route-serial.js')).routeSerial + : (await import('../dist/domains/cats/services/agents/routing/route-parallel.js')).routeParallel; + const deps = createMockDeps( + { + gemini: createMultipleErrorService('gemini', [ + 'init_failure: first provider attempt failed', + 'init_failure: second provider attempt failed', + ]), + }, + [], + ); + let lifecycleStore; + let responseMessageId; + const yielded = []; + + for await (const event of route(deps, ['gemini'], 'hello', 'user1', 'thread1', { + onLifecycleInvocationStarted: async (input) => { + const admission = await lifecycleResponseStoreFor(input); + lifecycleStore = admission.store; + deps.messageStore = admission.store; + responseMessageId = admission.response.id; + return { responseMessageId: admission.response.id, priorFrontierMessageId: null }; + }, + })) { + yielded.push(event); + } + + assert.ok(lifecycleStore, 'lifecycle store must be admitted before provider execution'); + const messages = lifecycleStore.getByThread('thread1', 100, 'user1'); + const response = messages.find((message) => message.id === responseMessageId); + assert.equal(response?.from?.kind, 'agent'); + assert.equal(response?.from?.catId, 'gemini'); + assert.equal(response?.lifecycle?.kind, 'response'); + assert.equal(response?.lifecycle?.status, 'failed'); + assert.equal( + response?.content, + 'init_failure: first provider attempt failed\ninit_failure: second provider attempt failed', + ); + assert.equal( + messages.some((message) => message.from?.kind === 'system' && message.from.service === 'agent-error'), + false, + ); + assert.equal( + yielded.some((message) => message.type === 'error'), + false, + 'a response-owned failure must update its canonical response instead of creating a live system error surface', + ); + }); + + it(`${strategy}: closes a canceled run on the fixed member response without a silent-completion notice`, async () => { + const route = + strategy === 'serial' + ? (await import('../dist/domains/cats/services/agents/routing/route-serial.js')).routeSerial + : (await import('../dist/domains/cats/services/agents/routing/route-parallel.js')).routeParallel; + const controller = new AbortController(); + const deps = createMockDeps( + { + gemini: { + async *invoke() { + controller.abort('user_cancel'); + yield { type: 'done', catId: 'gemini', timestamp: Date.now() }; + }, + }, + }, + [], + ); + let lifecycleStore; + let responseMessageId; + const yielded = []; + + for await (const message of route(deps, ['gemini'], 'hello', 'user1', 'thread1', { + signal: controller.signal, + onLifecycleInvocationStarted: async (input) => { + const admission = await lifecycleResponseStoreFor(input); + lifecycleStore = admission.store; + deps.messageStore = admission.store; + responseMessageId = admission.response.id; + return { responseMessageId: admission.response.id, priorFrontierMessageId: null }; + }, + })) { + yielded.push(message); + } + + assert.ok(lifecycleStore, 'lifecycle store must be admitted before cancellation'); + const response = lifecycleStore.getById(responseMessageId); + assert.equal(response?.from?.kind, 'agent'); + assert.equal(response?.lifecycle?.kind, 'response'); + assert.equal(response?.lifecycle?.status, 'canceled'); + assert.equal( + yielded.some( + (message) => message.type === 'system_info' && String(message.content).includes('silent_completion'), + ), + false, + ); + }); + } + + it('serial: reports a failed A2A response to the exact predecessor and suppresses recursive report carriers', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const deps = createMockDeps({ bengal: createErrorService('bengal', 'model unavailable') }, []); + const reports = []; + const commonOptions = { + a2aTriggerMessageId: 'source-from-opus', + a2aCallerCatId: 'opus', + onLifecycleInvocationStarted: async (input) => { + const admission = await lifecycleResponseStoreFor(input); + deps.messageStore = admission.store; + return { responseMessageId: admission.response.id, priorFrontierMessageId: null }; + }, + commitFailedA2AReport: async (input) => { + reports.push(input); + const current = await deps.messageStore.getById(input.responseMessageId); + return { ...current, content: input.message.content, lifecycle: { ...current.lifecycle, ...input.terminal } }; + }, + }; + + for await (const _ of routeSerial(deps, ['bengal'], 'please handle this', 'user1', 'thread1', commonOptions)) { + void _; + } + assert.equal(reports.length, 1); + assert.equal(reports[0].reporterCatId, 'bengal'); + assert.equal(reports[0].predecessorCatId, 'opus'); + assert.equal(reports[0].terminal.status, 'failed'); + assert.match(reports[0].message.content, /model unavailable/); + + reports.length = 0; + for await (const _ of routeSerial(deps, ['bengal'], 'failure report carrier', 'user1', 'thread1', { + ...commonOptions, + a2aFailureReport: true, + })) { + void _; + } + assert.equal(reports.length, 0, 'a failed report carrier must not recurse back to its reporter'); + }); + it('persists error-only response as system message with Error: prefix', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const appendCalls = []; @@ -104,7 +281,7 @@ describe('route-serial error persistence (F5 reload)', () => { } // Should have one append: the system error message - const errorAppend = appendCalls.find((m) => m.userId === 'system' && m.catId === null); + const errorAppend = appendCalls.find((m) => m.from?.kind === 'system' && m.from.service === 'agent-error'); assert.ok(errorAppend, 'should persist a system error message'); assert.ok( errorAppend.content.startsWith('Error:'), @@ -113,7 +290,7 @@ describe('route-serial error persistence (F5 reload)', () => { assert.ok(errorAppend.content.includes('stream_idle_stall'), 'system error should contain the original error text'); // No cat message with [错误] prefix should exist - const catAppend = appendCalls.find((m) => m.catId === 'gemini'); + const catAppend = appendCalls.find((m) => m.from?.kind === 'agent' && m.from.catId === 'gemini'); assert.equal(catAppend, undefined, 'error-only should NOT persist as cat message'); }); @@ -137,13 +314,13 @@ describe('route-serial error persistence (F5 reload)', () => { } // Cat message should have the text content WITHOUT [错误] contamination - const catAppend = appendCalls.find((m) => m.catId === 'gemini'); + const catAppend = appendCalls.find((m) => m.from?.kind === 'agent' && m.from.catId === 'gemini'); assert.ok(catAppend, 'should persist cat text message'); assert.ok(!catAppend.content.includes('[错误]'), 'cat message should NOT contain [错误] prefix'); assert.ok(catAppend.content.includes('partial response'), 'cat message should contain the actual response text'); // System error message should also be persisted - const errorAppend = appendCalls.find((m) => m.userId === 'system' && m.catId === null); + const errorAppend = appendCalls.find((m) => m.from?.kind === 'system' && m.from.service === 'agent-error'); assert.ok(errorAppend, 'should persist a separate system error message'); assert.ok(errorAppend.content.startsWith('Error:'), 'system error should start with Error: prefix'); assert.ok(errorAppend.content.includes('model_capacity'), 'system error should contain the error code'); @@ -186,7 +363,7 @@ describe('route-serial error persistence (F5 reload)', () => { void _; } - const errorAppend = appendCalls.find((m) => m.userId === 'system' && m.catId === null); + const errorAppend = appendCalls.find((m) => m.from?.kind === 'system' && m.from.service === 'agent-error'); assert.ok(errorAppend, 'should persist a system error message'); assert.ok(errorAppend.metadata, 'append must include metadata for hydration'); assert.deepEqual( @@ -207,7 +384,7 @@ describe('route-serial error persistence (F5 reload)', () => { void _; } - const errorAppend = appendCalls.find((m) => m.userId === 'system' && m.catId === null); + const errorAppend = appendCalls.find((m) => m.from?.kind === 'system' && m.from.service === 'agent-error'); assert.ok(errorAppend, 'should persist a system error message'); assert.equal(errorAppend.metadata, undefined, 'metadata must be absent when no cliDiagnostics'); }); @@ -277,13 +454,13 @@ describe('route-serial error persistence (F5 reload)', () => { assert.equal(projectionCloseCount, 1, 'custody projection still records the failed turn'); assert.equal(invokeCount, 1, 'provider-error turns must not start a stop-gate child'); - const catAppend = appendCalls.find((m) => m.catId === 'codex'); + const catAppend = appendCalls.find((m) => m.from?.kind === 'agent' && m.from.catId === 'codex'); assert.ok(catAppend, 'partial cat text should still persist'); assert.ok( catAppend.content.includes('Partial answer'), 'cat message should contain the partial pre-failure content', ); - const errorAppend = appendCalls.find((m) => m.userId === 'system' && m.catId === null); + const errorAppend = appendCalls.find((m) => m.from?.kind === 'system' && m.from.service === 'agent-error'); assert.ok(errorAppend, 'terminal failure must persist as system error message'); assert.ok(errorAppend.content.startsWith('Error:'), 'system error should carry Error: prefix for F5 rehydration'); assert.equal( @@ -349,15 +526,15 @@ describe('route-serial error persistence (F5 reload)', () => { 0, 'cloud R3 P1 fix contract: partial @mention from a failed turn MUST NOT dispatch downstream cats', ); - const catAppend = appendCalls.find((m) => m.catId === 'codex'); + const catAppend = appendCalls.find((m) => m.from?.kind === 'agent' && m.from.catId === 'codex'); assert.ok(catAppend?.content.includes('Partial handoff'), 'partial content still persisted'); - const errorAppend = appendCalls.find((m) => m.userId === 'system' && m.catId === null); + const errorAppend = appendCalls.find((m) => m.from?.kind === 'system' && m.from.service === 'agent-error'); assert.ok(errorAppend, 'terminal failure still surfaces as system error'); }); - // Structural regression: partial output from a failed provider invocation must - // never enqueue a downstream cat. - it('F212 cloud R3 P1 structural: A2A enqueue remains gated on provider success', async () => { + // Structural regression: only a completed lifecycle response may commit its + // durable A2A wake admission. + it('F212 cloud R3 P1 structural: lifecycle A2A admission remains gated on provider success', async () => { const { readFileSync } = await import('node:fs'); const { fileURLToPath } = await import('node:url'); const { dirname, resolve } = await import('node:path'); @@ -365,8 +542,8 @@ describe('route-serial error persistence (F5 reload)', () => { const source = readFileSync(resolve(here, '../src/domains/cats/services/agents/routing/route-serial.ts'), 'utf8'); const cloudR3P1Match = source.match( - /a2aMentions\.length\s*>\s*0\s*&&\s*!hadError[\s\S]{0,200}?worklistEntry\.a2aCount\s*<\s*maxDepth/, + /lifecycleResponse\?\.status\s*===\s*'completed'\s*&&\s*a2aMentions\.length\s*>\s*0/, ); - assert.ok(cloudR3P1Match, 'Cloud R3 P1: A2A enqueue MUST be gated on !hadError'); + assert.ok(cloudR3P1Match, 'Cloud R3 P1: durable A2A admission MUST require a completed response'); }); }); diff --git a/packages/api/test/route-serial-freshness-reinvoke.test.js b/packages/api/test/route-serial-freshness-reinvoke.test.js index 85322e220f..f727474ea7 100644 --- a/packages/api/test/route-serial-freshness-reinvoke.test.js +++ b/packages/api/test/route-serial-freshness-reinvoke.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -141,23 +142,27 @@ describe('F254 ADR-042 — route-serial publish then supplement', () => { it('publishes a stale serial answer and enqueues a distinct read-only supplement carrier', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - const unseen = await messageStore.append({ - userId: 'user1', - catId: 'codex-sol', - content: 'late visible cat analysis', - mentions: [], - origin: 'stream', - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + const unseen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'codex-sol', + content: 'late visible cat analysis', + mentions: [], + origin: 'stream', + timestamp: 150, + threadId: 'thread-1', + }), + ); const harness = createDeps({ opus: textService('opus', 'completed answer') }, messageStore); enableFreshness(harness, seen.id); const enqueued = []; @@ -185,22 +190,26 @@ describe('F254 ADR-042 — route-serial publish then supplement', () => { it('keeps the original and persists queue_full when the supplement carrier cannot enqueue', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - await messageStore.append({ - userId: 'user1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const harness = createDeps({ opus: textService('opus', 'completed answer') }, messageStore); enableFreshness(harness, seen.id); @@ -220,22 +229,26 @@ describe('F254 ADR-042 — route-serial publish then supplement', () => { it('keeps the original and persists scheduler_unavailable when no supplement scheduler is wired', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - await messageStore.append({ - userId: 'user1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const harness = createDeps({ opus: textService('opus', 'completed answer') }, messageStore); enableFreshness(harness, seen.id); @@ -254,22 +267,26 @@ describe('F254 ADR-042 — route-serial publish then supplement', () => { it('publishes an answer-bearing thinking-only completion instead of replacing it with a placeholder', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - await messageStore.append({ - userId: 'user1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const harness = createDeps({ opus: thinkingService('opus', 'visible reasoning result') }, messageStore); enableFreshness(harness, seen.id); const enqueued = []; @@ -290,22 +307,26 @@ describe('F254 ADR-042 — route-serial publish then supplement', () => { it('publishes a side-effecting answer and carries replay-unsafe names into the hard supplement policy', async () => { const messageStore = new MessageStore(); - const seen = await messageStore.append({ - userId: 'user1', - catId: null, - content: 'question', - mentions: ['opus'], - timestamp: 100, - threadId: 'thread-1', - }); - await messageStore.append({ - userId: 'user1', - catId: null, - content: 'late correction', - mentions: ['opus'], - timestamp: 150, - threadId: 'thread-1', - }); + const seen = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'question', + mentions: ['opus'], + timestamp: 100, + threadId: 'thread-1', + }), + ); + await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'late correction', + mentions: ['opus'], + timestamp: 150, + threadId: 'thread-1', + }), + ); const toolName = 'mcp__cat-cafe-collab__cat_cafe_hold_ball'; const harness = createDeps({ opus: textService('opus', 'action completed', { toolName }) }, messageStore); enableFreshness(harness, seen.id); @@ -325,23 +346,27 @@ describe('F254 ADR-042 — route-serial publish then supplement', () => { it('persists a supplement decline without creating an empty or marker bubble', async () => { const messageStore = new MessageStore(); - const original = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'published original', - mentions: [], - timestamp: 100, - threadId: 'thread-1', - extra: { freshness: { kind: 'fresh', priorFrontierMessageId: null } }, - }); - const update = await messageStore.append({ - userId: 'user1', - catId: null, - content: 'thanks', - mentions: [], - timestamp: 150, - threadId: 'thread-1', - }); + const original = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'published original', + mentions: [], + timestamp: 100, + threadId: 'thread-1', + extra: { freshness: { kind: 'fresh', priorFrontierMessageId: null } }, + }), + ); + const update = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'thanks', + mentions: [], + timestamp: 150, + threadId: 'thread-1', + }), + ); const harness = createDeps({ opus: textService('opus', SUPPLEMENT_DECLINE_MARKER) }, messageStore); const offered = await harness.closureStore.offerSupplement({ lineageId: original.id, @@ -378,23 +403,27 @@ describe('F254 ADR-042 — route-serial publish then supplement', () => { it('stores supplement text as a reply but never dispatches route-like output', async () => { const messageStore = new MessageStore(); - const original = await messageStore.append({ - userId: 'user1', - catId: 'opus', - content: 'published original', - mentions: [], - timestamp: 100, - threadId: 'thread-1', - extra: { freshness: { kind: 'fresh', priorFrontierMessageId: null } }, - }); - const update = await messageStore.append({ - userId: 'user1', - catId: null, - content: 'late correction', - mentions: [], - timestamp: 150, - threadId: 'thread-1', - }); + const original = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: 'opus', + content: 'published original', + mentions: [], + timestamp: 100, + threadId: 'thread-1', + extra: { freshness: { kind: 'fresh', priorFrontierMessageId: null } }, + }), + ); + const update = await messageStore.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'late correction', + mentions: [], + timestamp: 150, + threadId: 'thread-1', + }), + ); let codexInvocations = 0; const harness = createDeps( { diff --git a/packages/api/test/route-serial-notice-contract.test.js b/packages/api/test/route-serial-notice-contract.test.js index ebb8f9044c..36b02c7776 100644 --- a/packages/api/test/route-serial-notice-contract.test.js +++ b/packages/api/test/route-serial-notice-contract.test.js @@ -18,6 +18,7 @@ function createWarningSystemInfoService(catId) { catId, content: JSON.stringify({ type: 'warning', + presentation: 'user_action_required', message: '当前 opencode/CodeAgent 适配器未返回 token 用量,自动 handoff 无法按上下文比例触发。', }), timestamp: Date.now(), @@ -36,6 +37,7 @@ function createTextWithWarningService(catId) { catId, content: JSON.stringify({ type: 'warning', + presentation: 'user_action_required', message: '当前 opencode/CodeAgent 适配器未返回 token 用量,自动 handoff 无法按上下文比例触发。', }), timestamp: Date.now(), @@ -214,11 +216,7 @@ function createMockDeps(services, appendCalls, feedbackWrites, broadcasts) { } describe('route-serial notice contract', () => { - it('emits routing-syntax-hint with explicit system_notice presentation metadata', async () => { - // F167 Phase H AC-H5 (2026-04-24): Phase H `routing-syntax-hint` is now the - // primary emission for slot-internal inline @handles. It suppresses the - // legacy `inline-mention-hint` (#417) on the same turn. The legacy - // setMentionRoutingFeedback path remains — cats get next-turn correction. + it('keeps an inline action @handle as text without adding a public correction bubble', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const appendCalls = []; const feedbackWrites = []; @@ -228,26 +226,22 @@ describe('route-serial notice contract', () => { for await (const _msg of routeSerial(deps, ['opus'], 'review this', 'user1', 'thread-1')) { } - assert.equal(feedbackWrites.length, 1, 'should still write routing feedback (next-turn correction preserved)'); - - const hintAppend = appendCalls.find((msg) => msg.source?.connector === 'routing-syntax-hint'); - assert.ok(hintAppend, 'should append a routing-syntax-hint (Phase H primary)'); - assert.equal(hintAppend.userId, 'system'); - assert.equal(hintAppend.catId, null); - assert.equal(hintAppend.source.meta.presentation, 'system_notice'); - assert.equal(hintAppend.source.meta.noticeTone, 'warning'); - - // AC-H5: legacy inline-mention-hint is suppressed when Phase H hits - const legacyHint = appendCalls.find((msg) => msg.source?.connector === 'inline-mention-hint'); - assert.equal(legacyHint, undefined, 'AC-H5: legacy inline-mention-hint must be suppressed when Phase H hits'); - - const hintBroadcast = broadcasts.find( - (entry) => - entry.event === 'connector_message' && entry.payload.message.source?.connector === 'routing-syntax-hint', + assert.equal( + appendCalls.some((msg) => msg.content?.includes('Ready for @codex review')), + true, + ); + assert.equal( + appendCalls.some((msg) => ['routing-syntax-hint', 'inline-mention-hint'].includes(msg.source?.connector)), + false, + ); + assert.equal( + broadcasts.some( + (entry) => + entry.event === 'connector_message' && + ['routing-syntax-hint', 'inline-mention-hint'].includes(entry.payload.message.source?.connector), + ), + false, ); - assert.ok(hintBroadcast, 'should broadcast the routing-syntax-hint in real-time'); - assert.equal(hintBroadcast.payload.message.source.meta.presentation, 'system_notice'); - assert.equal(hintBroadcast.payload.message.source.meta.noticeTone, 'warning'); }); it('issue #1208 P2: persists user-facing warning system_info to messageStore', async () => { @@ -274,7 +268,7 @@ describe('route-serial notice contract', () => { const warningAppend = appendCalls.find((msg) => msg.source?.connector === 'system-warning'); assert.ok(warningAppend, 'warning system_info must be persisted to messageStore'); assert.equal(warningAppend.userId, 'system'); - assert.equal(warningAppend.catId, null); + assert.deepEqual(warningAppend.from, { kind: 'system', service: 'system-info-warning' }); assert.ok( warningAppend.content.includes('未返回 token 用量'), 'persisted warning content should include the original warning message', @@ -306,7 +300,11 @@ describe('route-serial notice contract', () => { // consume } - assert.ok(appendCalls.some((msg) => msg.catId === 'opus' && msg.content === 'Here is the result.')); + assert.ok( + appendCalls.some( + (msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus' && msg.content === 'Here is the result.', + ), + ); assert.ok( appendCalls.some((msg) => msg.source?.connector === 'system-warning'), 'warning must survive refresh when the same turn also produced text', diff --git a/packages/api/test/route-serial-phase-h-hint.test.js b/packages/api/test/route-serial-phase-h-hint.test.js index fe54b0b4e0..0aef38e78b 100644 --- a/packages/api/test/route-serial-phase-h-hint.test.js +++ b/packages/api/test/route-serial-phase-h-hint.test.js @@ -1,14 +1,12 @@ /** - * F167 Phase H AC-H3/H5 — route-serial integration: routing-syntax-hint emission. + * F167 Phase H AC-H3/H5 — route-serial integration: routing syntax correction. * * Pure detector coverage is in `final-routing-slot.test.js`. This suite locks * the wire-up between route-serial and the validator: - * - Inline @ in final routing slot + no legitimate exit → appends - * `source.connector === 'routing-syntax-hint'` system message - * - Legitimate exit (line-start @ / hold_ball / MCP targetCats) → no emit - * - Structural exemptions (fenced code, blockquote, URL) → no emit - * - AC-H5: when Phase H hits AND verdict-no-pass would also hit, only - * routing-syntax-hint emits (root-cause wins; AC-C7 suppressed) + * - Inline @ in final routing slot + no legitimate exit stays internal + * - Legitimate exit (line-start @ / hold_ball / MCP targetCats) needs no correction + * - Structural exemptions (fenced code, blockquote, URL) need no correction + * - AC-H5: protocol correction never appends a public History hint */ import assert from 'node:assert/strict'; @@ -167,18 +165,16 @@ async function runRouteWithTool(text, threadId, toolName, toolInput) { }); } -describe('F167 Phase H AC-H3: route-serial routing-syntax-hint emission', () => { - test('inline @ in final slot + no legitimate exit → emits routing-syntax-hint', async () => { +describe('F167 Phase H AC-H3: routing syntax stays an internal correction signal', () => { + test('exports a dedicated internal correction counter', async () => { + const { routingSyntaxCorrectionDetected } = await import('../dist/infrastructure/telemetry/instruments.js'); + assert.equal(typeof routingSyntaxCorrectionDetected.add, 'function'); + }); + + test('inline @ in final slot + no legitimate exit → no public routing-syntax-hint', async () => { const { appended } = await runRoute('我让 @codex 看了下', 'thread-ph-1'); const hint = appended.find((m) => m.source?.connector === 'routing-syntax-hint'); - assert.ok(hint, 'must append routing-syntax-hint when slot has inline @ with no exit'); - assert.equal(hint.userId, 'system'); - assert.equal(hint.catId, null); - assert.match(hint.content, /@codex/); - assert.match(hint.content, /行中|行首/); - assert.equal(hint.source.icon, '⚠️'); - assert.equal(hint.source.meta.presentation, 'system_notice'); - assert.equal(hint.source.meta.noticeTone, 'warning'); + assert.equal(hint, undefined, 'agent-format correction must not append a public History message'); }); test('legitimate line-start @ exit → NO routing-syntax-hint', async () => { @@ -217,21 +213,20 @@ describe('F167 Phase H AC-H3: route-serial routing-syntax-hint emission', () => }); }); -describe('F167 Phase H AC-H5: AC-C7 verdict-no-pass suppression when Phase H hits', () => { - test('inline @ + LGTM (verdict) in slot → only routing-syntax-hint, NOT verdict-no-pass-hint', async () => { +describe('F167 Phase H AC-H5: liveness diagnostics stay out of public History', () => { + test('inline @ + LGTM (verdict) in slot → no public correction hints', async () => { const { appended } = await runRoute('LGTM, 我让 @codex 看了下', 'thread-ph-7'); const phaseH = appended.find((m) => m.source?.connector === 'routing-syntax-hint'); const verdictHint = appended.find((m) => m.source?.connector === 'verdict-no-pass-hint'); - assert.ok(phaseH, 'Phase H hint must emit (root cause)'); - assert.equal(verdictHint, undefined, 'AC-H5: verdict-no-pass-hint must be suppressed when Phase H hits'); + assert.equal(phaseH, undefined); + assert.equal(verdictHint, undefined); }); - test('verdict LGTM without inline @ → verdict-no-pass-hint still emits (Phase H not hit)', async () => { - // Control case: Phase H does NOT fire (no inline @ in slot). AC-C7 should still fire. + test('verdict LGTM without inline @ → detector does not add a public verdict hint', async () => { const { appended } = await runRoute('LGTM, all tests pass', 'thread-ph-8'); const phaseH = appended.find((m) => m.source?.connector === 'routing-syntax-hint'); const verdictHint = appended.find((m) => m.source?.connector === 'verdict-no-pass-hint'); assert.equal(phaseH, undefined, 'Phase H does not fire without inline @ in slot'); - assert.ok(verdictHint, 'AC-C7 still fires on verdict-only output when Phase H did not hit'); + assert.equal(verdictHint, undefined, 'protocol diagnostics must remain internal'); }); }); diff --git a/packages/api/test/route-serial-pingpong.test.js b/packages/api/test/route-serial-pingpong.test.js deleted file mode 100644 index 9f43421e99..0000000000 --- a/packages/api/test/route-serial-pingpong.test.js +++ /dev/null @@ -1,530 +0,0 @@ -/** - * F167 L1: route-serial 消费 warnPingPong / blockPingPong - * - * streak=2+ → 下一只猫 prompt 注入"🏓 乒乓球"警告(pingPongWarning)。 - * streak=4 → route-serial 不 enqueue 下一棒 + emit a2a_pingpong_terminated system_info。 - * - * 场景:opus ↔ codex 互相 @ 的 ping-pong 链。 - * - round 0: opus 跑(original)→ 出 @codex → enqueue codex (streak=1) - * - round 1: codex 跑 → 出 @opus → enqueue opus (streak=2, warn) - * - round 2: opus 跑(prompt 含 warn)→ 出 @codex → enqueue codex (streak=3, warn) - * - round 3: codex 跑(prompt 含 warn)→ 出 @opus → streak=4 BLOCK, emit terminated, 不 enqueue - * 共 4 次 invoke,opus 2 次、codex 2 次。 - */ - -import './helpers/setup-cat-registry.js'; -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { catRegistry } from '@cat-cafe/shared'; - -const REPO_TEMPLATE_PATH = fileURLToPath(new URL('../../../cat-template.json', import.meta.url)); - -function createCapturingService(catId, text) { - const calls = []; - return { - calls, - async *invoke(prompt) { - calls.push(prompt); - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} - -/** - * F167 Phase D: service that emits a substantive tool_use before its text. - * Used to verify the breaker exempts real work (read/edit/write/task) from streak++. - */ -function createSubstantiveToolService(catId, text, toolName = 'Edit') { - const calls = []; - return { - calls, - async *invoke(prompt) { - calls.push(prompt); - yield { - type: 'tool_use', - catId, - toolName, - toolInput: {}, - id: `tool-${Date.now()}-${Math.random()}`, - timestamp: Date.now(), - }; - yield { type: 'text', catId, content: text, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} - -function createMockDeps(services) { - let counter = 0; - return { - services, - invocationDeps: { - registry: { - create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { - getOrCreate: async () => ({}), - resolveWorkingDirectory: () => '/tmp/test', - }, - threadStore: null, - apiUrl: 'http://127.0.0.1:3004', - }, - messageStore: { - append: async () => ({ - id: `msg-${counter}`, - userId: '', - catId: null, - content: '', - mentions: [], - timestamp: 0, - }), - getById: () => null, - getRecent: () => [], - getMentionsFor: () => [], - getBefore: () => [], - getByThread: () => [], - getByThreadAfter: () => [], - getByThreadBefore: () => [], - }, - }; -} - -function withClaimedA2ASlot(options = {}) { - return { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - ...options, - }; -} - -async function loadRealRoster() { - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - const runtimeConfigs = toAllCatConfigs(loadCatConfig(REPO_TEMPLATE_PATH)); - catRegistry.reset(); - for (const [id, config] of Object.entries(runtimeConfigs)) { - catRegistry.register(id, config); - } -} - -describe('F167 L1: route-serial ping-pong circuit breaker', { concurrency: false }, () => { - test('streak=4 (opus↔codex × 4 rounds) → block enqueue + emit a2a_pingpong_terminated', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const opusService = createCapturingService('opus', '看了\n@codex review 一下'); - const codexService = createCapturingService('codex', '看了\n@opus 确认一下'); - const deps = createMockDeps({ opus: opusService, codex: codexService }); - - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'ping-pong test', - 'user1', - 'thread-pp-block', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - events.push(msg); - } - - assert.strictEqual(opusService.calls.length, 2, 'opus should invoke 2 times (rounds 0, 2) before block'); - assert.strictEqual(codexService.calls.length, 2, 'codex should invoke 2 times (rounds 1, 3) before block'); - - const terminated = events.find( - (e) => - e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(terminated, 'must emit a2a_pingpong_terminated system_info on streak=4'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('streak >= 2 → next cat prompt contains ping-pong warning', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const opusService = createCapturingService('opus', '看了\n@codex 确认一下'); - const codexService = createCapturingService('codex', '看了\n@opus 确认一下'); - const deps = createMockDeps({ opus: opusService, codex: codexService }); - - for await (const _ of routeSerial( - deps, - ['opus'], - 'warn test', - 'user1', - 'thread-pp-warn', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - } - - // opus 第 2 次被 invoke 时(round 2),streakPair.count=2,prompt 应含警告 - assert.ok(opusService.calls.length >= 2, 'opus must be invoked at least twice'); - const secondOpusPrompt = JSON.stringify(opusService.calls[1]); - assert.match( - secondOpusPrompt, - /乒乓球|连续.*轮|ping[- ]?pong/, - 'second opus prompt (after streak=2) must contain ping-pong warning text', - ); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('F167-D1: 4 rounds with substantive tool (Edit) every round → NO termination', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - // Each cat calls a substantive tool (Edit) before emitting its @mention. - // Expected: breaker exempts every round → opus/codex each invoke 2+ times, no pingpong_terminated. - const opusService = createSubstantiveToolService('opus', '修完\n@codex review 一下', 'Edit'); - const codexService = createSubstantiveToolService('codex', '补测试\n@opus 看看', 'Write'); - const deps = createMockDeps({ opus: opusService, codex: codexService }); - - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'substantive review test', - 'user1', - 'thread-pp-substantive', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - events.push(msg); - } - - const terminated = events.find( - (e) => - e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(!terminated, 'substantive tool_use every round must exempt streak — no a2a_pingpong_terminated'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('F167-D1: 4 rounds with long text (>200 chars) every round → NO termination', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - // Output text must be > 200 chars (UTF-16 code units) to trigger the long-text exemption. - const longTextOpus = `${'架构讨论'.repeat(60)}\n@codex 这段你怎么看?`; // ~240+ code units - const longTextCodex = `${'架构回应'.repeat(60)}\n@opus 我的观点如上`; - const opusService = createCapturingService('opus', longTextOpus); - const codexService = createCapturingService('codex', longTextCodex); - const deps = createMockDeps({ opus: opusService, codex: codexService }); - - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'long-text discussion test', - 'user1', - 'thread-pp-longtext', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - events.push(msg); - } - - const terminated = events.find( - (e) => - e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_pingpong_terminated'), - ); - assert.ok( - !terminated, - 'long-text discussion (>200 chars) every round must exempt streak — no a2a_pingpong_terminated', - ); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('F167-E AC-E8: F172 愿景守护 replay — @gemini with "合入" in prior narrative must route normally (L3 retired)', async () => { - // Exact reproduction of the bug: - // opus reports "PR #1355 已合入 main" + asks @gemini 做愿景守护. - // Before Phase E: L3 regex matched "合入" anywhere in storedContent, a2a_role_rejected - // emitted, gemini NEVER invoked. - // After Phase E: no harness gate — gemini gets enqueued and invoked normally. - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const opusText = `PR #1355 已合入 main\n\nReview 历程:砚砚 review 2 轮放行 + 云端 review 3 轮(2 P1 + 2 P2 全修)\n\n请做愿景守护:对照 spec docs/features/F172-generated-image-publication.md 的 8 条需求点 (R1-R8),判断交付物是否解决了co-creator的问题。\n\n@gemini`; - const opusService = createCapturingService('opus', opusText); - const geminiService = createCapturingService('gemini', '愿景守护完成,方向对上了'); - const deps = createMockDeps({ opus: opusService, gemini: geminiService }); - - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'F172 愿景守护 replay', - 'user1', - 'thread-f172-replay', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - events.push(msg); - } - - // Gemini MUST have been invoked (L3 no longer pre-rejects based on "合入" text). - assert.strictEqual( - geminiService.calls.length, - 1, - 'gemini must be invoked for 愿景守护 despite "合入" in narrative', - ); - // No a2a_role_rejected event should appear. - const rejected = events.find( - (e) => e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_role_rejected'), - ); - assert.ok(!rejected, 'L3 retired — a2a_role_rejected must NOT be emitted anymore'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('streak=4 in deferred path (F185-B AC-B3a) → block deferred enqueue + emit a2a_pingpong_terminated', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const opusService = createCapturingService('opus', '看了\n@codex review 一下'); - const codexService = createCapturingService('codex', '看了\n@opus 确认一下'); - const deps = createMockDeps({ opus: opusService, codex: codexService }); - - let callCount = 0; - const deferredEntries = []; - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'ping-pong deferred test', - 'user1', - 'thread-pp-defer', - withClaimedA2ASlot({ - thinkingMode: 'play', - queueHasQueuedMessages: () => { - callCount++; - return callCount >= 4; - }, - deferA2AEnqueue: (entry) => deferredEntries.push(entry), - }), - )) { - events.push(msg); - } - - assert.strictEqual(opusService.calls.length, 2, 'opus should invoke 2 times (rounds 0, 2)'); - assert.strictEqual(codexService.calls.length, 2, 'codex should invoke 2 times (rounds 1, 3)'); - - assert.strictEqual(deferredEntries.length, 0, 'deferred enqueue must NOT be called when streak blocks'); - - const terminated = events.find( - (e) => - e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(terminated, 'deferred path must emit a2a_pingpong_terminated on streak=4'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - test('no ping-pong (single handoff) → no warning, no termination', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const opusService = createCapturingService('opus', '看过了\n@codex 帮忙 review'); - const codexService = createCapturingService('codex', '看过了,没问题'); - const deps = createMockDeps({ opus: opusService, codex: codexService }); - - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'single handoff', - 'user1', - 'thread-pp-single', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - events.push(msg); - } - - assert.strictEqual(opusService.calls.length, 1, 'opus invoked once (no ping back)'); - assert.strictEqual(codexService.calls.length, 1, 'codex invoked once'); - const terminated = events.find( - (e) => - e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(!terminated, 'single handoff must not emit a2a_pingpong_terminated'); - const firstCodexPrompt = JSON.stringify(codexService.calls[0]); - assert.doesNotMatch( - firstCodexPrompt, - /乒乓球|连续.*轮/, - 'codex first prompt (streak=1) must NOT contain warning', - ); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - // F216 P1-2 (砚砚 review): a multi-mention in ONE response must apply streak per-target IN ORDER. - // Repro: build opus<->codex streak hot, then codex emits "@gemini @opus" (multi-mention). - // Correct (resolve+apply one cat at a time): processing @gemini first RESETS the pair → @opus is - // then evaluated against a fresh pair → enqueued, opus runs again, no termination. - // Stale-batch bug (resolve ALL decisions before any mutation): both peeks read the hot - // opus<->codex streak, so @opus is predicted to hit the block threshold and gets WRONGLY blocked. - test('F216 P1-2: multi-mention applies streak per-target — gemini resets pair, @opus not stale-blocked', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - // Round-aware service: empty text after the scripted rounds → chain terminates deterministically. - const roundAware = (catId, textsByCall) => { - const calls = []; - return { - calls, - async *invoke(prompt) { - const i = calls.length; - calls.push(prompt); - const content = i < textsByCall.length ? textsByCall[i] : ''; - yield { type: 'text', catId, content, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; - }; - // opus<->codex pings: R0 opus@codex(streak1), R1 codex@opus(2), R2 opus@codex(3), - // R3 codex emits "@gemini @opus": gemini resets pair, @opus must enqueue (fresh pair=1). - const opusService = roundAware('opus', ['看了\n@codex review 一下', '看了\n@codex review 一下']); - const codexService = roundAware('codex', ['看了\n@opus 确认一下', '看了\n@gemini @opus 你们看看']); - const geminiService = roundAware('gemini', ['看过了,没问题']); - const deps = createMockDeps({ opus: opusService, codex: codexService, gemini: geminiService }); - - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'multi-mention streak test', - 'user1', - 'thread-pp-multi', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - events.push(msg); - } - - const terminated = events.find( - (e) => - e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(!terminated, 'round-3 @opus must NOT be stale-blocked — gemini should reset the pair first'); - assert.ok(geminiService.calls.length >= 1, 'gemini must be invoked (enqueued from the multi-mention)'); - assert.strictEqual(opusService.calls.length, 3, 'opus must invoke 3x (rounds 0,2, + round-3 re-enqueue)'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); - - // F216 c2 (behavior-preservation lock for the deferred-path refactor): the deferred block was rewired - // from a hand-written `for (nextCat of a2aMentions) { updateStreakOnPush per-iteration }` loop to the - // unified resolveRoutingDecisions decision layer (consistent with inline c1.3). Both the old loop and - // the new decision-driven loop apply streak PER-TARGET (mutate before evaluating the next cat), so this - // test passes on BOTH — it is a preservation guard, NOT a red→green regression (main's deferred was - // never the batch form that caused 砚砚's inline P1-2). It locks the per-target property so a future - // refactor that accidentally batches the deferred peeks (freezing every peekStreak against the pre-loop - // streakPair) would break here: rounds 0-2 build a hot opus<->codex streak inline, then round 3 - // (codex emits "@gemini @opus", deferred) must let gemini reset the pair first so @opus is not blocked. - // Captured via deferA2AEnqueue (deferred path enqueues there, not into the worklist). - test('F216 c2: deferred path preserves per-target streak — gemini resets pair, @opus not stale-blocked', async () => { - const original = catRegistry.getAllConfigs(); - await loadRealRoster(); - try { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const roundAware = (catId, textsByCall) => { - const calls = []; - return { - calls, - async *invoke(prompt) { - const i = calls.length; - calls.push(prompt); - const content = i < textsByCall.length ? textsByCall[i] : ''; - yield { type: 'text', catId, content, timestamp: Date.now() }; - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; - }; - const opusService = roundAware('opus', ['看了\n@codex review 一下', '看了\n@codex review 一下']); - const codexService = roundAware('codex', ['看了\n@opus 确认一下', '看了\n@gemini @opus 你们看看']); - const geminiService = roundAware('gemini', ['看过了,没问题']); - const deps = createMockDeps({ opus: opusService, codex: codexService, gemini: geminiService }); - - // Rounds 0-2 run INLINE (queue not pending) to build the opus<->codex streak to 3. - // Round 3 (codex emits "@gemini @opus") runs DEFERRED (queue pending) — the path under test. - let callCount = 0; - const deferredEntries = []; - const events = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'deferred streak test', - 'user1', - 'thread-pp-defer-multi', - withClaimedA2ASlot({ - thinkingMode: 'play', - queueHasQueuedMessages: () => { - callCount++; - return callCount >= 4; // rounds 0,1,2 inline (build streak); round 3 deferred (multi-mention) - }, - deferA2AEnqueue: (entry) => deferredEntries.push(entry), - }), - )) { - events.push(msg); - } - - // Buggy batch-resolve: @opus in round 3 frozen against opus<->codex=3 → block_pingpong, terminated. - // Fixed per-cat: gemini deferred first (resets pair), @opus deferred against fresh pair → no block. - const terminated = events.find( - (e) => - e.type === 'system_info' && typeof e.content === 'string' && e.content.includes('a2a_pingpong_terminated'), - ); - assert.ok(!terminated, 'deferred round-3 @opus must NOT be stale-blocked — gemini resets the pair first'); - const deferredCats = deferredEntries.flatMap((e) => e.targetCats); - assert.ok(deferredCats.includes('gemini'), 'gemini must be deferred-enqueued from the multi-mention'); - assert.ok(deferredCats.includes('opus'), 'opus must be deferred-enqueued (not stale-blocked) in round 3'); - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); -}); diff --git a/packages/api/test/route-serial-replyto-stream.test.js b/packages/api/test/route-serial-replyto-stream.test.js index b6a6757a9b..bebd4f092d 100644 --- a/packages/api/test/route-serial-replyto-stream.test.js +++ b/packages/api/test/route-serial-replyto-stream.test.js @@ -59,45 +59,7 @@ function createMockDeps(services, appendCalls, initialMessages = []) { }; } -function withClaimedA2ASlot(options = {}) { - return { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - ...options, - }; -} - describe('routeSerial replyTo on stream messages', () => { - it('attaches replyTo + replyPreview to CLI A2A stream responses', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const appendCalls = []; - const deps = createMockDeps( - { - opus: createMockService('opus', '我先看一下\n@缅因猫 帮忙复核'), - codex: createMockService('codex', '收到,我来复核'), - }, - appendCalls, - ); - - const yielded = []; - for await (const msg of routeSerial(deps, ['opus'], 'check this', 'user1', 'thread1', withClaimedA2ASlot())) { - yielded.push(msg); - } - - assert.equal(appendCalls.length, 2, 'should persist both opus and codex stream messages'); - assert.equal(appendCalls[0].replyTo, undefined, 'originating cat should not reply to anything'); - assert.equal(appendCalls[1].replyTo, 'msg-1', 'A2A stream reply should persist replyTo to trigger message'); - - const codexText = yielded.find((msg) => msg.type === 'text' && msg.catId === 'codex'); - assert.ok(codexText, 'should yield codex stream text'); - assert.equal(codexText.replyTo, 'msg-1', 'stream text should carry replyTo for live ReplyPill rendering'); - assert.deepEqual(codexText.replyPreview, { - senderCatId: 'opus', - content: '我先看一下\n@缅因猫 帮忙复核', - }); - }); - it('attaches replyTo and persists trigger provenance for queue-dispatched initial target', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const appendCalls = []; @@ -183,40 +145,4 @@ describe('routeSerial replyTo on stream messages', () => { assert.ok(codexText, 'should yield codex stream text'); assert.equal(codexText.replyTo, undefined, 'live stream must not carry a bogus user-message replyTo'); }); - - it('passes explicit trigger id into deferred queue dispatch when fairness gate defers text-scan A2A', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const appendCalls = []; - const deferred = []; - const deps = createMockDeps( - { - opus: createMockService('opus', '我先看一下\n@缅因猫 帮忙复核'), - }, - appendCalls, - ); - - const yielded = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'check this', - 'user1', - 'thread1', - withClaimedA2ASlot({ - queueHasQueuedMessages: () => true, - deferA2AEnqueue: (entry) => deferred.push(entry), - }), - )) { - yielded.push(msg); - } - - assert.ok(yielded.find((msg) => msg.type === 'text' && msg.catId === 'opus')); - assert.equal(deferred.length, 1, 'should enqueue deferred A2A target instead of extending worklist'); - assert.equal(deferred[0].targetCats[0], 'codex'); - assert.equal( - deferred[0].a2aTriggerMessageId, - 'msg-1', - 'deferred queue entry should keep the stored trigger message id', - ); - }); }); diff --git a/packages/api/test/route-serial-verdict-hint.test.js b/packages/api/test/route-serial-verdict-hint.test.js index 9a0201670f..ccad4a0aca 100644 --- a/packages/api/test/route-serial-verdict-hint.test.js +++ b/packages/api/test/route-serial-verdict-hint.test.js @@ -1,17 +1,11 @@ /** - * F167 C2 AC-C7 — route-serial integration: verdict-no-pass hint emission. + * F167 C2 AC-C7 — route-serial integration: verdict-without-pass detection + * must not manufacture public History rows. * * Pure detector behavior is covered in `verdict-detect.test.js`. This suite - * locks the wire-up between route-serial and the detector: when a cat's - * output contains a review verdict keyword AND has no line-start @mention - * AND collected tool names include no hold_ball, route-serial appends a - * connector message with `source.connector === 'verdict-no-pass-hint'`. - * - * Scope: verify side effect on messageStore.append. We don't verify chain - * routing (there IS no next cat — that's the whole point of the warning). - * - * Prompt-first, non-blocking. Emission failures inside try/catch must not - * break routing; that's covered implicitly by the try/catch in route-serial. + * Pure detector behavior is covered separately. This suite locks the + * presentation boundary: agent-protocol mistakes remain telemetry/prompt + * concerns and never become user-visible connector messages. */ import assert from 'node:assert/strict'; @@ -177,22 +171,17 @@ async function runRouteWithTool(text, threadId, toolName, toolInput) { }); } -describe('F167 C2 AC-C7: route-serial verdict-no-pass hint emission', () => { - test('LGTM with no @mention + no hold_ball → emits verdict-no-pass-hint', async () => { +describe('F167 C2 AC-C7: verdict-no-pass stays an internal signal', () => { + test('LGTM with no @mention + no hold_ball → no public verdict-no-pass-hint', async () => { const { appended } = await runRoute('LGTM, all tests pass', 'thread-vh-1'); const hint = appended.find((m) => m.source?.connector === 'verdict-no-pass-hint'); - assert.ok(hint, 'must append a message whose source.connector === verdict-no-pass-hint'); - assert.equal(hint.userId, 'system'); - assert.equal(hint.catId, null); - assert.match(hint.content, /传球|hold_ball/); - assert.equal(hint.source.icon, '🏓'); - assert.equal(hint.source.meta.presentation, 'system_notice'); + assert.equal(hint, undefined); }); - test('Chinese verdict (修改建议) with no @ + no hold_ball → emits hint', async () => { + test('Chinese verdict (修改建议) with no @ + no hold_ball → no public hint', async () => { const { appended } = await runRoute('修改建议:重命名 foo → bar', 'thread-vh-2'); const hint = appended.find((m) => m.source?.connector === 'verdict-no-pass-hint'); - assert.ok(hint, 'Chinese verdict must also trigger the hint'); + assert.equal(hint, undefined); }); test('verdict + line-start @mention → NO hint (ball was passed)', async () => { @@ -226,14 +215,14 @@ describe('F167 C2 AC-C7: route-serial verdict-no-pass hint emission', () => { assert.equal(hint, undefined, 'structured multi_mention.targets must exempt the hint'); }); - test('verdict + post_message WITHOUT targetCats → hint fires (passive post, not routing)', async () => { + test('verdict + post_message WITHOUT targetCats → still no public hint', async () => { // Calling post_message without targetCats is just posting to the thread — it doesn't // route the ball to any specific cat. Hint should still fire. const { appended } = await runRouteWithTool('LGTM, check is done', 'thread-vh-7', 'cat_cafe_post_message', { content: 'done', }); const hint = appended.find((m) => m.source?.connector === 'verdict-no-pass-hint'); - assert.ok(hint, 'post_message without targetCats does not constitute routing → hint fires'); + assert.equal(hint, undefined); }); test('provider-prefixed tool name (mcp__cat-cafe__cat_cafe_post_message) + targetCats → NO hint', async () => { @@ -277,11 +266,11 @@ describe('F167 C2 AC-C7: route-serial verdict-no-pass hint emission', () => { assert.equal(hint, undefined, 'CJK co-creator handle co-creator is also a legitimate exit'); }); - test('verdict + inline @co-creator (NOT line-start) + no other exit → hint fires (control)', async () => { + test('verdict + inline @co-creator (NOT line-start) + no other exit → no public hint', async () => { // Negative control: inline (mid-line) co-creator mention doesn't count as // line-start exit. Hint should still fire. const { appended } = await runRoute('LGTM, ask @co-creator to confirm later please', 'thread-vh-12'); const hint = appended.find((m) => m.source?.connector === 'verdict-no-pass-hint'); - assert.ok(hint, 'inline (mid-line) co-creator mention is not a line-start exit; hint must fire'); + assert.equal(hint, undefined); }); }); diff --git a/packages/api/test/route-strategies.test.js b/packages/api/test/route-strategies.test.js index 88c0a7e777..ba25d0a145 100644 --- a/packages/api/test/route-strategies.test.js +++ b/packages/api/test/route-strategies.test.js @@ -278,15 +278,6 @@ function createMockDeps(services, appendCalls, threadStore = null, guideSessionS }; } -function withClaimedA2ASlot(options = {}) { - return { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - ...options, - }; -} - function degradationSystemInfos(messages) { return messages.filter((m) => { if (m.type !== 'system_info') return false; @@ -1277,6 +1268,7 @@ describe('incremental current-message fallback integration', () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); const { formatConciergeHandleBinding } = await import('../dist/domains/concierge/concierge-search-context.js'); + const { projectConciergeStoredContent } = await import('../dist/domains/concierge/concierge-reply-validator.js'); const { CONCIERGE_CONFIG_DEFAULTS } = await import('@cat-cafe/shared'); const appendCalls = []; const targetEntry = { @@ -1380,7 +1372,7 @@ describe('incremental current-message fallback integration', () => { assert.match(prompt, /R1: 《Target Thread》/); assert.ok(prompt.includes(`[跳过去 ${targetBinding}]`)); - const storedReply = appendCalls.find((msg) => msg.catId === 'opus'); + const storedReply = appendCalls.find((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus'); const action = storedReply?.extra?.rich?.blocks ?.flatMap((block) => block.actions ?? []) .find((candidate) => candidate.payload?.threadId === 'thread_target'); @@ -1404,7 +1396,7 @@ describe('incremental current-message fallback integration', () => { assert.match(parallelPrompt, /R1: 《Target Thread》/); assert.ok(parallelPrompt.includes(`[跳过去 ${targetBinding}]`)); const parallelAction = appendCalls - .find((msg) => msg.catId === 'opus') + .find((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus') ?.extra?.rich?.blocks?.flatMap((block) => block.actions ?? []) .find((candidate) => candidate.payload?.threadId === 'thread_target'); assert.ok(parallelAction, 'parallel action resolution should use the same injected R1 table'); @@ -1416,8 +1408,9 @@ describe('incremental current-message fallback integration', () => { })) { } const mismatchedActions = - appendCalls.find((msg) => msg.catId === 'opus')?.extra?.rich?.blocks?.flatMap((block) => block.actions ?? []) ?? - []; + appendCalls + .find((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus') + ?.extra?.rich?.blocks?.flatMap((block) => block.actions ?? []) ?? []; assert.deepStrictEqual( mismatchedActions, [], @@ -1431,8 +1424,9 @@ describe('incremental current-message fallback integration', () => { })) { } const mixedBareActions = - appendCalls.find((msg) => msg.catId === 'opus')?.extra?.rich?.blocks?.flatMap((block) => block.actions ?? []) ?? - []; + appendCalls + .find((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus') + ?.extra?.rich?.blocks?.flatMap((block) => block.actions ?? []) ?? []; assert.deepStrictEqual( mixedBareActions, [], @@ -1465,17 +1459,27 @@ describe('incremental current-message fallback integration', () => { })) { serialProjectedEvents.push(event); } - const serialStoredReply = appendCalls.find((msg) => msg.catId === 'opus'); + const serialStoredReply = appendCalls.find((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus'); assertDanglingTriageStoredAsVisibleText(serialStoredReply, 'serial'); const serialDone = serialProjectedEvents.find((event) => event.type === 'done' && event.catId === 'opus'); - assert.equal(serialDone?.content, serialStoredReply.content, 'serial done must expose the canonical stored body'); + // F117: the done event carries the canonical projection of the stored body + // (route-serial persistedDoneContent = projectConciergeStoredContent), while the + // stream append keeps the verbatim reply text (marker syntax included). + assert.equal( + serialDone?.content, + projectConciergeStoredContent(serialStoredReply.content), + 'serial done must expose the canonical projection of the stored body', + ); assert.deepStrictEqual(serialStoredReply.mentions, [], 'serial should ignore hidden triage A2A mentions'); assert.equal( hiddenMentionService.calls.length, 0, 'serial should not invoke a cat mentioned only in hidden triage', ); - assert.ok(!appendCalls.some((msg) => msg.catId === 'codex'), 'serial should not persist a hidden A2A follow-up'); + assert.ok( + !appendCalls.some((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'codex'), + 'serial should not persist a hidden A2A follow-up', + ); captureService.calls.length = 0; hiddenMentionService.calls.length = 0; @@ -1486,13 +1490,9 @@ describe('incremental current-message fallback integration', () => { })) { parallelProjectedEvents.push(event); } - const parallelStoredReply = appendCalls.find((msg) => msg.catId === 'opus'); - assertDanglingTriageStoredAsVisibleText(parallelStoredReply, 'parallel'); - const parallelDone = parallelProjectedEvents.find((event) => event.type === 'done' && event.catId === 'opus'); - assert.equal( - parallelDone?.content, - parallelStoredReply.content, - 'parallel done must expose the canonical stored body', + assertDanglingTriageStoredAsVisibleText( + appendCalls.find((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus'), + 'parallel', ); assert.equal(hiddenMentionService.calls.length, 0, 'parallel should not invoke hidden triage mentions'); }); @@ -1578,8 +1578,9 @@ describe('incremental current-message fallback integration', () => { const assertVerifiedAction = (mode) => { const actions = - appendCalls.find((msg) => msg.catId === 'opus')?.extra?.rich?.blocks?.flatMap((block) => block.actions ?? []) ?? - []; + appendCalls + .find((msg) => msg.from?.kind === 'agent' && msg.from.catId === 'opus') + ?.extra?.rich?.blocks?.flatMap((block) => block.actions ?? []) ?? []; assert.equal(actions.length, 1, `${mode} should persist one verified teleport action`); assert.equal(actions[0].action, 'concierge_teleport'); assert.equal(actions[0].label, `跳过去:${targetTitle}`); @@ -1912,355 +1913,55 @@ describe('agent message timestamp uses invocation start time (#557)', () => { }); }); -describe('routeSerial A2A worklist', () => { - it('extends worklist when cat response contains line-start @mention', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - // opus responds with a line-start mention of codex - const deps = createMockDeps({ - opus: createMockService('opus', '我写好了代码\n@缅因猫 请 review 一下'), - codex: createMockService('codex', 'LGTM, 代码没问题'), - }); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'write hello world', - 'user1', - 'thread1', - withClaimedA2ASlot(), - )) { - messages.push(msg); - } - - // Should have text from both cats (opus + codex via A2A) - const opusText = messages.filter((m) => m.type === 'text' && m.catId === 'opus'); - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.ok(opusText.length > 0, 'opus should produce text'); - assert.ok(codexText.length > 0, 'codex should be invoked via A2A'); - }); - - it('yields a2a_handoff event when A2A chain triggers', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '请看一下\n@缅因猫 帮忙检查'), - codex: createMockService('codex', '已检查完毕'), - }); - - const messages = []; - for await (const msg of routeSerial(deps, ['opus'], 'check code', 'user1', 'thread1', withClaimedA2ASlot())) { - messages.push(msg); - } - - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 1, 'should yield exactly one a2a_handoff'); - assert.equal(handoffs[0].catId, 'opus', 'handoff should be from opus'); - assert.equal(handoffs[0].targetCatId, 'codex', 'handoff must carry machine-readable target cat'); - assert.ok(handoffs[0].invocationId, 'handoff must carry current turn invocation id for live slot migration'); - assert.ok(handoffs[0].content.includes('→'), 'handoff content should show arrow'); - }); - - it('A2A cat receives previousResponses in prompt (debug mode)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const codexService = createCapturingService('codex', '已审查'); - const deps = createMockDeps({ - opus: createMockService('opus', '代码完成\n@缅因猫 请review'), - codex: codexService, - }); - - for await (const _ of routeSerial( - deps, - ['opus'], - 'write code', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - } - - assert.equal(codexService.calls.length, 1, 'codex should be called once'); - assert.ok( - codexService.calls[0].includes('代码完成'), - 'codex prompt should include opus response content in debug mode', - ); - }); - - it('A2A cat does NOT receive previousResponses in play mode (thinking isolation)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const codexService = createCapturingService('codex', '已审查'); - const deps = createMockDeps({ - opus: createMockService('opus', '代码完成\n@缅因猫 请review'), - codex: codexService, - }); - - // Explicitly set play mode — cats should not see each other's thinking (default is now debug) - for await (const _ of routeSerial( - deps, - ['opus'], - 'write code', - 'user1', - 'thread1', - withClaimedA2ASlot({ thinkingMode: 'play' }), - )) { - } - - assert.equal(codexService.calls.length, 1, 'codex should be called once'); - assert.ok( - !codexService.calls[0].includes('代码完成'), - 'codex prompt should NOT include opus response content in play mode', - ); - }); - - it('isFinal is true only on the last done in the chain', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '好的\n@缅因猫 帮忙'), - codex: createMockService('codex', '搞定了'), - }); - - const messages = []; - for await (const msg of routeSerial(deps, ['opus'], 'help', 'user1', 'thread1', withClaimedA2ASlot())) { - messages.push(msg); - } - - const doneMsgs = messages.filter((m) => m.type === 'done'); - assert.ok(doneMsgs.length >= 2, 'should have done from both cats'); - // First done (opus) should NOT be isFinal - const opusDone = doneMsgs.find((m) => m.catId === 'opus'); - assert.ok(!opusDone.isFinal, 'opus done should not be isFinal'); - // Last done (codex) should be isFinal - const codexDone = doneMsgs.find((m) => m.catId === 'codex'); - assert.ok(codexDone.isFinal, 'codex done (chain end) should be isFinal'); - }); - - it('does not extend worklist beyond maxA2ADepth', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - // opus mentions codex, codex mentions gemini, gemini mentions opus - // With maxA2ADepth=1, only first A2A hop should trigger - const deps = createMockDeps({ - opus: createMockService('opus', '看看吧\n@缅因猫 帮忙'), - codex: createMockService('codex', '需要设计\n@暹罗猫 帮忙设计'), - gemini: createMockService('gemini', '设计好了'), - }); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'test', - 'user1', - 'thread1', - withClaimedA2ASlot({ maxA2ADepth: 1 }), - )) { - messages.push(msg); - } - - // Only opus + codex should produce text (depth=1 allows 1 hop) - const catIds = [...new Set(messages.filter((m) => m.type === 'text').map((m) => m.catId))]; - assert.ok(catIds.includes('opus'), 'opus should have text'); - assert.ok(catIds.includes('codex'), 'codex should be invoked (1st hop)'); - assert.ok(!catIds.includes('gemini'), 'gemini should NOT be invoked (2nd hop blocked by depth=1)'); - }); - - it('does not extend A2A worklist when queue has queued user messages', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '我先回复\n@缅因猫 帮忙继续'), - codex: createMockService('codex', 'should not run when queue pending'), - }); - - const messages = []; - for await (const msg of routeSerial(deps, ['opus'], 'test', 'user1', 'thread1', { - queueHasQueuedMessages: () => true, - })) { - messages.push(msg); - } - - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.equal(codexText.length, 0, 'A2A should yield to queued user messages'); - - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 0, 'should not emit handoff when fairness guard blocks extension'); - }); - - it('defers A2A handoff via callback when fairness gate blocks (F185-B AC-B3/B6)', async () => { +describe('routeSerial message wake and incremental context', () => { + it('publishes a line-start mention through the completed response wake without inline execution', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const inlineTargetCalls = []; const deps = createMockDeps({ opus: createMockService('opus', '代码完成\n@缅因猫 请 review'), - codex: createMockService('codex', 'should not run inline'), - }); - - const deferredEntries = []; - const messages = []; - for await (const msg of routeSerial(deps, ['opus'], 'test', 'user1', 'thread1', { - queueHasQueuedMessages: () => true, - deferA2AEnqueue: (entry) => deferredEntries.push(entry), - })) { - messages.push(msg); - } - - // Codex must NOT execute inline - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.equal(codexText.length, 0, 'codex must not run inline when fairness gate blocks'); - - // But deferred enqueue callback must be called with correct metadata - assert.equal(deferredEntries.length, 1, 'exactly one deferred A2A entry expected'); - const deferred = deferredEntries[0]; - assert.deepEqual(deferred.targetCats, ['codex'], 'deferred target must be codex'); - assert.equal(deferred.source, 'agent', 'deferred entry source must be agent'); - assert.equal(deferred.sourceCategory, 'a2a', 'deferred entry sourceCategory must be a2a'); - assert.equal(deferred.callerCatId, 'opus', 'deferred entry callerCatId must be opus'); - assert.ok(deferred.content, 'deferred entry must carry caller content (storedContent)'); - assert.equal(deferred.autoExecute, true, 'deferred entry must have autoExecute=true'); - assert.equal(deferred.priority, 'normal', 'deferred entry priority must be normal'); - }); - - it('defers A2A enqueue when signal is aborted without user reason (#813 seal recovery)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const ac = new AbortController(); - const deps = createMockDeps({ - opus: { + codex: { async *invoke() { - yield { type: 'text', catId: 'opus', content: '完成\n@缅因猫 帮忙', timestamp: Date.now() }; - ac.abort(); // no reason → seal/context-exhaustion case - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + inlineTargetCalls.push(true); + yield { type: 'done', catId: 'codex', timestamp: Date.now() }; }, }, - codex: createMockService('codex', 'should not run inline'), }); - - const deferredEntries = []; + const commits = []; const messages = []; - for await (const msg of routeSerial(deps, ['opus'], 'test', 'user1', 'thread1', { - queueHasQueuedMessages: () => true, - deferA2AEnqueue: (entry) => deferredEntries.push(entry), - signal: ac.signal, - })) { - messages.push(msg); - } - assert.equal(deferredEntries.length, 1, 'abort without user reason must defer @mention for seal recovery'); - }); - - it('does not defer A2A enqueue when signal is aborted by user_cancel (P2 gate)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const ac = new AbortController(); - const deps = createMockDeps({ - opus: { - async *invoke() { - yield { type: 'text', catId: 'opus', content: '完成\n@缅因猫 帮忙', timestamp: Date.now() }; - ac.abort('user_cancel'); // user explicitly stopped the flow - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + for await (const msg of routeSerial(deps, ['opus'], 'write code', 'user1', 'thread1', { + ownerAuthProvenance: 'unknown', + parentInvocationId: 'parent-route-serial', + onLifecycleInvocationStarted: async (input) => ({ + responseMessageId: 'response-route-serial', + priorFrontierMessageId: null, + activeRun: { + threadId: input.threadId, + targetId: input.catId, + invocationId: input.invocationId, + responseMessageId: 'response-route-serial', + inputEntryIds: [], + inputMessageIds: [], + privateInputEntryIds: [], + startedAt: input.startedAt, }, + }), + commitCompletedA2AWake: async (input) => { + commits.push(input); + return { ...input.message, id: input.responseMessageId }; }, - codex: createMockService('codex', 'should not run'), - }); - - const deferredEntries = []; - const messages = []; - for await (const msg of routeSerial(deps, ['opus'], 'test', 'user1', 'thread1', { - queueHasQueuedMessages: () => true, - deferA2AEnqueue: (entry) => deferredEntries.push(entry), - signal: ac.signal, - })) { - messages.push(msg); - } - - assert.equal(deferredEntries.length, 0, 'user_cancel abort must suppress A2A recovery'); - }); - - it('does not defer A2A enqueue for cat already in pendingTail (cloud P1-2)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '完成\n@缅因猫 请review'), - codex: createMockService('codex', 'already in targets, should not be deferred'), - }); - - const deferredEntries = []; - const messages = []; - // codex is already in targetCats → it's in pendingTail when opus runs - for await (const msg of routeSerial(deps, ['opus', 'codex'], 'test', 'user1', 'thread1', { - queueHasQueuedMessages: () => true, - deferA2AEnqueue: (entry) => deferredEntries.push(entry), })) { messages.push(msg); } - assert.equal(deferredEntries.length, 0, 'must not defer-enqueue cat already in pendingTail'); - // codex should still run from worklist (inline execution) - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.ok(codexText.length > 0, 'codex must still run from worklist even when fairness gate blocks deferred'); - }); - - it('does not call deferA2AEnqueue when fairness gate is clear (F185-B AC-B8)', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '看看吧\n@缅因猫 帮忙'), - codex: createMockService('codex', '收到'), - }); - - const deferredEntries = []; - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'test', - 'user1', - 'thread1', - withClaimedA2ASlot({ - queueHasQueuedMessages: () => false, - deferA2AEnqueue: (entry) => deferredEntries.push(entry), - }), - )) { - messages.push(msg); - } - - // Codex SHOULD execute inline (gate is clear) - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.ok(codexText.length > 0, 'codex must run inline when fairness gate is clear'); - - // No deferred entries - assert.equal(deferredEntries.length, 0, 'no deferred enqueue when gate is clear'); - }); - - it('durably defers a second A2A when the target is already executing queued agent work', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const deps = createMockDeps({ - opus: createMockService('opus', '代码完成\n@缅因猫 请 review'), - codex: createMockService('codex', 'should not be invoked via text-scan'), - }); - - const deferredEntries = []; - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'test', - 'user1', - 'thread1', - withClaimedA2ASlot({ - hasQueuedOrActiveAgentForCat: (_tid, catId) => catId === 'codex', - trackA2ASlot: () => false, - deferA2AEnqueue: (entry) => { - deferredEntries.push(entry); - return { outcome: 'enqueued', entry }; - }, - }), - )) { - messages.push(msg); - } - - const codexText = messages.filter((m) => m.type === 'text' && m.catId === 'codex'); - assert.equal(codexText.length, 0, 'the busy target must not start in the current route'); - assert.equal(deferredEntries.length, 1, 'the second intent must gain one durable Queue responsibility'); - assert.deepEqual(deferredEntries[0].targetCats, ['codex']); - assert.equal(deferredEntries[0].source, 'agent'); - assert.equal(deferredEntries[0].sourceCategory, 'a2a'); - assert.equal(deferredEntries[0].autoExecute, true); - assert.match(deferredEntries[0].content, /请 review/); - - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 0, 'deferred responsibility must not masquerade as an inline start'); + assert.equal(inlineTargetCalls.length, 0, 'the mentioned target must not execute inside the current route'); + assert.equal(commits.length, 1, 'the completed response must publish exactly one durable wake'); + assert.deepEqual(commits[0].targetCats, ['codex']); + assert.deepEqual(commits[0].message.mentions, ['codex']); + assert.equal( + messages.some((message) => message.type === 'a2a_handoff'), + false, + ); }); it('self-mention does not trigger A2A', async () => { @@ -2322,154 +2023,6 @@ describe('routeSerial A2A worklist', () => { assert.equal(codexText.length, 0, 'codex should not be invoked after abort'); }); - it('stores mentions correctly in messageStore.append', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const appendCalls = []; - const deps = createMockDeps( - { - opus: createMockService('opus', '写完了\n@缅因猫 帮review'), - codex: createMockService('codex', '审查完毕'), - }, - appendCalls, - ); - - for await (const _ of routeSerial(deps, ['opus'], 'code', 'user1', 'thread1', withClaimedA2ASlot())) { - } - - // opus's stored message should have mentions: ['codex'] - const opusAppend = appendCalls.find((c) => c.catId === 'opus'); - assert.ok(opusAppend, 'opus response should be stored'); - assert.deepEqual(opusAppend.mentions, ['codex'], 'opus mentions should include codex'); - - // codex's stored message (no mention in response) → mentions: [] - const codexAppend = appendCalls.find((c) => c.catId === 'codex'); - assert.ok(codexAppend, 'codex response should be stored'); - assert.deepEqual(codexAppend.mentions, [], 'codex mentions should be empty'); - }); - - it('supports 2-hop A2A chain: user→A→B→A', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - let opusCallCount = 0; - const deps = createMockDeps({ - opus: { - async *invoke() { - opusCallCount++; - if (opusCallCount === 1) { - yield { type: 'text', catId: 'opus', content: '写好了\n@缅因猫 review', timestamp: Date.now() }; - } else { - yield { type: 'text', catId: 'opus', content: '已修复', timestamp: Date.now() }; - } - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; - }, - }, - codex: createMockService('codex', '有bug\n@布偶猫 请修复'), - }); - - const messages = []; - for await (const msg of routeSerial( - deps, - ['opus'], - 'implement feature', - 'user1', - 'thread1', - withClaimedA2ASlot({ maxA2ADepth: 2 }), - )) { - messages.push(msg); - } - - // Chain: opus → codex → opus (2 hops) - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 2, 'should have 2 A2A handoffs'); - assert.equal(opusCallCount, 2, 'opus should be called twice'); - }); - - it('incremental play mode delivers the exact A2A trigger and overlays the deferred cursor on same-cat re-entry', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const initialMessages = [ - { - id: '0000000000000001-000001-aaaaaaaa', - threadId: 'thread1', - userId: 'user1', - catId: null, - content: 'OLD USER MESSAGE ONE', - mentions: [], - timestamp: 1_000, - visibilitySeq: 1, - }, - { - id: '0000000000000002-000001-bbbbbbbb', - threadId: 'thread1', - userId: 'user1', - catId: null, - content: 'OLD USER MESSAGE TWO', - mentions: ['opus'], - timestamp: 2_000, - visibilitySeq: 2, - }, - ]; - const storedMessages = [...initialMessages]; - let nextVisibilitySeq = 3; - const opusService = createSequentialCapturingService('opus', [ - 'OPUS EXACT DIRECT BODY\n@\u7f05\u56e0\u732b review', - 'fixed', - ]); - const codexService = createOptionsCapturingService('codex', 'CODEX EXACT DIRECT BODY\n@\u5e03\u5076\u732b fix'); - const deps = createMockDeps({ opus: opusService, codex: codexService }); - deps.deliveryCursorStore = { - getCursor: async () => undefined, - ackCursor: async () => {}, - ackSeenCursor: async () => {}, - }; - deps.messageStore.append = async (input) => { - const visibilitySeq = nextVisibilitySeq++; - const stored = { - ...input, - id: `000000000000000${visibilitySeq}-000001-${String(visibilitySeq).padStart(8, '0')}`, - timestamp: visibilitySeq * 1_000, - visibilitySeq, - }; - storedMessages.push(stored); - return stored; - }; - deps.messageStore.getById = async (id) => storedMessages.find((candidate) => candidate.id === id); - deps.messageStore.getByThreadAfter = async (_threadId, afterCursor) => { - const afterSeq = afterCursor?.startsWith('v2:') ? Number(afterCursor.slice(3, 19)) : 0; - return storedMessages.filter((candidate) => candidate.visibilitySeq > afterSeq); - }; - - const cursorBoundaries = new Map(); - for await (const _ of routeSerial( - deps, - ['opus'], - 'OLD USER MESSAGE TWO', - 'user1', - 'thread1', - withClaimedA2ASlot({ - currentUserMessageId: initialMessages[1].id, - cursorBoundaries, - maxA2ADepth: 2, - thinkingMode: 'play', - }), - )) { - /* drain */ - } - - assert.equal(codexService.calls.length, 1, 'codex should be invoked by opus exactly once'); - assert.ok( - codexService.calls[0].prompt.includes('OPUS EXACT DIRECT BODY'), - 'the exact persisted A2A trigger body must survive play-mode stream isolation', - ); - assert.equal(opusService.calls.length, 2, 'opus should be re-entered after codex replies'); - assert.ok( - opusService.calls[1].includes('CODEX EXACT DIRECT BODY'), - 'same-cat re-entry must receive the new exact A2A trigger body', - ); - assert.ok( - !opusService.calls[1].includes('OLD USER MESSAGE ONE') && !opusService.calls[1].includes('OLD USER MESSAGE TWO'), - 'same-cat re-entry must read after the in-flight deferred cursor instead of replaying old user messages', - ); - }); - it('incremental play mode does not expose or receipt an invisible A2A trigger', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const codexService = createOptionsCapturingService('codex', 'ack'); @@ -2512,6 +2065,7 @@ describe('routeSerial A2A worklist', () => { for await (const _ of routeSerial(deps, ['codex'], 'VISIBLE USER MESSAGE', 'user1', 'thread1', { currentUserMessageId: userMessageId, a2aTriggerMessageId: whisperTriggerId, + a2aCallerCatId: 'opus', thinkingMode: 'play', })) { /* drain */ @@ -2523,6 +2077,11 @@ describe('routeSerial A2A worklist', () => { !codexService.calls[0].options.recoveryAnchor?.promptMessageIds.includes(whisperTriggerId), 'an invisible trigger must not earn an exact body-exposure receipt', ); + assert.match( + codexService.calls[0].prompt, + /Direct message from .*\(opus\)/, + 'Queue caller provenance must reach the child prompt without exposing the trigger body', + ); }); it('incremental mode: falls back to explicit user message when current message is missing from incremental context', async () => { @@ -2668,44 +2227,26 @@ describe('routeSerial A2A worklist', () => { assert.ok(!prompt.includes('SECRET'), 'whisper content must NOT leak via fallback injection'); }); - it('line-start @mention always routes without keyword gate (no suppression feedback)', async () => { + it('does not inject the removed one-shot routing feedback', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); const threadStore = new ThreadStore(); const thread = threadStore.create('user1', 'no suppression'); - const opusService = createCapturingService('opus', '收到'); - const codexService = createSequentialCapturingService('codex', ['@布偶猫', '第二次调用']); - const deps = createMockDeps({ codex: codexService, opus: opusService }, undefined, threadStore); + const codexService = createSequentialCapturingService('codex', ['第一次调用', '第二次调用']); + const deps = createMockDeps({ codex: codexService }, undefined, threadStore); - const messages = []; - for await (const msg of routeSerial( - deps, - ['codex'], - 'first', - 'user1', - thread.id, - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { - messages.push(msg); + for await (const _ of routeSerial(deps, ['codex'], 'first', 'user1', thread.id, { + thinkingMode: 'debug', + })) { } - - // @布偶猫 alone should now trigger A2A handoff (no keyword required) - const handoffs = messages.filter((m) => m.type === 'a2a_handoff'); - assert.equal(handoffs.length, 1, 'bare @布偶猫 should trigger A2A handoff without action keywords'); - - // Second invocation should NOT have any routing feedback (suppression system removed) - for await (const _ of routeSerial( - deps, - ['codex'], - 'second', - 'user1', - thread.id, - withClaimedA2ASlot({ thinkingMode: 'debug' }), - )) { + for await (const _ of routeSerial(deps, ['codex'], 'second', 'user1', thread.id, { + thinkingMode: 'debug', + })) { } + assert.ok( !codexService.calls[1].includes('Routing feedback(one-shot):'), - 'no routing feedback should be injected (suppression system removed)', + 'removed routing feedback must not be injected', ); }); @@ -2722,7 +2263,7 @@ describe('routeSerial A2A worklist', () => { for await (const _ of routeSerial(deps, ['opus'], 'markdown test', 'user1', 'thread1')) { } - const saved = appendCalls.find((c) => c.catId === 'opus'); + const saved = appendCalls.find((c) => c.from?.kind === 'agent' && c.from.catId === 'opus'); assert.ok(saved, 'stored message should exist'); assert.equal(saved.content, '章节A\n---\n章节B', 'sanitizer must not remove normal markdown separator lines'); }); @@ -3775,7 +3316,7 @@ describe('routeParallel tool events persistence', () => { } // opus message should have toolEvents - const opusAppend = appendCalls.find((c) => c.catId === 'opus'); + const opusAppend = appendCalls.find((c) => c.from?.kind === 'agent' && c.from.catId === 'opus'); assert.ok(opusAppend, 'opus message should be appended'); assert.ok(opusAppend.toolEvents, 'opus should have toolEvents'); assert.equal(opusAppend.toolEvents.length, 2); @@ -3783,7 +3324,7 @@ describe('routeParallel tool events persistence', () => { assert.equal(opusAppend.toolEvents[1].type, 'tool_result'); // codex message should NOT have toolEvents (no tool usage) - const codexAppend = appendCalls.find((c) => c.catId === 'codex'); + const codexAppend = appendCalls.find((c) => c.from?.kind === 'agent' && c.from.catId === 'codex'); assert.ok(codexAppend, 'codex message should be appended'); assert.ok(!codexAppend.toolEvents, 'codex should not have toolEvents'); }); @@ -3808,7 +3349,7 @@ describe('routeParallel tool events persistence', () => { } // Even though opus had no text, it should still be persisted with tool events - const opusAppend = appendCalls.find((c) => c.catId === 'opus'); + const opusAppend = appendCalls.find((c) => c.from?.kind === 'agent' && c.from.catId === 'opus'); assert.ok(opusAppend, 'tool-only cat should still be persisted'); assert.equal(opusAppend.content, '', 'content should be empty'); assert.ok(opusAppend.toolEvents, 'should have toolEvents'); @@ -4214,7 +3755,7 @@ describe('routeParallel A2A safety', () => { // F167 L2 AC-A5: mentions must be persisted as [] in parallel mode so that // MessageStore.getMentionsFor / pending-mentions flow does NOT surface parallel @ messages. // The raw @ tokens are still captured in the `suppressedMentions` log for observability. - const opusAppend = appendCalls.find((c) => c.catId === 'opus'); + const opusAppend = appendCalls.find((c) => c.from?.kind === 'agent' && c.from.catId === 'opus'); assert.ok(opusAppend, 'opus response should be stored'); assert.deepEqual(opusAppend.mentions, [], 'AC-A5: parallel-mode mentions must be []'); }); @@ -4279,9 +3820,9 @@ describe('routeSerial: CLI error without text should not persist empty message ( assert.ok(errorMsgs.length > 0, 'error message should be yielded to frontend'); // Error-only response: no cat message, error persisted as system message - const catAppends = appendCalls.filter((c) => c.catId === 'codex'); + const catAppends = appendCalls.filter((c) => c.from?.kind === 'agent' && c.from.catId === 'codex'); assert.equal(catAppends.length, 0, 'error-only should NOT persist as cat message'); - const sysAppends = appendCalls.filter((c) => c.userId === 'system' && c.catId === null); + const sysAppends = appendCalls.filter((c) => c.from?.kind === 'system' && c.from.service === 'agent-error'); assert.equal(sysAppends.length, 1, 'error should be persisted as system message'); assert.ok(sysAppends[0].content.startsWith('Error:'), 'system error should start with Error: prefix'); @@ -4303,7 +3844,7 @@ describe('routeSerial: CLI error without text should not persist empty message ( for await (const _ of routeSerial(deps, ['codex'], 'test', 'user1', 'thread1')) { } - const catAppends = appendCalls.filter((c) => c.catId === 'codex'); + const catAppends = appendCalls.filter((c) => c.from?.kind === 'agent' && c.from.catId === 'codex'); assert.equal(catAppends.length, 1, 'normal response should be persisted'); assert.equal(catAppends[0].content, 'normal response'); }); @@ -4328,7 +3869,7 @@ describe('routeSerial: CLI error without text should not persist empty message ( } // Partial text persisted as cat message (clean, no [错误] contamination) - const catAppends = appendCalls.filter((c) => c.catId === 'codex'); + const catAppends = appendCalls.filter((c) => c.from?.kind === 'agent' && c.from.catId === 'codex'); assert.equal(catAppends.length, 1, 'partial response with text should still be persisted'); assert.equal( catAppends[0].content, @@ -4336,7 +3877,7 @@ describe('routeSerial: CLI error without text should not persist empty message ( 'cat content should be clean text without error suffix', ); // Error persisted separately as system message - const sysAppends = appendCalls.filter((c) => c.userId === 'system' && c.catId === null); + const sysAppends = appendCalls.filter((c) => c.from?.kind === 'system' && c.from.service === 'agent-error'); assert.equal(sysAppends.length, 1, 'error should be persisted as separate system message'); assert.ok(sysAppends[0].content.includes('timeout'), 'system error should contain error text'); }); @@ -4379,7 +3920,7 @@ describe('routeSerial: done-only (no text, no error)', () => { })) { } - const catAppends = appendCalls.filter((c) => c.catId === 'codex'); + const catAppends = appendCalls.filter((c) => c.from?.kind === 'agent' && c.from.catId === 'codex'); assert.equal(catAppends.length, 0, 'done-only cat should not persist a blank message'); }); @@ -4474,7 +4015,7 @@ describe('routeSerial: done-only (no text, no error)', () => { const doneMsgs = messages.filter((m) => m.type === 'done'); assert.equal(doneMsgs.length, 1, 'silent cat should still produce one done event'); assert.equal(doneMsgs[0].isFinal, true, 'silent single-cat run should mark done as final'); - const catAppends = appendCalls.filter((c) => c.catId === 'codex'); + const catAppends = appendCalls.filter((c) => c.from?.kind === 'agent' && c.from.catId === 'codex'); assert.equal(catAppends.length, 0, 'silent cat should not persist blank content'); }); }); @@ -4498,7 +4039,7 @@ describe('routeParallel: done-only (no text, no error)', () => { const doneMsgs = messages.filter((m) => m.type === 'done'); assert.equal(doneMsgs.length, 1, 'silent parallel cat should still produce one done event'); assert.equal(doneMsgs[0].isFinal, true, 'silent parallel single-cat run should mark done as final'); - const catAppends = appendCalls.filter((c) => c.catId === 'codex'); + const catAppends = appendCalls.filter((c) => c.from?.kind === 'agent' && c.from.catId === 'codex'); assert.equal(catAppends.length, 0, 'silent parallel cat should not persist blank content'); }); diff --git a/packages/api/test/routes/message-disposition-preference-route.test.js b/packages/api/test/routes/message-disposition-preference-route.test.js index bcaec6d83a..ab0287025a 100644 --- a/packages/api/test/routes/message-disposition-preference-route.test.js +++ b/packages/api/test/routes/message-disposition-preference-route.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, before, beforeEach, describe, it } from 'node:test'; @@ -29,7 +29,7 @@ describe('GET/PUT /api/config/message-disposition (F264)', () => { await rm(join(projectRoot, '.cat-cafe'), { recursive: true, force: true }); }); - it('defaults to next-work and reports the product source', async () => { + it('defaults to queued work and reports the product source', async () => { const res = await app.inject({ method: 'GET', url: '/api/config/message-disposition?threadId=thread-a', @@ -42,7 +42,6 @@ describe('GET/PUT /api/config/message-disposition (F264)', () => { thread: null, effective: 'next_work', source: 'product', - onboardingSeen: false, }); }); @@ -68,7 +67,6 @@ describe('GET/PUT /api/config/message-disposition (F264)', () => { thread: 'next_work', effective: 'next_work', source: 'thread', - onboardingSeen: false, }); res = await app.inject({ @@ -82,28 +80,6 @@ describe('GET/PUT /api/config/message-disposition (F264)', () => { assert.equal(JSON.parse(res.payload).source, 'global'); }); - it('persists onboarding monotonically and preserves unrelated preferences', async () => { - const headers = { 'x-cat-cafe-user': OWNER_ID }; - await app.inject({ - method: 'PUT', - url: '/api/config/cat-order', - headers, - payload: { catOrder: [] }, - }); - const res = await app.inject({ - method: 'PUT', - url: '/api/config/message-disposition', - headers, - payload: { scope: 'onboarding', seen: true }, - }); - - assert.equal(res.statusCode, 200, res.payload); - assert.equal(JSON.parse(res.payload).onboardingSeen, true); - const raw = JSON.parse(await readFile(join(projectRoot, '.cat-cafe', 'user-preferences.json'), 'utf-8')); - assert.deepEqual(raw.catOrder, []); - assert.equal(raw.messageDisposition.onboardingSeen, true); - }); - it('rejects non-owner writes and invalid dispositions', async () => { const forbidden = await app.inject({ method: 'PUT', @@ -120,5 +96,13 @@ describe('GET/PUT /api/config/message-disposition (F264)', () => { payload: { scope: 'global', disposition: 'magic' }, }); assert.equal(invalid.statusCode, 400); + + const retiredOnboarding = await app.inject({ + method: 'PUT', + url: '/api/config/message-disposition', + headers: { 'x-cat-cafe-user': OWNER_ID }, + payload: { scope: 'onboarding', seen: true }, + }); + assert.equal(retiredOnboarding.statusCode, 400); }); }); diff --git a/packages/api/test/routing-decision-streak.test.js b/packages/api/test/routing-decision-streak.test.js deleted file mode 100644 index 92d337c3c3..0000000000 --- a/packages/api/test/routing-decision-streak.test.js +++ /dev/null @@ -1,78 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -/** - * F216 c1.1: peekStreakOnPush — pure-read streak prediction. - * - * The decision layer (resolveRoutingDecisions) must predict whether pushing a cat would trip the - * ping-pong breaker WITHOUT mutating worklistEntry.streakPair. updateStreakOnPush mutates (count++ / - * reset) — that side effect belongs in the execution layer. peekStreakOnPush computes the same - * verdict read-only so the decision function stays pure (砚砚 OQ3: no side effects in the decision). - */ - -const REG_PATH = '../dist/domains/cats/services/agents/routing/WorklistRegistry.js'; - -function makeEntry() { - return { - list: ['opus'], - originalCount: 1, - a2aCount: 0, - maxDepth: 10, - executedIndex: 0, - a2aFrom: new Map(), - a2aTriggerMessageId: new Map(), - // no streakPair yet - }; -} - -describe('peekStreakOnPush (F216 c1.1) — pure read, no mutation', () => { - test('does NOT mutate entry.streakPair', async () => { - const { peekStreakOnPush } = await import(REG_PATH); - const entry = makeEntry(); - entry.streakPair = { from: 'opus', to: 'codex', count: 3 }; - const before = { ...entry.streakPair }; - peekStreakOnPush(entry, 'opus', 'codex', { hadSubstantiveToolCall: false, outputLength: 10 }); - assert.deepEqual(entry.streakPair, before, 'peek must not change streakPair (no count++, no reset)'); - }); - - test('predicts wouldBlock at the block threshold (same pair, inertia → would be count 4)', async () => { - const { peekStreakOnPush } = await import(REG_PATH); - const entry = makeEntry(); - entry.streakPair = { from: 'opus', to: 'codex', count: 3 }; // next inertia push → 4 = block - const r = peekStreakOnPush(entry, 'opus', 'codex', { hadSubstantiveToolCall: false, outputLength: 10 }); - assert.equal(r.wouldBlock, true, 'count 3 + inertia push → 4 ≥ block threshold'); - assert.equal(r.count, 4, 'predicted post-push count'); - }); - - test('predicts NO block when substantive activity resets the streak', async () => { - const { peekStreakOnPush } = await import(REG_PATH); - const entry = makeEntry(); - entry.streakPair = { from: 'opus', to: 'codex', count: 3 }; - // substantive (long output) → reset to 1, not 4 - const r = peekStreakOnPush(entry, 'opus', 'codex', { hadSubstantiveToolCall: true, outputLength: 10 }); - assert.equal(r.wouldBlock, false, 'substantive work breaks inertia → no block'); - assert.equal(r.count, 1, 'substantive resets predicted count to 1'); - }); - - test('predicts count 1 for a fresh pair (no prior streak)', async () => { - const { peekStreakOnPush } = await import(REG_PATH); - const entry = makeEntry(); // no streakPair - const r = peekStreakOnPush(entry, 'opus', 'gemini', { hadSubstantiveToolCall: false, outputLength: 10 }); - assert.equal(r.count, 1, 'new pair starts at 1'); - assert.equal(r.wouldBlock, false); - }); - - test('peek result matches what updateStreakOnPush would actually produce (parity)', async () => { - const { peekStreakOnPush, updateStreakOnPush } = await import(REG_PATH); - // two identical entries; peek one, mutate the other; verdicts must match - const peeked = makeEntry(); - peeked.streakPair = { from: 'opus', to: 'codex', count: 2 }; - const mutated = makeEntry(); - mutated.streakPair = { from: 'opus', to: 'codex', count: 2 }; - const activity = { hadSubstantiveToolCall: false, outputLength: 10 }; - const peek = peekStreakOnPush(peeked, 'opus', 'codex', activity); - const real = updateStreakOnPush(mutated, 'opus', 'codex', activity); - assert.equal(peek.wouldBlock, real.blockPingPong, 'block verdict parity'); - assert.equal(peek.count, real.count, 'count parity'); - }); -}); diff --git a/packages/api/test/routing-decision.test.js b/packages/api/test/routing-decision.test.js deleted file mode 100644 index 6940991faf..0000000000 --- a/packages/api/test/routing-decision.test.js +++ /dev/null @@ -1,187 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -/** - * F216 c1.2: resolveRoutingDecisions — PURE decision function. - * - * Unifies the depth/dedup/streak/pendingTail guards that today are duplicated across the inline- - * mention and relay branches of routeSerial. Returns RoutingDecision[] (structured verdicts); the - * execution layer applies side effects (worklist.push, updateStreakOnPush, span, yield). No mutation - * here — streak is predicted read-only via ctx.peekStreak (砚砚 OQ3, plan 约束1). - */ - -const PATH = '../dist/domains/cats/services/agents/routing/routing-decision.js'; - -/** Build a RoutingContext with sane defaults; override per-test. */ -function ctx(over = {}) { - return { - a2aCount: 0, - maxDepth: 10, - aborted: false, - queuedMessagesPending: false, - pendingTail: [], - pendingOriginalTargets: [], - hasActiveAgent: () => false, - // default: never blocks, predicted count 1 - peekStreak: () => ({ wouldBlock: false, count: 1 }), - ...over, - }; -} - -describe('resolveRoutingDecisions — inline_mention', () => { - test('enqueues a fresh mentioned cat (happy path)', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx(), - ); - assert.deepEqual(out, [{ action: 'enqueue_worklist', cat: 'codex' }]); - }); - - test('skip:aborted when signal aborted', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx({ aborted: true }), - ); - assert.deepEqual(out, [{ action: 'skip', cat: 'codex', reason: 'aborted' }]); - }); - - test('skip:queue_pending when non-agent messages pending (fairness gate)', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx({ queuedMessagesPending: true }), - ); - assert.deepEqual(out, [{ action: 'defer_queue', cat: 'codex' }]); - }); - - test('skip:depth when a2aCount >= maxDepth', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx({ a2aCount: 10, maxDepth: 10 }), - ); - assert.deepEqual(out, [{ action: 'skip', cat: 'codex', reason: 'depth' }]); - }); - - test('defers when target is already processing in InvocationQueue', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx({ hasActiveAgent: (c) => c === 'codex' }), - ); - assert.deepEqual(out, [{ action: 'defer_queue', cat: 'codex' }]); - }); - - test('mark_replyto when cat already in pendingTail but NOT an original target', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx({ pendingTail: ['codex'], pendingOriginalTargets: [] }), - ); - assert.deepEqual(out, [{ action: 'mark_replyto', cat: 'codex' }]); - }); - - test('SKIPS entirely (empty decision) when cat is a pending ORIGINAL target (replies to user)', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx({ pendingTail: ['codex'], pendingOriginalTargets: ['codex'] }), - ); - // original target stays replying to user — no decision emitted for it - assert.deepEqual(out, []); - }); - - test('block_pingpong when peekStreak says wouldBlock', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, - ctx({ peekStreak: () => ({ wouldBlock: true, count: 4 }) }), - ); - assert.deepEqual(out, [{ action: 'block_pingpong', cat: 'codex', pairCount: 4 }]); - }); - - test('multi-cat: per-target decisions in order', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex', 'gemini'], content: 'hi', callerCatId: 'opus' }, - ctx({ hasActiveAgent: (c) => c === 'codex' }), - ); - assert.deepEqual(out, [ - { action: 'defer_queue', cat: 'codex' }, - { action: 'enqueue_worklist', cat: 'gemini' }, - ]); - }); - - test('depth budget consumed across multi-cat: 2nd cat hits depth after 1st enqueues', async () => { - const { resolveRoutingDecisions } = await import(PATH); - // a2aCount starts at maxDepth-1: first enqueue consumes the last slot, second hits depth - const out = resolveRoutingDecisions( - { type: 'inline_mention', cats: ['codex', 'gemini'], content: 'hi', callerCatId: 'opus' }, - ctx({ a2aCount: 9, maxDepth: 10 }), - ); - assert.deepEqual(out, [ - { action: 'enqueue_worklist', cat: 'codex' }, - { action: 'skip', cat: 'gemini', reason: 'depth' }, - ]); - }); - - // 砚砚 review PR#1991 P2: a defer_queue is ALSO an A2A route slot (it enqueues a handoff, just - // behind non-agent messages), so it must consume the depth budget too — otherwise a batch resolve - // could emit unlimited defer_queue past maxDepth. The execution layer happens to call one cat at a - // time, but the PURE function's contract must be self-consistent for any input. - test('depth budget consumed by defer_queue too: 2nd deferred cat hits depth after 1st defers', async () => { - const { resolveRoutingDecisions } = await import(PATH); - // queuedMessagesPending=true → every cat resolves to defer_queue. a2aCount=maxDepth-1 means the - // first defer consumes the last slot; the second must hit depth, NOT emit a 2nd defer_queue. - const out = resolveRoutingDecisions( - { type: 'deferred', cats: ['codex', 'gemini'], content: 'hi', callerCatId: 'opus' }, - ctx({ a2aCount: 9, maxDepth: 10, queuedMessagesPending: true }), - ); - assert.deepEqual(out, [ - { action: 'defer_queue', cat: 'codex' }, - { action: 'skip', cat: 'gemini', reason: 'depth' }, - ]); - }); -}); - -describe('resolveRoutingDecisions — relay_malformed', () => { - test('enqueues relay cat when not already pending', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'relay_malformed', cat: 'opus', callerCatId: 'opus-48' }, - ctx({ pendingTail: [] }), - ); - assert.deepEqual(out, [{ action: 'enqueue_worklist', cat: 'opus' }]); - }); - - test('skip when relay cat already pending (F215 pending-only dedup,逐字保留)', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'relay_malformed', cat: 'opus', callerCatId: 'opus-48' }, - ctx({ pendingTail: ['opus'] }), - ); - assert.deepEqual(out, [{ action: 'skip', cat: 'opus', reason: 'dedup_active' }]); - }); - - test('relay does NOT consult streak/queue fairness (only depth + pending dedup)', async () => { - const { resolveRoutingDecisions } = await import(PATH); - // even with queuedMessagesPending + would-block streak, relay still enqueues (it is a recovery - // path, not subject to ping-pong / fairness gates that apply to text-scan mentions) - const out = resolveRoutingDecisions( - { type: 'relay_malformed', cat: 'opus', callerCatId: 'opus-48' }, - ctx({ queuedMessagesPending: true, peekStreak: () => ({ wouldBlock: true, count: 9 }) }), - ); - assert.deepEqual(out, [{ action: 'enqueue_worklist', cat: 'opus' }]); - }); - - test('relay skip:depth when at depth limit', async () => { - const { resolveRoutingDecisions } = await import(PATH); - const out = resolveRoutingDecisions( - { type: 'relay_malformed', cat: 'opus', callerCatId: 'opus-48' }, - ctx({ a2aCount: 10, maxDepth: 10 }), - ); - assert.deepEqual(out, [{ action: 'skip', cat: 'opus', reason: 'depth' }]); - }); -}); diff --git a/packages/api/test/routing-dispatch-preflight.test.js b/packages/api/test/routing-dispatch-preflight.test.js index 17534d73b3..922c3102f2 100644 --- a/packages/api/test/routing-dispatch-preflight.test.js +++ b/packages/api/test/routing-dispatch-preflight.test.js @@ -120,7 +120,7 @@ describe('F293 actual-send routing preflight', () => { ); for await (const event of route(deps, ['opus', 'codex'], 'request', 'owner-1', 'rejected-thread')) collector.observe(event); - assert.deepEqual(calls, []); + assert.equal(calls.length, 0, `${route.name} must not start a rejected child invocation: ${calls.join(',')}`); assert.deepEqual(collector.getSuccessfulCatIds(), []); assert.ok(collector.getPrimaryTerminalError(), `${route.name} must expose a retryable failure`); } @@ -253,7 +253,8 @@ describe('F293 actual-send routing preflight', () => { receipts.some((receipt) => receipt.target.targetCatId === 'opus' && receipt.target.disposition === 'rejected'), ); assert.ok( - receipts.some((receipt) => receipt.target.targetCatId === 'codex' && receipt.target.disposition === 'warned'), + !receipts.some((receipt) => receipt.target.targetCatId === 'codex'), + 'fail-open infrastructure degradation stays in routing telemetry instead of becoming chat content', ); }); @@ -270,61 +271,181 @@ describe('F293 actual-send routing preflight', () => { } assert.ok(calls.includes('opus')); - const receipt = events - .filter((event) => event.type === 'system_info' && event.content?.includes('routing_preflight')) - .map((event) => JSON.parse(event.content))[0]; - assert.equal(receipt.resolverState, 'degraded'); - assert.equal(receipt.target.targetCatId, 'opus'); - assert.equal(receipt.target.disposition, 'warned'); - assert.deepEqual(receipt.target.alternatives, []); + assert.equal( + events.filter((event) => event.type === 'system_info' && event.content?.includes('routing_preflight')).length, + 0, + 'an unavailable advisory resolver must not manufacture a visible warning for an unchanged send', + ); }); - test('serial deferred A2A checks fresh state before creating a queue entry', async () => { - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const calls = []; - const deferred = []; - const deps = routeDeps( + test('completed response preflights before atomic ledger admission and leaves no rejected row', async () => { + const { commitCompletedResponseAndEnqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const queue = new InvocationQueue(); + const messageStore = new MessageStore(); + const broadcasts = []; + const processing = messageStore.append({ + from: { kind: 'agent', catId: 'opus' }, + threadId: 'thread-deferred', + userId: 'owner-1', + content: '', + mentions: [], + origin: 'stream', + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: '0000000000100:response-opus', + from: { kind: 'agent', catId: 'opus' }, + invocationId: 'inv-opus', + targetId: 'opus', + inputEntryIds: ['entry-source'], + inputMessageIds: ['message-source'], + status: 'processing', + startedAt: 100, + }, + }); + const stored = await commitCompletedResponseAndEnqueueA2ATargets( { - opus: { - async *invoke() { - calls.push('opus'); - yield { type: 'text', catId: 'opus', content: '@codex\ncontinue from here', timestamp: Date.now() }; - yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + invocationQueue: queue, + messageStore, + queueProcessor: { async requestDrain() {} }, + socketManager: { + broadcastAgentMessage(message, threadId) { + broadcasts.push({ message, threadId }); }, + emitToUser() {}, }, - codex: service('codex', calls), + routingDispatchPreflight: { + preflight: async (input) => decision(input, { codex: 'rejected', terra: 'warned' }), + }, + log: { error() {}, warn() {}, info() {} }, }, - { preflight: async (input) => decision(input, { opus: 'allowed', codex: 'rejected' }) }, - ); - const events = []; - for await (const event of routeSerial(deps, ['opus'], 'start', 'owner-1', 'thread-deferred', { - queueHasQueuedMessages: () => true, - deferA2AEnqueue: (entry) => { - deferred.push(entry); - return { outcome: 'enqueued' }; + { + responseMessageId: processing.id, + invocationId: 'inv-opus', + terminal: { status: 'completed', completedAt: 200 }, + message: { + from: { kind: 'agent', catId: 'opus' }, + threadId: 'thread-deferred', + userId: 'owner-1', + content: '@codex\n@terra\ncontinue from here', + mentions: ['codex', 'terra'], + origin: 'stream', + timestamp: 200, + }, + targetCats: ['codex', 'terra'], + userId: 'owner-1', + ownerAuthProvenance: 'unknown', + threadId: 'thread-deferred', + callerCatId: 'opus', }, - })) { - events.push(event); - } + ); - assert.deepEqual(calls, ['opus']); - assert.deepEqual(deferred, [], 'rejected dynamic target must never enter InvocationQueue'); + assert.equal(stored.lifecycle.status, 'completed'); + assert.deepEqual( + queue.list('thread-deferred', 'owner-1').flatMap((entry) => entry.targets), + ['terra'], + ); assert.ok( - events.some( - (event) => - event.type === 'system_info' && - event.content?.includes('routing_preflight') && - JSON.parse(event.content).target.targetCatId === 'codex', + broadcasts.some( + ({ message }) => + message.type === 'system_info' && + message.content?.includes('routing_preflight') && + JSON.parse(message.content).target.targetCatId === 'codex', ), ); }); + test('failed A2A response atomically admits one idempotent Queue-only wake for the exact caller', async () => { + const { commitFailedResponseAndEnqueueA2ACaller } = await import('../dist/routes/callback-a2a-trigger.js'); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const queue = new InvocationQueue(); + const messageStore = new MessageStore(); + const processing = messageStore.append({ + from: { kind: 'agent', catId: 'codex' }, + threadId: 'thread-failed-a2a', + userId: 'owner-1', + content: '', + mentions: [], + origin: 'stream', + timestamp: 100, + lifecycle: { + kind: 'response', + orderKey: '0000000000100:response-codex', + from: { kind: 'agent', catId: 'codex' }, + invocationId: 'inv-codex', + targetId: 'codex', + inputEntryIds: ['entry-source'], + inputMessageIds: ['message-source'], + status: 'processing', + startedAt: 100, + }, + }); + const observedBusinessDispatches = []; + const deps = { + invocationQueue: queue, + messageStore, + queueProcessor: { + async requestDrain() {}, + registerCallerDispatchInitialTargets(source, targets) { + observedBusinessDispatches.push({ sourceId: source.id, targets }); + }, + }, + socketManager: { broadcastAgentMessage() {}, emitToUser() {} }, + log: { error() {}, warn() {}, info() {} }, + }; + const input = { + responseMessageId: processing.id, + invocationId: 'inv-codex', + terminal: { status: 'failed', completedAt: 200, reason: 'model_not_found' }, + message: { + from: { kind: 'agent', catId: 'codex' }, + threadId: 'thread-failed-a2a', + userId: 'owner-1', + content: 'configured model unavailable', + mentions: [], + origin: 'stream', + timestamp: 200, + }, + userId: 'owner-1', + ownerAuthProvenance: 'strict', + threadId: 'thread-failed-a2a', + reporterCatId: 'codex', + predecessorCatId: 'fable', + }; + + await commitFailedResponseAndEnqueueA2ACaller(deps, input); + await commitFailedResponseAndEnqueueA2ACaller(deps, input); + + const failed = messageStore.getById(processing.id); + assert.equal(failed.lifecycle.status, 'failed'); + assert.equal(failed.content, 'configured model unavailable'); + const rows = queue.list('thread-failed-a2a', 'owner-1'); + assert.equal(rows.length, 1); + assert.equal(rows[0].sourceCategory, 'a2a_failure'); + assert.deepEqual(rows[0].targets, ['fable']); + assert.equal(rows[0].payload.messageId, processing.id); + assert.deepEqual( + observedBusinessDispatches, + [], + 'the failure control edge is not a new outbound business dispatch by the reporter', + ); + assert.equal( + messageStore.getByThread('thread-failed-a2a', 100, 'owner-1').filter((message) => message.id === processing.id) + .length, + 1, + 'the Queue control carrier must not create a second public failure result', + ); + }); + test('parallel mixed targets never invoke the rejected child', async () => { const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); const calls = []; const deps = routeDeps( { opus: service('opus', calls), codex: service('codex', calls) }, - { preflight: async (input) => decision(input, { opus: 'rejected', codex: 'allowed' }) }, + { preflight: async (input) => decision(input, { opus: 'rejected', codex: 'warned' }) }, ); const events = []; for await (const event of routeParallel(deps, ['opus', 'codex'], 'ideate', 'owner-1', 'thread-parallel')) { @@ -333,13 +454,13 @@ describe('F293 actual-send routing preflight', () => { assert.ok(!calls.includes('opus')); assert.ok(calls.includes('codex')); - assert.ok( - events.some( - (event) => - event.type === 'system_info' && - event.content?.includes('routing_preflight') && - JSON.parse(event.content).target.disposition === 'rejected', - ), + const receipts = events + .filter((event) => event.type === 'system_info' && event.content?.includes('routing_preflight')) + .map((event) => JSON.parse(event.content)); + assert.deepEqual( + receipts.map((receipt) => [receipt.target.targetCatId, receipt.target.disposition]), + [['opus', 'rejected']], + 'parallel fail-open degradation stays in routing telemetry instead of becoming chat content', ); }); @@ -407,14 +528,24 @@ describe('F293 actual-send routing preflight', () => { test('callback queue partitions mixed targets before creating queue entries and returns the complete receipt', async () => { const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const queue = new InvocationQueue(); + const messageStore = new MessageStore(); const broadcasts = []; + const triggerMessage = messageStore.append({ + from: { kind: 'agent', catId: 'terra' }, + threadId: 'thread-callback', + userId: 'owner-1', + content: 'review this', + mentions: ['opus', 'codex'], + origin: 'callback', + timestamp: Date.now(), + }); const result = await enqueueA2ATargets( { - router: {}, - invocationRecordStore: {}, invocationQueue: queue, - queueProcessor: { async tryAutoExecute() {} }, + messageStore, + queueProcessor: { async requestDrain() {} }, socketManager: { broadcastAgentMessage(message, threadId) { broadcasts.push({ message, threadId }); @@ -433,15 +564,7 @@ describe('F293 actual-send routing preflight', () => { userId: 'owner-1', ownerAuthProvenance: 'unknown', threadId: 'thread-callback', - triggerMessage: { - id: 'message-callback', - threadId: 'thread-callback', - userId: 'owner-1', - catId: 'terra', - content: 'review this', - mentions: ['opus', 'codex'], - timestamp: Date.now(), - }, + triggerMessage, callerCatId: 'terra', }, ); @@ -455,7 +578,7 @@ describe('F293 actual-send routing preflight', () => { ], ); assert.deepEqual( - queue.list('thread-callback', 'owner-1').flatMap((entry) => entry.targetCats), + queue.list('thread-callback', 'owner-1').flatMap((entry) => entry.targets), ['codex'], ); assert.deepEqual( @@ -466,10 +589,7 @@ describe('F293 actual-send routing preflight', () => { target: JSON.parse(message.content).target.targetCatId, disposition: JSON.parse(message.content).target.disposition, })), - [ - { threadId: 'thread-callback', target: 'opus', disposition: 'rejected' }, - { threadId: 'thread-callback', target: 'codex', disposition: 'warned' }, - ], + [{ threadId: 'thread-callback', target: 'opus', disposition: 'rejected' }], ); }); }); diff --git a/packages/api/test/routing-human-recovery.test.js b/packages/api/test/routing-human-recovery.test.js index ea6a8764c8..6553e40b94 100644 --- a/packages/api/test/routing-human-recovery.test.js +++ b/packages/api/test/routing-human-recovery.test.js @@ -136,20 +136,29 @@ describe('F293 controlled human recovery journey', () => { assert.equal((await h.decide()).targets[0].disposition, 'rejected', 'real failed terminal updates routing truth'); for (const threadId of ['thread-b', 'thread-c']) { const events = await send(threadId); - assert.ok(events.some((event) => event.errorCode === 'routing_preflight_rejected')); + assert.ok( + events.some((event) => { + if (event.type !== 'system_info') return false; + try { + const receipt = JSON.parse(event.content); + return receipt.type === 'routing_preflight' && receipt.target?.disposition === 'rejected'; + } catch { + return false; + } + }), + ); } assert.equal(calls, 1, 'automatic retries are blocked before invoking the provider'); providerFails = false; - const attempted = await send('thread-a', true); - assert.ok( - attempted.some( - (event) => event.type === 'system_info' && JSON.parse(event.content).target?.ownerAttempt === true, - ), - ); + await send('thread-a', true); assert.equal(calls, 2, 'a human attempt reaches the recovered provider'); assert.equal((await h.decide()).targets[0].disposition, 'allowed'); assert.equal(calls, 2, 'recovery does not replay either blocked request'); - assert.equal(messages.filter((message) => message.extra?.systemInfo?.payload.retryInvocationId).length, 3); + assert.equal( + messages.filter((message) => message.extra?.systemInfo?.payload.retryInvocationId).length, + 2, + 'only rejected automatic sends persist retryable receipts; a permitted owner attempt does not add noise', + ); await send('thread-b'); await send('thread-c'); assert.equal(calls, 4, 'both other threads can send again through the same resolver'); diff --git a/packages/api/test/routing-warning-feedback.test.js b/packages/api/test/routing-warning-feedback.test.js index ab5cc73279..d1f8cf172b 100644 --- a/packages/api/test/routing-warning-feedback.test.js +++ b/packages/api/test/routing-warning-feedback.test.js @@ -65,6 +65,26 @@ function buildRouter() { } describe('AgentRouter.resolveTargetsAndIntent: routing_warnings for not-found cats', () => { + it('keeps prose parsing targetless so the message ingress can apply the canonical fallback', async () => { + const router = buildRouter(); + const result = await router.resolveTargetsAndIntent('continue', 'thread-1', { allowFallback: false }); + + assert.deepEqual(result.targetCats, []); + assert.equal(result.hasMentions, false); + assert.deepEqual(result.routing_warnings, []); + }); + + it('does not substitute the default cat for an invalid explicit mention', async () => { + const router = buildRouter(); + const result = await router.resolveTargetsAndIntent('@ghostcat continue', 'thread-1', { + allowFallback: false, + }); + + assert.deepEqual(result.targetCats, []); + assert.equal(result.hasMentions, false); + assert.equal(result.routing_warnings[0]?.kind, 'cat_not_found'); + }); + it('returns routing_warnings field (at minimum an empty array)', async () => { const router = buildRouter(); const result = await router.resolveTargetsAndIntent('help me', 'thread-1'); diff --git a/packages/api/test/s1-review-fixes.test.js b/packages/api/test/s1-review-fixes.test.js index f77057cfae..f68c0967d8 100644 --- a/packages/api/test/s1-review-fixes.test.js +++ b/packages/api/test/s1-review-fixes.test.js @@ -234,15 +234,15 @@ describe('Integration: dedup does not trigger tracker abort', () => { }); }); -// --- R2: delete-guard race — route-level integration test --- -// Simulates: isDeleting()=false → (race: guard acquired) → start() returns aborted -// The route MUST detect aborted controller, mark InvocationRecord canceled, return 409 +// --- R2: delete-guard race — canonical Queue ingress regression --- +// Tracker reservation now belongs to QueueProcessor, so route admission must not +// consult an aborting tracker or mint a premature InvocationRecord. import Fastify from 'fastify'; import { migrateRouterOpts } from './helpers/agent-registry-helpers.js'; describe('R2: delete-guard race via POST /api/messages route', () => { - test('returns 409 and cancels InvocationRecord when start() returns aborted controller', async () => { + test('admits durable Queue custody without consulting an aborting tracker', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const { InvocationRegistry } = await import( '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' @@ -250,6 +250,7 @@ describe('R2: delete-guard race via POST /api/messages route', () => { const { InvocationRecordStore } = await import( '../dist/domains/cats/services/stores/ports/InvocationRecordStore.js' ); + const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { messagesRoutes } = await import('../dist/routes/messages.js'); const threadId = 'thread-race-r2'; @@ -319,6 +320,7 @@ describe('R2: delete-guard race via POST /api/messages route', () => { async ackCollectedCursors() {}, }; + const invocationQueue = new InvocationQueue(); const app = Fastify(); await app.register(messagesRoutes, { registry: new InvocationRegistry(), @@ -328,6 +330,8 @@ describe('R2: delete-guard race via POST /api/messages route', () => { threadStore, invocationTracker: raceTracker, invocationRecordStore, + invocationQueue, + queueProcessor: { requestDrain() {} }, }); await app.ready(); @@ -346,19 +350,22 @@ describe('R2: delete-guard race via POST /api/messages route', () => { // Wait briefly to ensure background path would have had time to append if bug existed await new Promise((resolve) => setTimeout(resolve, 50)); - // Assert: 409 THREAD_DELETING - assert.equal(res.statusCode, 409); + // Queue ingress owns admission; tracker reservation happens only during drain. + assert.equal(res.statusCode, 202, res.body); const body = JSON.parse(res.body); - assert.equal(body.code, 'THREAD_DELETING'); + assert.equal(body.status, 'queued'); - // Assert: no user message was written to MessageStore - assert.equal(messageStore.getByThread(threadId).length, 0); + const messages = messageStore.getByThreadIncludingQueued(threadId); + assert.equal(messages.length, 1); + assert.equal(messages[0].deliveryStatus, 'queued'); + assert.equal(messages[0].queueCustody, undefined, 'Message History must not mirror Queue state'); + const [entry] = invocationQueue.list(threadId, 'alice'); + assert.equal(entry?.id, body.entryId); + assert.equal(entry?.payload.messageId, messages[0].id); - // Assert: InvocationRecord was created then marked canceled by the route + // InvocationRecord is minted only after QueueProcessor reserves the entry. const record = invocationRecordStore.getByIdempotencyKey(threadId, 'alice', idempotencyKey); - assert.ok(record, 'InvocationRecord should exist (created before start())'); - assert.equal(record.status, 'canceled'); - assert.equal(record.userMessageId, null, 'No message should have been written'); + assert.equal(record, null); await app.close(); }); diff --git a/packages/api/test/scheduler-delivery.test.js b/packages/api/test/scheduler-delivery.test.js index 806b067b61..25c518fd3c 100644 --- a/packages/api/test/scheduler-delivery.test.js +++ b/packages/api/test/scheduler-delivery.test.js @@ -21,7 +21,8 @@ describe('createDeliverFn', () => { const appendArg = messageStore.append.mock.calls[0].arguments[0]; assert.equal(appendArg.threadId, 'th-1'); assert.equal(appendArg.content, 'Hello reminder'); - assert.equal(appendArg.catId, null); + assert.deepEqual(appendArg.from, { kind: 'system', service: 'scheduler' }); + assert.equal(appendArg.catId, undefined); assert.equal(appendArg.origin, 'callback'); assert.equal(appendArg.source.connector, 'scheduler'); assert.equal(appendArg.source.label, '定时任务'); diff --git a/packages/api/test/scheduler-reply-userid-backfill.test.js b/packages/api/test/scheduler-reply-userid-backfill.test.js index 643fec3c64..ca7fa9919c 100644 --- a/packages/api/test/scheduler-reply-userid-backfill.test.js +++ b/packages/api/test/scheduler-reply-userid-backfill.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupPrefixedRedisKeys, @@ -66,15 +67,17 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED const thread = await threadStore.create('real-user-123', 'scheduler backfill'); const now = Date.now(); - const triggerMessage = await messageStore.append({ - userId: 'scheduler', - catId: 'system', - content: '[定时任务] 发今天的 AI 新闻', - mentions: [], - timestamp: now, - threadId: thread.id, - origin: 'callback', - }); + const triggerMessage = await messageStore.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: '[定时任务] 发今天的 AI 新闻', + mentions: [], + timestamp: now, + threadId: thread.id, + origin: 'callback', + }), + ); const createResult = await invocationRecordStore.create({ threadId: thread.id, @@ -95,15 +98,17 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED }); assert.ok(completed, 'invocation should persist trigger message id'); - const hiddenReply = await messageStore.append({ - userId: 'scheduler', - catId: 'opus', - content: '这是旧的猫回复', - mentions: [], - timestamp: now + 1, - threadId: thread.id, - origin: 'callback', - }); + const hiddenReply = await messageStore.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'opus', + content: '这是旧的猫回复', + mentions: [], + timestamp: now + 1, + threadId: thread.id, + origin: 'callback', + }), + ); const before = await messageStore.getByThread(thread.id, 50, 'real-user-123'); assert.equal(before.length, 1, 'before backfill only system trigger message is visible'); @@ -132,25 +137,29 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED const thread = await threadStore.create('real-user-456', 'scheduler stream backfill'); const now = Date.now(); - const triggerMessage = await messageStore.append({ - userId: 'scheduler', - catId: 'system', - content: '[定时任务] eval:a2a daily run', - mentions: [], - timestamp: now, - threadId: thread.id, - origin: 'callback', - }); + const triggerMessage = await messageStore.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: '[定时任务] eval:a2a daily run', + mentions: [], + timestamp: now, + threadId: thread.id, + origin: 'callback', + }), + ); - const hiddenStreamReply = await messageStore.append({ - userId: 'scheduler', - catId: 'codex', - content: 'eval:a2a daily eval result from route-serial stream', - mentions: [], - timestamp: now + 1, - threadId: thread.id, - origin: 'stream', - }); + const hiddenStreamReply = await messageStore.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'codex', + content: 'eval:a2a daily eval result from route-serial stream', + mentions: [], + timestamp: now + 1, + threadId: thread.id, + origin: 'stream', + }), + ); const before = await messageStore.getByThread(thread.id, 50, 'real-user-456'); assert.deepEqual( @@ -186,25 +195,29 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED await threadStore.indexForUser(threadId, ownerUserId); const now = Date.now(); - const triggerMessage = await messageStore.append({ - userId: 'scheduler', - catId: 'system', - content: '[定时任务] eval:a2a daily run', - mentions: [], - timestamp: now, - threadId, - origin: 'callback', - }); + const triggerMessage = await messageStore.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'system', + content: '[定时任务] eval:a2a daily run', + mentions: [], + timestamp: now, + threadId, + origin: 'callback', + }), + ); - const hiddenStreamReply = await messageStore.append({ - userId: 'scheduler', - catId: 'codex', - content: 'eval:a2a result persisted under scheduler scope', - mentions: [], - timestamp: now + 1, - threadId, - origin: 'stream', - }); + const hiddenStreamReply = await messageStore.append( + canonicalTestMessageInput({ + userId: 'scheduler', + catId: 'codex', + content: 'eval:a2a result persisted under scheduler scope', + mentions: [], + timestamp: now + 1, + threadId, + origin: 'stream', + }), + ); const before = await messageStore.getByThread(threadId, 50, ownerUserId); assert.deepEqual( diff --git a/packages/api/test/scheduler/phase4-e2e.test.js b/packages/api/test/scheduler/phase4-e2e.test.js index d3f3be647b..3aa7f2d542 100644 --- a/packages/api/test/scheduler/phase4-e2e.test.js +++ b/packages/api/test/scheduler/phase4-e2e.test.js @@ -135,7 +135,10 @@ describe('F139 Phase 4 E2E', () => { assert.equal(deliverCalls.length, 1); assert.equal(deliverCalls[0].threadId, 'thread-news-123'); assert.ok(deliverCalls[0].content.includes('搜三天内 Anthropic 新闻')); - assert.equal(deliverCalls[0].userId, 'scheduler'); + // F117 ADR-043 D.4 (sol adjudication R4): stored trigger-message userId is the + // verified trigger owner (triggerUserId), not the author 'scheduler'. Scheduler + // authorship is expressed via from: system:scheduler in createDeliverFn instead. + assert.equal(deliverCalls[0].userId, 'default-user'); // === triggerUserId assert.equal(deliverCalls[0].extra?.scheduler?.hiddenTrigger, true); // 2. invokeTrigger was called with the REAL messageId from deliver @@ -151,6 +154,53 @@ describe('F139 Phase 4 E2E', () => { assert.equal(runs[0].outcome, 'RUN_DELIVERED'); }); + test('hold continuation stays queued and urgent until canonical Queue admission publishes it', async () => { + const triggerCalls = []; + const runnerWithTrigger = new TaskRunnerV2({ + logger: { info: () => {}, error: () => {} }, + ledger, + globalControlStore, + deliver: async (opts) => { + deliverCalls.push(opts); + return deliverMock(opts); + }, + invokeTrigger: { trigger: (...args) => triggerCalls.push(args) }, + }); + const fireAt = Date.now() + 60_000; + store.insert({ + id: 'hold-ball-timer-1', + templateId: 'reminder', + trigger: { type: 'once', fireAt }, + params: { + message: '等待 CI 完成后复核', + targetCatId: 'codex-sol', + triggerUserId: 'user-1', + holdLifecycle: { + mode: 'timer', + status: 'active', + wakeAt: fireAt, + createdBy: 'hold-ball:codex-sol', + }, + }, + display: { label: 'hold', category: 'system' }, + deliveryThreadId: 'thread-hold-1', + enabled: true, + createdBy: 'hold-ball:codex-sol', + createdAt: new Date().toISOString(), + }); + runnerWithTrigger.hydrateDynamic(store, templateRegistry); + + await runnerWithTrigger.triggerNow('hold-ball-timer-1', { manual: true }); + + assert.equal(deliverCalls.length, 1); + assert.equal(deliverCalls[0].deliveryStatus, 'queued'); + assert.equal(deliverCalls[0].source.connector, 'hold-ball'); + assert.equal(deliverCalls[0].source.meta.phase, 'wake'); + assert.equal(triggerCalls.length, 1); + assert.equal(triggerCalls[0][6].priority, 'urgent'); + assert.equal(triggerCalls[0][6].sourceCategory, 'scheduled'); + }); + test('AC-H4: builtin task pause via override → trigger skipped', async () => { // Register a reminder store.insert({ @@ -304,7 +354,10 @@ describe('F139 Phase 4 E2E', () => { assert.equal(fetchMock.mock.calls.length, 1); assert.equal(deliverCalls.length, 1); assert.equal(deliverCalls[0].threadId, 'thread-browser'); - assert.equal(deliverCalls[0].userId, 'scheduler'); + // F117 ADR-043 D.4 (sol adjudication R4): stored trigger-message userId is the + // verified trigger owner (triggerUserId), not the author 'scheduler'. Scheduler + // authorship is expressed via from: system:scheduler in createDeliverFn instead. + assert.equal(deliverCalls[0].userId, 'default-user'); // === triggerUserId assert.ok(deliverCalls[0].content.includes('browser-automation')); assert.ok(deliverCalls[0].content.includes('https://x.com/anthropic')); assert.ok(deliverCalls[0].content.includes('今天 AI 新闻')); diff --git a/packages/api/test/scheduler/schedule-mutation-routes.test.js b/packages/api/test/scheduler/schedule-mutation-routes.test.js index c3a3f44253..a1be0b93db 100644 --- a/packages/api/test/scheduler/schedule-mutation-routes.test.js +++ b/packages/api/test/scheduler/schedule-mutation-routes.test.js @@ -276,6 +276,7 @@ describe('schedule mutation principal routing', () => { assert.deepEqual(invokeCalls[0][6], { sourceCategory: 'scheduled', ownerAuthProvenance: 'strict', + priority: 'urgent', }); }); diff --git a/packages/api/test/scheduler/scheduled-trigger-owner-fence.test.js b/packages/api/test/scheduler/scheduled-trigger-owner-fence.test.js new file mode 100644 index 0000000000..2cc5b7f4a2 --- /dev/null +++ b/packages/api/test/scheduler/scheduled-trigger-owner-fence.test.js @@ -0,0 +1,124 @@ +// @ts-check +/** + * F117 R4 combo test (sol adjudication): scheduled trigger message must pass the + * canonical ingress fence end-to-end — real reminder template + real createDeliverFn + * + real ConnectorInvokeTrigger + real Queue ledger. Nails "source owner == queue + * owner and enqueue succeeds", which the Phase 4 mock-based tests cannot detect + * (the R4 reminder merge regression delivered userId 'scheduler' while triggering + * with triggerUserId, and would have been rejected by the fence in production). + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, test } from 'node:test'; +import Database from 'better-sqlite3'; +import { InvocationQueue } from '../../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { MessageStore } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { applyMigrations } from '../../dist/domains/memory/schema.js'; +import { ConnectorInvokeTrigger } from '../../dist/infrastructure/email/ConnectorInvokeTrigger.js'; +import { DynamicTaskStore } from '../../dist/infrastructure/scheduler/DynamicTaskStore.js'; +import { createDeliverFn } from '../../dist/infrastructure/scheduler/delivery.js'; +import { GlobalControlStore } from '../../dist/infrastructure/scheduler/GlobalControlStore.js'; +import { RunLedger } from '../../dist/infrastructure/scheduler/RunLedger.js'; +import { TaskRunnerV2 } from '../../dist/infrastructure/scheduler/TaskRunnerV2.js'; +import { templateRegistry } from '../../dist/infrastructure/scheduler/templates/registry.js'; + +function noopLog() { + const noop = () => {}; + return /** @type {any} */ ({ + info: noop, + warn: noop, + error: noop, + debug: noop, + trace: noop, + fatal: noop, + child: () => noopLog(), + }); +} + +function socketHarness() { + return { + manager: /** @type {any} */ ({ + emitToUser() {}, + broadcastAgentMessage() {}, + broadcastToRoom() {}, + }), + }; +} + +describe('scheduled trigger message passes the canonical ingress fence end-to-end', () => { + let db; + let ledger; + let globalControlStore; + let store; + let messageStore; + let queue; + let runner; + let drains; + const ownerUserId = 'user-owner-1'; + + beforeEach(() => { + db = new Database(':memory:'); + applyMigrations(db); + ledger = new RunLedger(db); + globalControlStore = new GlobalControlStore(db); + store = new DynamicTaskStore(db); + messageStore = new MessageStore(); + queue = new InvocationQueue(); + drains = []; + const sockets = socketHarness(); + const deliver = createDeliverFn({ messageStore, socketManager: sockets.manager }); + const trigger = new ConnectorInvokeTrigger({ + socketManager: sockets.manager, + invocationQueue: queue, + queueProcessor: /** @type {any} */ ({ + async requestDrain(threadId) { + drains.push(threadId); + }, + }), + messageStore, + log: noopLog(), + }); + runner = new TaskRunnerV2({ + logger: { info: () => {}, error: () => {} }, + ledger, + globalControlStore, + deliver, + invokeTrigger: trigger, + }); + }); + + test('reminder template → createDeliverFn → ConnectorInvokeTrigger enqueues under the trigger owner', async () => { + store.insert( + { + id: 'remind-owner-fence', + templateId: 'reminder', + trigger: { type: 'once', fireAt: Date.now() + 60_000 }, + params: { message: '喝水', triggerUserId: ownerUserId }, + display: { label: '喝水提醒', category: 'system' }, + deliveryThreadId: 'thread-owner-1', + enabled: true, + createdBy: 'opus', + createdAt: new Date().toISOString(), + }, + 'strict', + ); + runner.hydrateDynamic(store, templateRegistry); + + await runner.triggerNow('remind-owner-fence', { manual: true }); + + // The canonical ingress fence accepted it: queue owner === source owner. + const entries = queue.list('thread-owner-1', ownerUserId); + assert.equal(entries.length, 1); + // Ordinary reminders carry an authenticated owner identity but no private + // managed-hold continuation proof. Only hold-ball tasks may project strict. + assert.equal(entries[0].execution.ownerAuthProvenance, 'unknown'); + + // The source message was persisted by createDeliverFn with the verified owner. + const stored = messageStore.getById(entries[0].payload.messageId); + assert.ok(stored, 'trigger message must be persisted'); + assert.equal(stored.userId, ownerUserId); + assert.deepEqual(stored.from, { kind: 'system', service: 'scheduler' }); + assert.equal(stored.deliveryStatus, 'queued'); + assert.deepEqual(drains, ['thread-owner-1']); + }); +}); diff --git a/packages/api/test/scheduler/task-runner-v2.test.js b/packages/api/test/scheduler/task-runner-v2.test.js index 2a020ad9c1..9819740c68 100644 --- a/packages/api/test/scheduler/task-runner-v2.test.js +++ b/packages/api/test/scheduler/task-runner-v2.test.js @@ -373,7 +373,7 @@ describe('TaskRunnerV2', () => { ledger, deliver: async ({ content }) => settleAfterTimeout(`msg:${content}`), invokeTrigger: { - trigger: async () => settleAfterTimeout('dispatched'), + trigger: async () => settleAfterTimeout('enqueued'), }, }); runner.setManagedCommandWakeRecovery(async () => settleAfterTimeout('recovered')); @@ -439,11 +439,11 @@ describe('TaskRunnerV2', () => { assert.deepEqual(completed, [ 'msg:deliver', - 'dispatched', + 'enqueued', 'recovered', - 'msg:chain:dispatched', + 'msg:chain:enqueued', 'unbound-trigger-blocked', - 'detached:dispatched', + 'detached:enqueued', ]); assert.equal( ledger.query('completed-effect-timeout-test', 10).filter((row) => row.outcome === 'RUN_FAILED').length, @@ -452,6 +452,40 @@ describe('TaskRunnerV2', () => { ); }); + it('passes queued-delivery cancellation through the scheduler execution boundary', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + const canceled = []; + runner = new TaskRunnerV2({ + logger: silentLogger, + ledger, + cancelQueuedDelivery: async (messageId) => { + canceled.push(messageId); + return true; + }, + }); + runner.register({ + id: 'cancel-queued-delivery-test', + profile: 'awareness', + trigger: { type: 'interval', ms: 999999 }, + admission: { + gate: async () => ({ run: true, workItems: [{ signal: 'wake', subjectKey: 'thread-1' }] }), + }, + run: { + overlap: 'skip', + timeoutMs: 5_000, + execute: async (_signal, _subjectKey, ctx) => { + assert.equal(await ctx.cancelQueuedDelivery('wake-message-1'), true); + }, + }, + state: { runLedger: 'sqlite' }, + outcome: { whenNoSignal: 'drop' }, + enabled: () => true, + }); + + await runner.triggerNow('cancel-queued-delivery-test'); + assert.deepEqual(canceled, ['wake-message-1']); + }); + it('restart after timeout does not leave a zombie execution beside the new runner', async () => { let oldRunnerIo = 0; const oldTask = { @@ -1392,6 +1426,64 @@ describe('TaskRunnerV2 — once trigger (#415)', () => { runner.stop(); }); + it('retries an active hold-ball once task after RUN_FAILED instead of retiring its wake', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + const taskId = 'hold-ball-retry-after-run-failure'; + const fireAt = Date.now() + 20; + let attempts = 0; + const runner = new TaskRunnerV2({ + logger: silentLogger, + ledger, + dynamicTaskStore, + retryableHoldFailureDelayMs: 20, + }); + dynamicTaskStore.insert({ + id: taskId, + templateId: 'reminder', + trigger: { type: 'once', fireAt }, + params: { + message: 'managed wake', + holdLifecycle: { + mode: 'wake_when', + status: 'active', + managedCommand: { state: 'condition_met', command: 'pnpm gate', startedAt: fireAt - 1_000 }, + }, + }, + display: { label: 'managed wake', category: 'system' }, + deliveryThreadId: 'thread-managed-retry', + enabled: true, + createdBy: 'hold-ball:codex-sol', + createdAt: new Date().toISOString(), + }); + runner.registerDynamic( + makeOnceTask(taskId, fireAt, { + run: { + overlap: 'skip', + timeoutMs: 5_000, + execute: async () => { + attempts += 1; + if (attempts === 1) throw new Error('delivery failed before durable settlement'); + }, + }, + }), + taskId, + ); + + runner.start(); + await new Promise((resolve) => setTimeout(resolve, 180)); + + assert.equal(attempts, 2); + assert.deepEqual( + ledger + .query(taskId, 10) + .map((row) => row.outcome) + .sort(), + ['RUN_DELIVERED', 'RUN_FAILED'], + ); + assert.ok(dynamicTaskStore.getById(taskId), 'durable managed-command receipt remains recovery-owned'); + runner.stop(); + }); + it('once trigger auto-retires: unregisters from runner + removes from store', async () => { const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); const runner = new TaskRunnerV2({ logger: silentLogger, ledger, dynamicTaskStore }); @@ -1683,17 +1775,16 @@ describe('TaskRunnerV2 — once trigger (#415)', () => { runner.stop(); }); - it('hydrated missed hold-ball once task records ball.hold_expired before retiring', async () => { + it('hydrated missed hold-ball once task persists shared lifecycle status before retiring', async () => { const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); - const events = []; + const deliveries = []; const runner = new TaskRunnerV2({ logger: silentLogger, ledger, dynamicTaskStore, - ballCustody: { - async record(event) { - events.push(event); - }, + deliver: async (input) => { + deliveries.push(input); + return 'message-hold-missed'; }, }); @@ -1711,13 +1802,20 @@ describe('TaskRunnerV2 — once trigger (#415)', () => { }); runner.hydrateDynamic(dynamicTaskStore, { get: () => null }); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(dynamicTaskStore.getById('hold-ball-missed-1'), null, 'missed hold-ball task should be retired'); - assert.equal(events.length, 1, 'missed hold-ball task should emit one expiry event'); - assert.equal(events[0].kind, 'ball.hold_expired'); - assert.equal(events[0].sourceEventId, `holdexp:thread-hold-missed:codex:${pastFireAt}`); - assert.equal(events[0].subjectKey, 'ball:thread:thread-hold-missed'); - assert.deepEqual(events[0].payload, { catId: 'codex', fireAt: pastFireAt }); + assert.equal(deliveries.length, 1, 'missed hold-ball task should persist one lifecycle status'); + assert.equal(deliveries[0].threadId, 'thread-hold-missed'); + assert.equal(deliveries[0].userId, 'user-42'); + assert.equal(deliveries[0].idempotencyKey, 'hold-ball-missed:hold-ball-missed-1'); + assert.deepEqual(deliveries[0].source.meta, { + managedHold: true, + phase: 'status', + taskId: 'hold-ball-missed-1', + threadId: 'thread-hold-missed', + catId: 'codex', + }); runner.stop(); }); diff --git a/packages/api/test/session-handoff-approve-route.test.js b/packages/api/test/session-handoff-approve-route.test.js index 940b6d50e8..53eb10505b 100644 --- a/packages/api/test/session-handoff-approve-route.test.js +++ b/packages/api/test/session-handoff-approve-route.test.js @@ -31,7 +31,7 @@ describe('session-handoff approve/reject route (F225 ②b)', () => { assert.equal(deps.sealCalls.length, 1); assert.equal(deps.sealCalls[0].reason, 'cat_initiated_handoff', 'sealed with handoff reason'); assert.equal(deps.enqueueCalls.length, 1); - assert.equal(deps.enqueueCalls[0].source, 'agent'); + assert.deepEqual(deps.enqueueCalls[0].from, { kind: 'agent', catId: 'opus' }); assert.equal(deps.enqueueCalls[0].ownerAuthProvenance, 'strict'); assert.equal(deps.enqueueCalls[0].sourceCategory, 'continuation', 'system-pinned continuation'); assert.equal(deps.enqueueCalls[0].idempotencyKey, p.proposalId, 'idempotency keyed by proposalId (B5)'); diff --git a/packages/api/test/side-dispatch-integration.test.js b/packages/api/test/side-dispatch-integration.test.js index b7da1cdfdb..ba49edd5c7 100644 --- a/packages/api/test/side-dispatch-integration.test.js +++ b/packages/api/test/side-dispatch-integration.test.js @@ -3,7 +3,6 @@ * * Tests cross-module interactions that verify AC-A1~A9: * - InvocationTracker + QueueProcessor (concurrent slots) - * - WorklistRegistry (parentInvocationId isolation) * - AgentMessage invocationId tagging * * These are NOT redundant with unit tests — they test the *seams* @@ -11,21 +10,19 @@ */ import assert from 'node:assert/strict'; -import { afterEach, describe, it, mock } from 'node:test'; +import { describe, it, mock } from 'node:test'; +import { adaptInvocationQueue } from './helpers/message-from-fixtures.js'; const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { registerWorklist, unregisterWorklist, pushToWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' -); // ── Helpers ── function stubDeps(overrides = {}) { const tracker = new InvocationTracker(); return { - queue: new InvocationQueue(), + queue: adaptInvocationQueue(new InvocationQueue()), invocationTracker: tracker, invocationRecordStore: { create: mock.fn(async () => ({ @@ -35,6 +32,8 @@ function stubDeps(overrides = {}) { update: mock.fn(async () => {}), }, router: { + resolveExplicitTargets: mock.fn(async (targetCats) => [...targetCats]), + resolveConversationTargetsAtAdmission: mock.fn(async (targetCats) => [...targetCats]), routeExecution: mock.fn(async function* () { yield { type: 'done', catId: 'opus', timestamp: Date.now() }; }), @@ -61,13 +60,14 @@ function stubDeps(overrides = {}) { function enqueueEntry(queue, overrides = {}) { const entry = { + kind: 'private_input', ownerAuthProvenance: 'unknown', threadId: 't1', userId: 'u1', content: 'hello', targetCats: ['opus'], intent: 'execute', - source: 'user', + source: 'agent', ...overrides, }; return queue.enqueue(entry); @@ -93,13 +93,9 @@ describe('AC-A1: concurrent different-cat invocations (InvocationTracker + Queue assert.equal(deps.invocationTracker.has('t1', 'opus'), true); assert.equal(deps.invocationTracker.has('t1', 'codex'), true); - // QueueProcessor: completing opus slot doesn't pause codex slot - // Enqueue codex entry ONLY — opus has nothing queued - enqueueEntry(deps.queue, { targetCats: ['codex'] }); - await processor.onInvocationComplete('t1', 'opus', 'canceled'); - // opus slot gets paused (canceled + queue has entries for thread) - // but this is expected — the key test is codex is NOT paused - assert.equal(processor.isPaused('t1', 'codex'), false, 'codex not paused by opus cancel'); + // Completing one slot must leave the sibling tracker slot alive. + await processor.onInvocationComplete('t1', 'opus', 'completed'); + assert.equal(deps.invocationTracker.has('t1', 'codex'), true, 'codex remains active after opus completes'); }); }); @@ -118,6 +114,8 @@ describe('AC-A3: same cat same thread serializes (tracker → QueueProcessor)', let resolveExecution; const slowDeps = stubDeps({ router: { + resolveExplicitTargets: mock.fn(async (targetCats) => [...targetCats]), + resolveConversationTargetsAtAdmission: mock.fn(async (targetCats) => [...targetCats]), routeExecution: mock.fn(async function* () { await new Promise((r) => { resolveExecution = r; @@ -131,9 +129,7 @@ describe('AC-A3: same cat same thread serializes (tracker → QueueProcessor)', // Two opus entries const e1 = enqueueEntry(slowDeps.queue, { content: 'first opus', targetCats: ['opus'] }); - slowDeps.queue.backfillMessageId('t1', 'u1', e1.id, 'msg-1'); const e2 = enqueueEntry(slowDeps.queue, { content: 'second opus', targetCats: ['opus'] }); - slowDeps.queue.backfillMessageId('t1', 'u1', e2.id, 'msg-2'); // Start first const r1 = await processor.processNext('t1', 'u1'); @@ -148,9 +144,9 @@ describe('AC-A3: same cat same thread serializes (tracker → QueueProcessor)', }); }); -// ── AC-A5: Backward compatibility ── +// ── AC-A5: Single-cat lifecycle ── -describe('AC-A5: backward compatibility — single-cat execution unchanged', () => { +describe('AC-A5: single-cat execution lifecycle', () => { it('single cat execute-then-complete cycle works', async () => { const deps = stubDeps(); const processor = new QueueProcessor(deps); @@ -178,44 +174,6 @@ describe('AC-A5: backward compatibility — single-cat execution unchanged', () }); }); -// ── AC-A6: WorklistRegistry parentInvocationId isolation ── - -describe('AC-A6: WorklistRegistry parentInvocationId isolation', () => { - afterEach(() => { - // Cleanup all registries - unregisterWorklist('t1', undefined, 'inv-opus-1'); - unregisterWorklist('t1', undefined, 'inv-codex-1'); - unregisterWorklist('t1'); - }); - - it('two invocations in same thread have isolated worklists', () => { - // Register worklists for two invocations: opus and codex - const entry1 = registerWorklist('t1', ['opus'], 3, 'inv-opus-1'); - const entry2 = registerWorklist('t1', ['codex'], 3, 'inv-codex-1'); - - // Push to opus worklist (callerCatId='opus' matches entry1.list[0]) - pushToWorklist('t1', ['gemini'], 'opus', 'inv-opus-1'); - - // Opus worklist has the pushed cat - const w1 = getWorklist('t1', 'inv-opus-1'); - assert.equal(w1?.list.length, 2); // ['opus', 'gemini'] - assert.ok(w1?.list.includes('gemini'), 'gemini pushed to opus worklist'); - - // Codex worklist is separate - const w2 = getWorklist('t1', 'inv-codex-1'); - assert.equal(w2?.list.length, 1, 'codex worklist untouched'); - assert.ok(!w2?.list.includes('gemini'), 'gemini not in codex worklist'); - }); - - it('fallback to threadId key when no parentInvocationId', () => { - const entry = registerWorklist('t1', ['opus'], 3); - pushToWorklist('t1', ['codex'], 'opus'); - const w = getWorklist('t1'); - assert.equal(w?.list.length, 2); // ['opus', 'codex'] - assert.ok(w?.list.includes('codex')); - }); -}); - // ── AC-A7: QueueProcessor slot-aware dequeue ── describe('AC-A7: QueueProcessor slot-aware — opus completion dequeues only opus entries', () => { @@ -248,7 +206,6 @@ describe('AC-A8: QueueProcessor broadcasts carry invocationId', () => { // Enqueue and process const entry = enqueueEntry(deps.queue, { targetCats: ['opus'] }); - deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-1'); await processor.processNext('t1', 'u1'); // Wait for async execution diff --git a/packages/api/test/signal-intake/meeting-intake-action-service.test.js b/packages/api/test/signal-intake/meeting-intake-action-service.test.js index ea2a4346eb..00a75327c6 100644 --- a/packages/api/test/signal-intake/meeting-intake-action-service.test.js +++ b/packages/api/test/signal-intake/meeting-intake-action-service.test.js @@ -216,9 +216,10 @@ describe('F292 MeetingIntakeActionService', () => { }, messageStore, invocationQueue: { - enqueue: () => ({ outcome: 'enqueued', entry: { id: `queue-${++queueSequence}`, messageId: null } }), - backfillMessageId: () => {}, - rollbackEnqueue: () => {}, + async appendAndEnqueueDurable(store, messageInput) { + const message = await store.append(messageInput); + return { outcome: 'enqueued', entry: { id: `queue-${++queueSequence}` }, message }; + }, }, queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: { emitToUser() {} }, diff --git a/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js b/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js index 0e1b2721c7..bfa4ce5750 100644 --- a/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js +++ b/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js @@ -51,12 +51,11 @@ describe('F292 private-thread artifact handoff', () => { const appended = []; const enqueued = []; const queue = { - enqueue(input) { - enqueued.push(input); - return { outcome: 'enqueued', entry: { id: 'q-1', messageId: null } }; + async appendAndEnqueueDurable(messageStore, messageInput, queueInput) { + const message = await messageStore.append(messageInput); + enqueued.push(queueInput); + return { outcome: 'enqueued', entry: { id: 'q-1' }, message }; }, - backfillMessageId() {}, - rollbackEnqueue() {}, }; const dispatcher = new ThreadMeetingArtifactDispatcher({ threadStore: { get: async () => ({ ...thread, participants: [] }) }, @@ -97,7 +96,7 @@ describe('F292 private-thread artifact handoff', () => { assert.equal(enqueued.length, 1); assert.deepEqual(enqueued[0].targetCats, ['codex-sol']); - assert.equal(enqueued[0].source, 'connector'); + assert.deepEqual(enqueued[0].from, { kind: 'user', userId: 'owner-1' }); assert.deepEqual(appended[0].source, { connector: 'feishu', label: '飞书会议入站 / 录音豆', @@ -128,16 +127,26 @@ describe('F292 private-thread artifact handoff', () => { assert.equal(buildMeetingArtifactPrompt(intake, artifact), appended[0].content); }); - it('publishes the durable Host receipt before processNext even when execution does not start', async () => { + it('persists the durable Host receipt before processNext even when execution does not start', async () => { const order = []; - const published = []; + const durableInputs = []; const dispatcher = new ThreadMeetingArtifactDispatcher({ threadStore: { get: async () => thread }, messageStore: { - append: async (input) => ({ ...input, id: 'meeting-message-visible', threadId: input.threadId }), + append: async (input) => { + durableInputs.push(input); + return { ...input, id: 'meeting-message-visible', threadId: input.threadId }; + }, getByIdempotencyKey: async () => null, }, invocationQueue: { + // F117: the dispatcher now routes admission through the durable + // append+enqueue contract instead of enqueue + backfill. + async appendAndEnqueueDurable(messageStore, messageInput) { + const message = await messageStore.append(messageInput); + order.push('durableAppend'); + return { outcome: 'enqueued', entry: { id: 'queue-visible', messageId: message.id }, message }; + }, enqueue: () => ({ outcome: 'enqueued', entry: { id: 'queue-visible', messageId: null } }), backfillMessageId() {}, rollbackEnqueue() {}, @@ -149,9 +158,8 @@ describe('F292 private-thread artifact handoff', () => { }, }, socketManager: { - emitToUser(userId, event, data) { - order.push(event); - published.push({ userId, event, data }); + emitToUser() { + assert.fail('F117: admission publication is the durable ledger append, not a socket event'); }, }, supportsPresentationRetry: () => true, @@ -182,20 +190,21 @@ describe('F292 private-thread artifact handoff', () => { }); assert.equal(receipt, undefined); - assert.deepEqual(order, ['messages_queued', 'processNext']); - assert.equal(published.length, 1); - assert.equal(published[0].userId, 'owner-1'); - assert.equal(published[0].data.threadId, 'thread-1'); - assert.deepEqual(published[0].data.messageIds, ['meeting-message-visible']); - assert.deepEqual(published[0].data.messages[0].source, { + // F117 new contract: the owner-visible admission anchor is the durable message + // append (via appendAndEnqueueDurable), ordered before processNext; the old + // 'messages_queued' socket publication was retired from this dispatcher. + assert.deepEqual(order, ['durableAppend', 'processNext']); + assert.equal(durableInputs.length, 1); + assert.equal(durableInputs[0].threadId, 'thread-1'); + assert.equal(durableInputs[0].userId, 'owner-1'); + assert.equal(durableInputs[0].deliveryStatus, 'queued'); + assert.deepEqual(durableInputs[0].source, { connector: 'feishu', label: '飞书会议入站 / 录音豆', icon: 'feishu', meta: { sourceRevision: artifact.sourceRevision }, }); - assert.equal(published[0].data.messages[0].timestamp, 12_345); - assert.equal(published[0].data.messages[0].deliveredAt, undefined); - assert.equal(published[0].data.messages[0].timelineOrderAt, undefined); + assert.equal(durableInputs[0].timestamp, 12_345); }); it('admits the Alpha canary through the canonical meeting write-opportunity producer', async () => { @@ -211,6 +220,11 @@ describe('F292 private-thread artifact handoff', () => { getByIdempotencyKey: async () => null, }, invocationQueue: { + // F117: same durable append+enqueue contract as the production InvocationQueue. + async appendAndEnqueueDurable(messageStore, messageInput) { + const message = await messageStore.append(messageInput); + return { outcome: 'enqueued', entry: { id: 'q-alpha', messageId: message.id }, message }; + }, enqueue: () => ({ outcome: 'enqueued', entry: { id: 'q-alpha', messageId: null } }), backfillMessageId() {}, rollbackEnqueue() {}, @@ -325,12 +339,11 @@ describe('F292 private-thread artifact handoff', () => { const enqueued = []; const published = []; const queue = { - enqueue(input) { - enqueued.push(input); - return { outcome: 'enqueued', entry: { id: 'q-retry', messageId: null } }; + async appendAndEnqueueDurable(messageStore, messageInput, queueInput) { + const message = await messageStore.append(messageInput); + enqueued.push(queueInput); + return { outcome: 'enqueued', entry: { id: 'q-retry' }, message }; }, - backfillMessageId() {}, - rollbackEnqueue() {}, }; let now = 5_000; const dispatcher = new ThreadMeetingArtifactDispatcher({ @@ -339,7 +352,7 @@ describe('F292 private-thread artifact handoff', () => { getByIdempotencyKey: async (userId, _threadId, key) => { if (userId === 'owner-1' && key === `meeting-artifact:intake-1:${artifact.sourceRevision}`) return sourceMessage; - if (userId === 'scheduler') return appended.find((message) => message.idempotencyKey === key) ?? null; + if (userId === 'owner-1') return appended.find((message) => message.idempotencyKey === key) ?? null; return null; }, append: async (input) => { @@ -373,8 +386,8 @@ describe('F292 private-thread artifact handoff', () => { assert.equal(receipt.triggerMessageId, 'retry-message-1'); assert.equal(receipt.queueEntryId, 'q-retry'); assert.deepEqual(enqueued[0].targetCats, ['codex-sol']); - assert.equal(enqueued[0].source, 'connector'); - assert.equal(appended[0].userId, 'scheduler'); + assert.deepEqual(enqueued[0].from, { kind: 'system', service: 'meeting-write-opportunity' }); + assert.equal(appended[0].userId, 'owner-1'); assert.equal(appended[0].extra.scheduler.hiddenTrigger, true); assert.equal( appended[0].extra.writeOpportunityPresentationRetry.sourceOpportunityId, @@ -467,14 +480,19 @@ describe('F292 private-thread artifact handoff', () => { appended.length > 0 ? { ...appended[0], id: 'meeting-message-1', threadId: appended[0].threadId } : null, }, invocationQueue: { - enqueue() { + async appendAndEnqueueDurable(messageStore, messageInput) { enqueueCalls += 1; - return enqueueCalls === 1 - ? { outcome: 'enqueued', entry: { id: 'queue-1', messageId: null } } - : { outcome: 'enqueued', deduped: true, entry: { id: 'queue-1', messageId: 'meeting-message-1' } }; + if (enqueueCalls === 1) { + const message = await messageStore.append(messageInput); + return { outcome: 'enqueued', entry: { id: 'queue-1' }, message }; + } + return { + outcome: 'enqueued', + deduped: true, + entry: { id: 'queue-1' }, + message: appended[0], + }; }, - backfillMessageId() {}, - rollbackEnqueue() {}, }, queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: noopSocketManager, @@ -567,12 +585,10 @@ describe('F292 private-thread artifact handoff', () => { append: async () => assert.fail('must not append'), }, invocationQueue: { - enqueue() { + appendAndEnqueueDurable() { enqueueCount += 1; - return { outcome: 'enqueued', entry: { id: 'unexpected', messageId: null } }; + return { outcome: 'enqueued', entry: { id: 'unexpected' } }; }, - backfillMessageId() {}, - rollbackEnqueue() {}, }, queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: noopSocketManager, diff --git a/packages/api/test/soft-delete.test.js b/packages/api/test/soft-delete.test.js index 9e6f66a1e1..3615979365 100644 --- a/packages/api/test/soft-delete.test.js +++ b/packages/api/test/soft-delete.test.js @@ -10,6 +10,7 @@ import { describe, it } from 'node:test'; import Fastify from 'fastify'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { messageActionsRoutes } from '../dist/routes/message-actions.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { const events = []; @@ -28,14 +29,16 @@ function seedMessages(store) { const msgs = []; for (let i = 0; i < 5; i++) { msgs.push( - store.append({ - userId: 'user-1', - catId: null, - content: `message ${i}`, - mentions: ['opus'], - timestamp: 1000 + i, - threadId: 'thread-sd', - }), + store.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: `message ${i}`, + mentions: ['opus'], + timestamp: 1000 + i, + threadId: 'thread-sd', + }), + ), ); } return msgs; @@ -530,14 +533,16 @@ describe('Authorization: DELETE /api/messages/:id', () => { const messageStore = new MessageStore(); const socketManager = createMockSocketManager(); // Add a message from a different user - const catMsg = messageStore.append({ - userId: 'cat-opus', - catId: 'opus', - content: 'cat reply', - mentions: [], - timestamp: 2000, - threadId: 'thread-sd', - }); + const catMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'cat-opus', + catId: 'opus', + content: 'cat reply', + mentions: [], + timestamp: 2000, + threadId: 'thread-sd', + }), + ); const threadStore = createMockThreadStore({ 'thread-sd': { id: 'thread-sd', title: 'T', createdBy: 'user-1', participants: [] }, }); diff --git a/packages/api/test/startup-reconciler.test.js b/packages/api/test/startup-reconciler.test.js index bd890f7e08..129725c35b 100644 --- a/packages/api/test/startup-reconciler.test.js +++ b/packages/api/test/startup-reconciler.test.js @@ -11,6 +11,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; +import { canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; // ── Fake InvocationRecordStore (simulates RedisInvocationRecordStore) ── @@ -139,9 +140,15 @@ function makeTaskSnapshot(threadId, catId) { // ── Import StartupReconciler (lazy — file may not exist yet in RED phase) ── let StartupReconciler; +let InvocationQueue; +let InMemoryQueueLedgerStore; try { const mod = await import('../dist/domains/cats/services/agents/invocation/StartupReconciler.js'); StartupReconciler = mod.StartupReconciler; + ({ InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js')); + ({ InMemoryQueueLedgerStore } = await import( + '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js' + )); } catch { // RED phase: module doesn't exist yet — tests will fail with clear message } @@ -452,14 +459,15 @@ describe('StartupReconciler', () => { assert.equal(appendedMessages.length, 2, 'should append 2 messages'); assert.equal(broadcastedEvents.length, 2, 'should broadcast 2 messages'); - // AC-A+2: Verify message uses source field (not catId: null) + // AC-A+2: Verify the system sender union owns identity; source owns presentation. const msgA = appendedMessages.find((m) => m.threadId === 'thread-a'); assert.ok(msgA, 'thread-a should have a message'); - assert.ok(msgA.source, 'message must have source field (not catId: null)'); + assert.deepEqual(msgA.from, { kind: 'system', service: 'startup-reconciler' }); + assert.ok(msgA.source, 'message must have source field'); assert.equal(msgA.source.connector, 'startup-reconciler', 'source.connector must be startup-reconciler'); assert.equal(msgA.source.meta.presentation, 'system_notice'); assert.equal(msgA.source.meta.noticeTone, 'warning'); - assert.equal(msgA.catId, null, 'catId should be null (connector message)'); + assert.equal(msgA.catId, undefined, 'new writes must not carry the legacy naked catId'); assert.ok(msgA.content.includes('opus'), 'message should mention affected cat'); assert.ok( msgA.content.includes('restart') || msgA.content.includes('interrupted') || msgA.content.includes('重启'), @@ -875,171 +883,62 @@ describe('StartupReconciler', () => { assert.equal(result.running, 1); }); - // ── #697 + #805 review: recoverOrphanedQueuedMessages ── - - test('#697: recovers orphaned queued messages when no InvocationRecord exists', async () => { - // No InvocationRecords — message is purely orphaned - const deliveredIds = []; - const messageStore = { - append(msg) { - return { ...msg, id: 'msg-x', threadId: msg.threadId ?? 'default' }; - }, - scanByDeliveryStatus(status) { - if (status === 'queued') return ['orphan-msg-1', 'orphan-msg-2']; - return []; - }, - markDelivered(id, deliveredAt) { - deliveredIds.push(id); - return { - id, - threadId: 'thread-orphan', - userId: 'user-1', - mentions: ['opus'], - deliveryStatus: 'delivered', - deliveredAt, - }; - }, - }; - - const reconciler = new StartupReconciler({ - invocationRecordStore: store, - taskProgressStore, - log, - messageStore, - }); - - const result = await reconciler.reconcileOrphans(); - - assert.equal(deliveredIds.length, 2, 'both orphaned messages should be recovered'); - assert.ok(deliveredIds.includes('orphan-msg-1')); - assert.ok(deliveredIds.includes('orphan-msg-2')); - assert.equal(result.messagesRecovered, 2); - assert.equal(result.notifiedThreads, 1, 'owner-authored queued work still produces one thread notice'); - }); - - test('#805 P2-1: InvocationRecord cleanup — queued record with matching userMessageId is marked failed', async () => { - // Fresh queued InvocationRecord (< 5min, NOT caught by sweepStaleQueued) - const freshRecord = makeRecord({ - id: 'fresh-inv-1', - status: 'queued', - userMessageId: 'orphan-msg-1', - targetCats: ['opus'], - createdAt: Date.now() - 60_000, // 1 min ago (< 5min threshold) - }); - store.seed(freshRecord); + test('ADR-043: restart resumes pending Queue work without reconstructing already-claimed work', async () => { + const ledger = new InMemoryQueueLedgerStore(); + const queue = new InvocationQueue(ledger); + const processingAdmission = queue.enqueueDurableNow( + canonicalTestQueueInput({ + sourceId: 'processing-source', + threadId: 'thread-ledger-restart', + userId: 'user-1', + kind: 'conversation_input', + content: 'processing source', + targetCats: ['opus'], + messageId: 'processing-source', + intent: 'execute', + }), + ); + const claimed = await queue.markProcessingByIdDurable( + 'thread-ledger-restart', + processingAdmission.entry.id, + 'opus', + ); + assert.ok(claimed); + assert.equal(await queue.commitClaimedProcessing('thread-ledger-restart', [claimed.id]), true); + assert.equal( + await ledger.get('thread-ledger-restart', claimed.id), + null, + 'History owns processing/terminal truth after Queue releases its claim', + ); - const messageStore = { - append(msg) { - return { ...msg, id: 'msg-x', threadId: msg.threadId ?? 'default' }; - }, - scanByDeliveryStatus(status) { - if (status === 'queued') return ['orphan-msg-1']; - return []; - }, - markDelivered(id) { - return { - id, - threadId: 'thread-p2-1', - userId: 'user-1', - mentions: ['opus'], - deliveryStatus: 'delivered', - deliveredAt: Date.now(), - }; - }, - }; + queue.enqueueDurableNow( + canonicalTestQueueInput({ + sourceId: 'queued-source', + threadId: 'thread-ledger-restart', + userId: 'user-1', + kind: 'conversation_input', + content: 'queued source', + targetCats: ['opus'], + messageId: 'queued-source', + intent: 'execute', + }), + ); const reconciler = new StartupReconciler({ invocationRecordStore: store, taskProgressStore, log, - messageStore, + invocationQueue: queue, }); - const result = await reconciler.reconcileOrphans(); - // Message recovered - assert.equal(result.messagesRecovered, 1); - // InvocationRecord should now be 'failed' (not left as 'queued' residue) - const record = await store.get('fresh-inv-1'); - assert.equal(record.status, 'failed', 'InvocationRecord should be marked failed after message recovery'); - assert.equal(record.error, 'process_restart'); - }); - - test('#805 P2-1: InvocationRecord cleanup — unrelated queued records are NOT touched', async () => { - // Queued record whose userMessageId does NOT match any recovered message - const unrelatedRecord = makeRecord({ - id: 'unrelated-inv', - status: 'queued', - userMessageId: 'different-msg', - targetCats: ['codex'], - createdAt: Date.now() - 60_000, - }); - store.seed(unrelatedRecord); - - const messageStore = { - append(msg) { - return { ...msg, id: 'msg-x', threadId: msg.threadId ?? 'default' }; - }, - scanByDeliveryStatus(status) { - if (status === 'queued') return ['orphan-msg-1']; - return []; - }, - markDelivered(id) { - return { - id, - threadId: 'thread-keep', - userId: 'user-1', - mentions: ['opus'], - }; - }, - }; - - const reconciler = new StartupReconciler({ - invocationRecordStore: store, - taskProgressStore, - log, - messageStore, - }); - - await reconciler.reconcileOrphans(); - - // Unrelated record should still be queued (not swept — only 1 min old) - const record = await store.get('unrelated-inv'); - assert.equal(record.status, 'queued', 'unrelated queued record should NOT be touched'); - }); - - test('#805 P3-2: log.warn emitted when recovered message has no mentions', async () => { - const messageStore = { - append(msg) { - return { ...msg, id: 'msg-x', threadId: msg.threadId ?? 'default' }; - }, - scanByDeliveryStatus(status) { - if (status === 'queued') return ['no-mention-msg']; - return []; - }, - markDelivered(id) { - return { - id, - threadId: 'thread-no-mention', - userId: 'user-1', - // No mentions field - }; - }, - }; - - const reconciler = new StartupReconciler({ - invocationRecordStore: store, - taskProgressStore, - log, - messageStore, - }); - - await reconciler.reconcileOrphans(); - - assert.ok( - log.messages.some((m) => m.level === 'warn' && m.msg.includes('unusual') && m.msg.includes('no mentions')), - 'should log warning about message without mentions', + assert.equal(result.queueMessagesTerminalized, 0); + assert.deepEqual(result.queueResumeScopes, [{ threadId: 'thread-ledger-restart', userId: 'user-1' }]); + assert.deepEqual( + queue.list('thread-ledger-restart', 'user-1').map((entry) => entry.payload.messageId), + ['queued-source'], ); + assert.equal(await ledger.get('thread-ledger-restart', claimed.id), null); }); // ── Phase A (original) tests continue ── diff --git a/packages/api/test/system-info.test.js b/packages/api/test/system-info.test.js index c46262f686..9c753741af 100644 --- a/packages/api/test/system-info.test.js +++ b/packages/api/test/system-info.test.js @@ -64,12 +64,26 @@ describe('isUserFacingSystemInfoContent', () => { ); assert.ok( isUserFacingSystemInfoContent( - JSON.stringify({ type: 'warning', message: 'current opencode/CodeAgent adapter did not return token usage' }), + JSON.stringify({ + type: 'warning', + presentation: 'user_action_required', + message: 'current opencode/CodeAgent adapter did not return token usage', + }), ), 'warning system_info must be treated as user-facing so it persists and prevents silent_completion duplicates', ); }); + it('does not treat an unclassified provider diagnostic as user-facing', async () => { + const { isUserFacingSystemInfoContent } = await import( + '../dist/domains/cats/services/agents/routing/route-helpers.js' + ); + assert.equal( + isUserFacingSystemInfoContent(JSON.stringify({ type: 'warning', message: 'model metadata missing' })), + false, + ); + }); + it('recognizes cloud bridge status as user-facing', async () => { const { isUserFacingSystemInfoContent } = await import( '../dist/domains/cats/services/agents/routing/route-helpers.js' diff --git a/packages/api/test/system-prompt-builder.test.js b/packages/api/test/system-prompt-builder.test.js index c5ded94888..cae114211f 100644 --- a/packages/api/test/system-prompt-builder.test.js +++ b/packages/api/test/system-prompt-builder.test.js @@ -1367,8 +1367,8 @@ describe('SystemPromptBuilder', () => { { catId: 'codex', lastMessageAt: 1000, messageCount: 3 }, ], }); - assert.match(ctx, /最近活跃:布偶猫\(opus\)\n|最近活跃:布偶猫\(opus\)$/, 'Should inject displayName(id) format'); - assert.ok(!ctx.includes('最近活跃:缅因猫(codex)'), 'Self (codex) should not appear as most recently active'); + assert.match(ctx, /最近发言:布偶猫\(opus\)\n|最近发言:布偶猫\(opus\)$/, 'Should inject displayName(id) format'); + assert.ok(!ctx.includes('最近发言:缅因猫(codex)'), 'Self (codex) should not appear as most recent speaker'); }); test('buildInvocationContext skips self in activity list', async () => { @@ -1386,7 +1386,7 @@ describe('SystemPromptBuilder', () => { ], }); // opus is self and most-recent, should be skipped; codex is next - assert.match(ctx, /最近活跃:缅因猫\(codex\)\n|最近活跃:缅因猫\(codex\)$/, 'Should inject displayName(id) format'); + assert.match(ctx, /最近发言:缅因猫\(codex\)\n|最近发言:缅因猫\(codex\)$/, 'Should inject displayName(id) format'); }); test('buildInvocationContext omits hint when activeParticipants absent', async () => { @@ -1397,7 +1397,7 @@ describe('SystemPromptBuilder', () => { teammates: [], mcpAvailable: false, }); - assert.ok(!ctx.includes('最近活跃'), 'Should not inject when no activeParticipants'); + assert.ok(!ctx.includes('最近发言'), 'Should not inject when no activeParticipants'); }); test('buildInvocationContext omits hint when only self has activity', async () => { @@ -1414,7 +1414,7 @@ describe('SystemPromptBuilder', () => { { catId: 'codex', lastMessageAt: 0, messageCount: 0 }, ], }); - assert.ok(!ctx.includes('最近活跃'), 'Should not inject when no non-self participant has activity'); + assert.ok(!ctx.includes('最近发言'), 'Should not inject when no non-self participant has activity'); }); test('buildSystemPrompt size with activeParticipants stays under full runtime prompt budget after Magic Words + runtime prompt growth', async () => { diff --git a/packages/api/test/telemetry/mention-dispatch-trace.test.js b/packages/api/test/telemetry/mention-dispatch-trace.test.js index 52b507525c..683eb95c63 100644 --- a/packages/api/test/telemetry/mention-dispatch-trace.test.js +++ b/packages/api/test/telemetry/mention-dispatch-trace.test.js @@ -2,7 +2,6 @@ * F153: Cross-cat trace propagation via mention_dispatch spans. * * Covers: - * - Text-scan A2A path: mention_dispatch span created as child of mentioner's invocation * - Callback A2A path: wrapWithDispatchSpan inserts mention_dispatch between caller and dispatched route * - invocationSpanRef: caller can capture invocation span reference */ @@ -43,89 +42,10 @@ test('F153: invokeSingleCat writes span to invocationSpanRef', () => { // ── route-serial: mention_dispatch span creation ─────────────────── -test('F153: route-serial creates cat_cafe.mention_dispatch span on A2A push', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - assert.ok( - src.includes("'cat_cafe.mention_dispatch'") && src.includes('dispatch.target_count'), - 'Should create mention_dispatch span with dispatch.target_count attribute', - ); -}); - -test('F153: route-serial tracks mentionParentSpan for dispatched targets', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - assert.ok( - src.includes('mentionParentSpan') && src.includes('catInvocationSpans'), - 'Should track both mentionParentSpan and catInvocationSpans maps', - ); -}); - -test('F153: route-serial tracks catInvocationSpans per worklist index', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - assert.ok( - src.includes('catInvocationSpans.set(index, invocationSpanRef.current)'), - 'Should store invocation span by worklist index after cat completes', - ); -}); - -test('F153: dispatched cat uses mentionParentSpan as effective routeSpan', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - assert.ok( - src.includes('mentionParentSpan.get(index)') && src.includes('routeSpan: mentionParentSpan.get(index)'), - 'Should use mention dispatch span as parent for A2A-dispatched cat invocations', - ); -}); - // ── Dispatch span deferred end ──────────────────────────────────── -test('F153: dispatch span end is deferred until last child completes', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - assert.ok( - src.includes('pendingDispatchSpans') && src.includes('lastChildIndex'), - 'Should track pending dispatch spans with lastChildIndex for deferred end', - ); -}); - // ── Dispatch span lifecycle ──────────────────────────────────────── -test('F153: dispatch spans ended unconditionally in finally block', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - assert.ok( - src.includes('pendingDispatchSpans') && src.includes('entry.span.end()'), - 'Should end all dispatch spans unconditionally in finally block', - ); -}); - -test('F153: finally block ends orphaned dispatch spans on early abort', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - const finallyIdx = src.indexOf('} finally {'); - const cleanupIdx = src.indexOf('entry.span.end()'); - assert.ok( - finallyIdx > 0 && cleanupIdx > finallyIdx, - 'Finally block should unconditionally end all pending dispatch spans (abort safety)', - ); -}); - // ── Behavioral: OTel span parent-child wiring (real tracer) ─────── const { context: ctxApi, trace: traceApi } = await import('@opentelemetry/api'); @@ -265,7 +185,6 @@ test('F153 P1: genai-semconv exports route aggregate constants', () => { 'Should export ROUTE_TOTAL_CATS_INVOKED', ); assert.ok(src.includes("ROUTE_TOTAL_TOKENS = 'route.total_tokens'"), 'Should export ROUTE_TOTAL_TOKENS'); - assert.ok(src.includes("ROUTE_HAS_A2A_HANDOFF = 'route.has_a2a_handoff'"), 'Should export ROUTE_HAS_A2A_HANDOFF'); }); test('F153 P1: route-serial sets aggregate attributes on routeSpan in finally', () => { @@ -275,7 +194,6 @@ test('F153 P1: route-serial sets aggregate attributes on routeSpan in finally', ); assert.ok(src.includes('ROUTE_TOTAL_CATS_INVOKED'), 'Should import ROUTE_TOTAL_CATS_INVOKED'); assert.ok(src.includes('ROUTE_TOTAL_TOKENS'), 'Should import ROUTE_TOTAL_TOKENS'); - assert.ok(src.includes('ROUTE_HAS_A2A_HANDOFF'), 'Should import ROUTE_HAS_A2A_HANDOFF'); const finallyIdx = src.indexOf('} finally {'); const setAttrIdx = src.indexOf('routeSpan.setAttribute(ROUTE_TOTAL_CATS_INVOKED'); assert.ok(finallyIdx > 0 && setAttrIdx > finallyIdx, 'Aggregate attributes must be set inside finally block'); @@ -383,18 +301,13 @@ test('F153: callbacks.ts passes traceContext from InvocationRecord to enqueueA2A ); }); -test('F153: callback-a2a-trigger propagates callerTraceContext to both queue and legacy paths', () => { +test('F153: callback-a2a-trigger propagates callerTraceContext only through the canonical Queue path', () => { const src = readFileSync(resolve(__dirname, '../../src/routes/callback-a2a-trigger.ts'), 'utf8'); assert.ok( - src.includes('callerTraceContext: opts.callerTraceContext'), + src.includes('callerTraceContext: ensureDispatchTraceContext()'), 'Should pass callerTraceContext to invocationQueue.enqueue', ); - const triggerIdx = src.indexOf('async function triggerA2AInvocation'); - const routeExecIdx = src.indexOf('callerTraceContext: opts.callerTraceContext', triggerIdx); - assert.ok( - triggerIdx > 0 && routeExecIdx > triggerIdx, - 'triggerA2AInvocation should pass callerTraceContext to routeExecution', - ); + assert.equal(src.includes('triggerA2AInvocation'), false, 'direct callback execution must stay deleted'); }); test('F153: InvocationQueue entry includes callerTraceContext field', () => { @@ -443,8 +356,8 @@ test('F153: QueueProcessor passes callerTraceContext from entry to routeExecutio 'utf8', ); assert.ok( - src.includes('entry.callerTraceContext'), - 'QueueProcessor should pass entry.callerTraceContext to routeExecution', + src.includes('entry.execution.callerTraceContext'), + 'QueueProcessor should pass the ledger execution trace context to routeExecution', ); }); @@ -534,136 +447,3 @@ test('F153 behavioral: wrapWithDispatchSpan creates mention_dispatch as child of assert.equal(dispatch.attributes['dispatch.source'], 'callback', 'Should mark source as callback'); assert.equal(dispatch.parentSpanContext.spanId, callerSc.spanId, 'dispatch is child of caller'); }); - -// ── F185 deferred A2A path: mention_dispatch span + counter + ctx ── - -test('F153 Phase I (F185 deferred): route-serial creates mention_dispatch span in deferred path', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - // The deferred branch is the `else if (... && deferA2AEnqueue && !catSignal?.aborted)` block. - const deferredIdx = src.indexOf('deferA2AEnqueue && !catSignal?.aborted'); - assert.ok(deferredIdx > 0, 'Should find the deferred branch guard'); - // After the deferred guard, the same block should create a mention_dispatch span tagged as deferred. - const dispatchSpanIdx = src.indexOf("'cat_cafe.mention_dispatch'", deferredIdx); - const deferredSourceIdx = src.indexOf("'dispatch.source': 'text-scan-deferred'", deferredIdx); - assert.ok(dispatchSpanIdx > deferredIdx, 'Deferred branch should create cat_cafe.mention_dispatch span'); - assert.ok( - deferredSourceIdx > deferredIdx, - "Deferred dispatch span should set dispatch.source='text-scan-deferred' (distinct from callback/inline)", - ); -}); - -test('F153 Phase I (F185 deferred): deferred path increments a2aDispatchCount', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - const deferredIdx = src.indexOf('deferA2AEnqueue && !catSignal?.aborted'); - const counterIdx = src.indexOf('a2aDispatchCount.add', deferredIdx); - assert.ok( - counterIdx > deferredIdx, - 'Deferred branch should call a2aDispatchCount.add — otherwise Step Summary aggregate under-reports', - ); -}); - -test('F153 Phase I (F185 deferred): deferA2AEnqueue entry carries callerTraceContext', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - const deferredIdx = src.indexOf('deferA2AEnqueue && !catSignal?.aborted'); - // After the deferred guard, the enqueue invocation should include callerTraceContext - // so QueueProcessor reuses it as the parent for the dispatched route. - const enqueueCallIdx = src.indexOf('deferA2AEnqueue({', deferredIdx); - const ctxFieldIdx = src.indexOf('callerTraceContext: deferredDispatchCtx', deferredIdx); - assert.ok(enqueueCallIdx > deferredIdx, 'Deferred branch should call deferA2AEnqueue({...})'); - assert.ok( - ctxFieldIdx > enqueueCallIdx, - 'deferA2AEnqueue entry must include callerTraceContext: deferredDispatchCtx — otherwise cross-route causality is severed at the fairness gate', - ); -}); - -test('F153 Phase I (F185 deferred): deferA2AEnqueue entry contract declares callerTraceContext', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-helpers.ts'), - 'utf8', - ); - // The entry type for deferA2AEnqueue must declare callerTraceContext so callers (route-serial, - // and downstream consumers including QueueProcessor) see a stable contract. - assert.ok( - src.includes('callerTraceContext?: import(') && src.includes('CallerTraceContext'), - 'RouteOptions.deferA2AEnqueue entry type should declare optional callerTraceContext field', - ); -}); - -test('F153 Phase I behavioral (F185 deferred): captured ctx parents the dispatched route', async () => { - otelExporter.reset(); - - // Simulate route-serial deferred path: - // route1 → invocation(A) → mention_dispatch (ended immediately, source=text-scan-deferred) - // ↘ queue handoff (captured ctx) - // Later: QueueProcessor → route2 (child of mention_dispatch via remote ctx) - const route1 = otelTracer.startSpan('cat_cafe.route'); - const route1Ctx = traceApi.setSpan(ctxApi.active(), route1); - - const invA = otelTracer.startSpan('cat_cafe.invocation', { attributes: { 'agent.id': 'opus' } }, route1Ctx); - - // Lazy create dispatch span as child of mentioner invocation, then immediately end. - const invACtx = traceApi.setSpan(ctxApi.active(), invA); - const dispatchSpan = otelTracer.startSpan( - 'cat_cafe.mention_dispatch', - { - attributes: { - 'agent.id': 'opus', - 'dispatch.target_count': 1, - 'dispatch.source': 'text-scan-deferred', - }, - }, - invACtx, - ); - const dispatchSc = dispatchSpan.spanContext(); - dispatchSpan.end(); - invA.end(); - route1.end(); - - // Captured CallerTraceContext propagated through queue entry. - const callerCtx = { traceId: dispatchSc.traceId, spanId: dispatchSc.spanId, traceFlags: dispatchSc.traceFlags }; - - // Downstream QueueProcessor reconstructs remote parent (mirrors AgentRouter.routeExecution). - const { context: ctxApiLocal, trace: traceApiLocal } = await import('@opentelemetry/api'); - const remoteParentCtx = traceApiLocal.setSpanContext(ctxApiLocal.active(), { - traceId: callerCtx.traceId, - spanId: callerCtx.spanId, - traceFlags: callerCtx.traceFlags, - isRemote: true, - }); - const route2 = otelTracer.startSpan('cat_cafe.route', {}, remoteParentCtx); - const route2Ctx = traceApiLocal.setSpan(ctxApiLocal.active(), route2); - const invB = otelTracer.startSpan('cat_cafe.invocation', { attributes: { 'agent.id': 'sonnet' } }, route2Ctx); - invB.end(); - route2.end(); - - const spans = otelExporter.getFinishedSpans(); - const dispatch = spans.find( - (s) => s.name === 'cat_cafe.mention_dispatch' && s.attributes['dispatch.source'] === 'text-scan-deferred', - ); - const route2Span = spans.find((s) => s.name === 'cat_cafe.route' && s.parentSpanContext); - const invBSpan = spans.find((s) => s.attributes['agent.id'] === 'sonnet'); - - assert.ok(dispatch, 'deferred dispatch span should be present'); - assert.ok(route2Span && invBSpan, 'queued route2 and invocation B should be present'); - - // Same trace across the fairness-gate hop. - const traceId = dispatch.spanContext().traceId; - assert.equal(route2Span.spanContext().traceId, traceId, 'queued route stays in caller trace'); - assert.equal(invBSpan.spanContext().traceId, traceId, 'dispatched invocation stays in caller trace'); - - // route2 is parented at the dispatch span (not the mentioner directly). - assert.equal( - route2Span.parentSpanContext.spanId, - dispatch.spanContext().spanId, - 'queued route is child of mention_dispatch (preserves causality through fairness gate)', - ); -}); diff --git a/packages/api/test/telemetry/step-summary.test.js b/packages/api/test/telemetry/step-summary.test.js index 3a4dad48d5..de6a09e953 100644 --- a/packages/api/test/telemetry/step-summary.test.js +++ b/packages/api/test/telemetry/step-summary.test.js @@ -4,7 +4,7 @@ * - Behavioral: computeStepSummary on synthetic spans covers * live/restored, missing marker, dual-track tool counts, width derivation, * and the descriptive-only contract (no normative fields synthesized). - * - Source-level: counter wiring (instruments + dispatch-span + route-serial), + * - Source-level: counter wiring (instruments + dispatch-span), * recordAgentLoop wiring (span-helpers + invoke-single-cat), Claude parser * emits agent_loop on message_stop, AgentMessageType extension. */ @@ -37,15 +37,6 @@ test('F153 Phase I: dispatch-span.ts increments counter and accepts optional sou ); }); -test('F153 Phase I: route-serial.ts increments a2aDispatchCount on in-process dispatch', () => { - const src = readFileSync( - resolve(__dirname, '../../src/domains/cats/services/agents/routing/route-serial.ts'), - 'utf8', - ); - assert.ok(src.includes('a2aDispatchCount'), 'Should import a2aDispatchCount'); - assert.ok(src.includes('a2aDispatchCount.add(1'), 'Should call a2aDispatchCount.add at dispatch span creation'); -}); - test('F153 Phase I: span-helpers.ts exports recordAgentLoop, sets agent_loop.count attribute', () => { const src = readFileSync(resolve(__dirname, '../../src/infrastructure/telemetry/span-helpers.ts'), 'utf8'); assert.ok(src.includes('export function recordAgentLoop'), 'Should export recordAgentLoop'); @@ -92,16 +83,23 @@ test('F153 Phase I: telemetry routes expose /api/telemetry/step-summary endpoint test('F153 Phase I (P1-1): callback-a2a-trigger lazy-creates dispatch span only when actually dispatching', () => { const src = readFileSync(resolve(__dirname, '../../src/routes/callback-a2a-trigger.ts'), 'utf8'); + const enqueueSource = src.slice(src.indexOf('export async function enqueueA2ATargets')); // Lazy helper must exist - assert.ok(src.includes('ensureDispatchTraceContext'), 'Should define ensureDispatchTraceContext lazy helper'); + assert.ok( + enqueueSource.includes('ensureDispatchTraceContext'), + 'Should define ensureDispatchTraceContext lazy helper', + ); // wrapWithDispatchSpan must NOT be called at function top before guards // (look for top-level early creation pattern) assert.ok( - !/const dispatchTraceContext = opts\.callerTraceContext\s*\?\s*wrapWithDispatchSpan/.test(src), + !/const dispatchTraceContext = opts\.callerTraceContext\s*\?\s*wrapWithDispatchSpan/.test(enqueueSource), 'Must NOT pre-allocate dispatchTraceContext before depth/dedup/streak guards', ); // All three downstream usage points must go through the lazy helper - assert.ok(src.includes('callerTraceContext: ensureDispatchTraceContext()'), 'enqueue path must use lazy helper'); + assert.ok( + enqueueSource.includes('callerTraceContext: ensureDispatchTraceContext()'), + 'enqueue path must use lazy helper', + ); }); test('F153 Phase I (P1-2): step-summary route reads full buffer, not capped at 500', () => { @@ -119,22 +117,6 @@ test('F153 Phase I (P1-2): step-summary route reads full buffer, not capped at 5 ); }); -test('F153 Phase I (round-2 P2): legacy worklist callback success also mints dispatch span/counter', () => { - const src = readFileSync(resolve(__dirname, '../../src/routes/callback-a2a-trigger.ts'), 'utf8'); - // The lazy helper must be invoked in the worklist success branch (enqueued.length > 0) - // — otherwise callbacks routed via the legacy F027 worklist path will silently skip the - // mention_dispatch span and a2a.dispatch.count counter even when dispatch did happen. - const worklistParts = src.split('if (hasWorklist(threadId))'); - assert.ok(worklistParts[1], 'callback-a2a-trigger should contain the legacy worklist branch'); - const successParts = worklistParts[1].split('if (enqueued.length > 0)'); - assert.ok(successParts[1], 'legacy worklist branch should contain the success branch'); - const successBranch = successParts[1]; - assert.ok( - successBranch.includes('ensureDispatchTraceContext()'), - 'worklist success branch must call ensureDispatchTraceContext() (lazy side-effects: span + counter)', - ); -}); - test('AC-I1 (P1-2 regression): computeStepSummary aggregates correctly across > 500 spans', async () => { const computeStepSummary = await importComputeStepSummary(); if (!computeStepSummary) return; diff --git a/packages/api/test/thread-branch-permission.test.js b/packages/api/test/thread-branch-permission.test.js index f8478483b1..044557f788 100644 --- a/packages/api/test/thread-branch-permission.test.js +++ b/packages/api/test/thread-branch-permission.test.js @@ -9,6 +9,7 @@ import { describe, it } from 'node:test'; import Fastify from 'fastify'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { threadBranchRoutes } from '../dist/routes/thread-branch.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { const events = []; @@ -65,14 +66,16 @@ describe('F109: Branch from system-created thread', () => { const socketManager = createMockSocketManager(); // Seed a system-created thread with a message - const msg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'hello from system thread', - mentions: [], - timestamp: 1000, - threadId: 'thread-sys', - }); + const msg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'hello from system thread', + mentions: [], + timestamp: 1000, + threadId: 'thread-sys', + }), + ); const threadStore = createMockThreadStore({ 'thread-sys': { @@ -110,14 +113,16 @@ describe('F109: Branch from system-created thread', () => { const messageStore = new MessageStore(); const socketManager = createMockSocketManager(); - const msg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'message', - mentions: [], - timestamp: 1000, - threadId: 'thread-other', - }); + const msg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'message', + mentions: [], + timestamp: 1000, + threadId: 'thread-other', + }), + ); const threadStore = createMockThreadStore({ 'thread-other': { diff --git a/packages/api/test/thread-branch.test.js b/packages/api/test/thread-branch.test.js index 68a24cad6e..5340ae5e3d 100644 --- a/packages/api/test/thread-branch.test.js +++ b/packages/api/test/thread-branch.test.js @@ -10,6 +10,7 @@ import { describe, it } from 'node:test'; import Fastify from 'fastify'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { threadBranchRoutes } from '../dist/routes/thread-branch.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { const events = []; @@ -93,44 +94,52 @@ function seedThread(messageStore, threadStore) { const msgs = []; msgs.push( - messageStore.append({ - userId: 'user-1', - catId: null, - content: '你好', - mentions: ['opus'], - timestamp: 1000, - threadId: 'thread-orig', - }), + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '你好', + mentions: ['opus'], + timestamp: 1000, + threadId: 'thread-orig', + }), + ), ); msgs.push( - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '你好!有什么可以帮你?', - mentions: [], - timestamp: 1001, - threadId: 'thread-orig', - }), + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '你好!有什么可以帮你?', + mentions: [], + timestamp: 1001, + threadId: 'thread-orig', + }), + ), ); msgs.push( - messageStore.append({ - userId: 'user-1', - catId: null, - content: '帮我写个登录页', - mentions: ['opus'], - timestamp: 1002, - threadId: 'thread-orig', - }), + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: '帮我写个登录页', + mentions: ['opus'], + timestamp: 1002, + threadId: 'thread-orig', + }), + ), ); msgs.push( - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: '好的,已创建登录页...', - mentions: [], - timestamp: 1003, - threadId: 'thread-orig', - }), + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: '好的,已创建登录页...', + mentions: [], + timestamp: 1003, + threadId: 'thread-orig', + }), + ), ); return msgs; } @@ -199,14 +208,57 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { const messageStore = new MessageStore(); const threadStore = createMockThreadStore(); seedThread(messageStore, threadStore); + const seed = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'published source-cat seed', + mentions: ['opus'], + timestamp: 1004, + threadId: 'thread-orig', + deliveryStatus: 'queued', + }), + ); + const { app } = await setupApp(messageStore, threadStore); + + const res = await app.inject({ + method: 'POST', + url: '/api/threads/thread-orig/branch', + payload: { fromMessageId: seed.id, userId: 'user-1' }, + }); + + assert.equal(res.statusCode, 201, res.body); + const body = res.json(); + assert.equal(body.messageCount, 5); + assert.equal(messageStore.getByThread(body.threadId, 100).at(-1).content, 'published source-cat seed'); + await app.close(); + }); + + it('preserves canonical plugin identity and payload when branching', async () => { + const messageStore = new MessageStore(); + const threadStore = createMockThreadStore(); + threadStore._seedThread('thread-orig', { + title: 'Plugin source', + createdBy: 'user-1', + }); + const pluginMessage = { + instanceId: 'plugin-instance-1', + revision: 1, + provenance: { + origin: { kind: 'plugin', instanceId: 'plugin-instance-1' }, + epistemicStatus: 'inference', + }, + elements: [{ elementId: 'el-1', kind: 'text', payload: { text: 'plugin output' } }], + appendOps: [], + }; const seed = messageStore.append({ + from: { kind: 'plugin', instanceId: 'plugin-instance-1' }, userId: 'user-1', - catId: 'codex', - content: 'published source-cat seed', - mentions: ['opus'], - timestamp: 1004, + content: 'plugin output', + mentions: [], + timestamp: 1000, threadId: 'thread-orig', - deliveryStatus: 'queued', + extra: { pluginMessage }, }); const { app } = await setupApp(messageStore, threadStore); @@ -217,9 +269,10 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { }); assert.equal(res.statusCode, 201, res.body); - const body = res.json(); - assert.equal(body.messageCount, 5); - assert.equal(messageStore.getByThread(body.threadId, 100).at(-1).content, 'published source-cat seed'); + const copied = messageStore.getByThread(res.json().threadId, 100); + assert.equal(copied.length, 1); + assert.deepEqual(copied[0].from, { kind: 'plugin', instanceId: 'plugin-instance-1' }); + assert.deepEqual(copied[0].extra?.pluginMessage, pluginMessage); await app.close(); }); @@ -332,14 +385,16 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { seedThread(messageStore, threadStore); // Create a message in a different thread - const otherMsg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'other thread', - mentions: [], - timestamp: 2000, - threadId: 'other-thread', - }); + const otherMsg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'other thread', + mentions: [], + timestamp: 2000, + threadId: 'other-thread', + }), + ); threadStore._seedThread('other-thread', { title: 'Other' }); const { app } = await setupApp(messageStore, threadStore); @@ -546,14 +601,16 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { title: null, createdBy: 'user-1', }); - const msg = messageStore.append({ - userId: 'user-1', - catId: null, - content: 'hi', - mentions: [], - timestamp: 1000, - threadId: 'thread-notitle', - }); + const msg = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'hi', + mentions: [], + timestamp: 1000, + threadId: 'thread-notitle', + }), + ); const { app } = await setupApp(messageStore, threadStore); @@ -580,34 +637,40 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { }); // User message (no origin) - messageStore.append({ - userId: 'user-1', - catId: null, - content: 'Hello', - mentions: ['opus'], - timestamp: 1000, - threadId: 'thread-origin-test', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + content: 'Hello', + mentions: ['opus'], + timestamp: 1000, + threadId: 'thread-origin-test', + }), + ); // Opus stream-origin persisted message - messageStore.append({ - userId: 'user-1', - catId: 'opus', - content: 'persisted answer', - mentions: [], - origin: 'stream', - timestamp: 1001, - threadId: 'thread-origin-test', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'opus', + content: 'persisted answer', + mentions: [], + origin: 'stream', + timestamp: 1001, + threadId: 'thread-origin-test', + }), + ); // Codex callback message (origin: 'callback' — should be visible) - const m3 = messageStore.append({ - userId: 'user-1', - catId: 'codex', - content: 'result', - mentions: [], - origin: 'callback', - timestamp: 1002, - threadId: 'thread-origin-test', - }); + const m3 = messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: 'codex', + content: 'result', + mentions: [], + origin: 'callback', + timestamp: 1002, + threadId: 'thread-origin-test', + }), + ); const { app } = await setupApp(messageStore, threadStore); diff --git a/packages/api/test/thread-cats.test.js b/packages/api/test/thread-cats.test.js index 76208ba853..df3488d6eb 100644 --- a/packages/api/test/thread-cats.test.js +++ b/packages/api/test/thread-cats.test.js @@ -2,7 +2,7 @@ * GET /api/threads/:id/cats — thread cat categorization API (F142) */ import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it } from 'node:test'; +import { afterEach, describe, it } from 'node:test'; import Fastify from 'fastify'; function stubThreadStore(threads = new Map(), participants = new Map()) { @@ -34,13 +34,14 @@ describe('GET /api/threads/:id/cats', () => { if (app) await app.close(); }); - async function setup({ threads, participants, bindings, roster, services, available } = {}) { + async function setup({ threads, participants, bindings, roster, services, available, fallbackTargetCatId } = {}) { const { threadCatsRoutes } = await import('../dist/routes/thread-cats.js'); app = Fastify(); await app.register(threadCatsRoutes, { threadStore: stubThreadStore(threads, participants), bindingStore: stubBindingStore(bindings), ...stubCatDeps({ roster, services, available }), + resolveConversationFallbackTarget: async () => fallbackTargetCatId, }); await app.ready(); return app; @@ -166,4 +167,22 @@ describe('GET /api/threads/:id/cats', () => { const body = JSON.parse(res.body); assert.equal(body.participants[0].displayName, '布偶猫 Opus'); }); + + it('projects the exact no-mention fallback shared by message ingress and Queue admission', async () => { + const threads = new Map([['t-fallback', { id: 't-fallback' }]]); + await setup({ threads, roster: {}, fallbackTargetCatId: 'codex' }); + + const res = await app.inject({ method: 'GET', url: '/api/threads/t-fallback/cats' }); + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).fallbackTargetCatId, 'codex'); + }); + + it('uses null instead of guessing when canonical conversation routing has no fallback', async () => { + const threads = new Map([['t-no-fallback', { id: 't-no-fallback' }]]); + await setup({ threads, roster: {} }); + + const res = await app.inject({ method: 'GET', url: '/api/threads/t-no-fallback/cats' }); + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).fallbackTargetCatId, null); + }); }); diff --git a/packages/api/test/thread-context-workflow-sop.test.js b/packages/api/test/thread-context-workflow-sop.test.js index 393f49cfa3..84496a3d34 100644 --- a/packages/api/test/thread-context-workflow-sop.test.js +++ b/packages/api/test/thread-context-workflow-sop.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import Fastify from 'fastify'; import './helpers/setup-cat-registry.js'; +import { adaptMessageStore, canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; function createMockSocketManager() { return { @@ -28,7 +29,7 @@ describe('GET thread-context with workflowSop', () => { const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); registry = new InvocationRegistry(); - messageStore = new MessageStore(); + messageStore = adaptMessageStore(new MessageStore()); threadStore = new ThreadStore(); socketManager = createMockSocketManager(); workflowSopStore = createInMemoryWorkflowSopStore(); @@ -69,7 +70,7 @@ describe('GET thread-context with workflowSop', () => { }; } - async function createApp() { + async function createApp(overrides = {}) { const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); const app = Fastify(); await app.register(callbacksRoutes, { @@ -78,6 +79,7 @@ describe('GET thread-context with workflowSop', () => { socketManager, threadStore, workflowSopStore, + ...overrides, }); return app; } @@ -125,14 +127,16 @@ describe('GET thread-context with workflowSop', () => { await workflowSopStore.upsert('item-73', 'F073', {}, 'opus', 'test-user'); // Add a message so we have content - messageStore.append({ - userId: 'user-1', - catId: null, - threadId: thread.id, - content: 'Hello', - mentions: [], - timestamp: Date.now(), - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + threadId: thread.id, + content: 'Hello', + mentions: [], + timestamp: Date.now(), + }), + ); const response = await app.inject({ method: 'GET', @@ -161,6 +165,41 @@ describe('GET thread-context with workflowSop', () => { assert.equal(body.workflowSop.updatedAt, undefined); }); + test('returns the same exact lifecycle situation exposed to agent prompts and UI', async () => { + const expectedSituation = { + kind: 'thread_execution_situation.v1', + complete: true, + activeRuns: [ + { + phase: 'processing', + targetId: 'kimi', + invocationId: 'invocation-kimi', + responseMessageId: 'response-kimi', + startedAt: 200, + sources: [{ messageId: 'source-opus', from: { kind: 'agent', catId: 'opus' } }], + }, + ], + }; + const app = await createApp({ + threadExecutionSituationSource: { + async resolve() { + return expectedSituation; + }, + }, + }); + const thread = threadStore.create('user-1', 'execution situation', 'default'); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', thread.id); + + const response = await app.inject({ + method: 'GET', + url: '/api/callbacks/thread-context?responseMode=full', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + + assert.equal(response.statusCode, 200, response.body); + assert.deepEqual(JSON.parse(response.body).situation, expectedSituation); + }); + test('bounds an oversized workflowSop even when the thread has no messages', async () => { const app = await createApp(); const thread = threadStore.create('user-1', 'F236 oversized workflow SOP', 'default'); @@ -311,14 +350,16 @@ describe('GET thread-context with workflowSop', () => { updatedBy: 'opus', }); - messageStore.append({ - userId: 'user-1', - catId: null, - threadId: thread.id, - content: 'Hello', - mentions: [], - timestamp: Date.now(), - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + threadId: thread.id, + content: 'Hello', + mentions: [], + timestamp: Date.now(), + }), + ); const response = await app.inject({ method: 'GET', @@ -339,14 +380,16 @@ describe('GET thread-context with workflowSop', () => { const thread = threadStore.create('user-1', 'plain thread', 'default'); const { invocationId, callbackToken } = await registry.create('user-1', 'opus', thread.id); - messageStore.append({ - userId: 'user-1', - catId: null, - threadId: thread.id, - content: 'Hello', - mentions: [], - timestamp: Date.now(), - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + threadId: thread.id, + content: 'Hello', + mentions: [], + timestamp: Date.now(), + }), + ); const response = await app.inject({ method: 'GET', @@ -374,14 +417,16 @@ describe('GET thread-context with workflowSop', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus', ownThread.id); // Add a message so thread-context has content - messageStore.append({ - userId: 'user-2', - catId: null, - threadId: otherThread.id, - content: 'Hello from other user', - mentions: [], - timestamp: Date.now(), - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-2', + catId: null, + threadId: otherThread.id, + content: 'Hello from other user', + mentions: [], + timestamp: Date.now(), + }), + ); // Try to read other user's thread context with override const response = await app.inject({ @@ -403,14 +448,16 @@ describe('GET thread-context with workflowSop', () => { threadStore.linkBacklogItem(thread.id, 'item-no-sop'); const { invocationId, callbackToken } = await registry.create('user-1', 'opus', thread.id); - messageStore.append({ - userId: 'user-1', - catId: null, - threadId: thread.id, - content: 'Hello', - mentions: [], - timestamp: Date.now(), - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'user-1', + catId: null, + threadId: thread.id, + content: 'Hello', + mentions: [], + timestamp: Date.now(), + }), + ); const response = await app.inject({ method: 'GET', diff --git a/packages/api/test/thread-execution-situation.test.js b/packages/api/test/thread-execution-situation.test.js new file mode 100644 index 0000000000..5cf1c78edc --- /dev/null +++ b/packages/api/test/thread-execution-situation.test.js @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +const { createThreadExecutionSituationSource } = await import( + '../dist/domains/cats/services/agents/invocation/thread-execution-situation.js' +); + +function exactFixture() { + const sourceMessage = { + id: 'message-source', + threadId: 'thread-1', + from: { kind: 'agent', catId: 'opus' }, + lifecycle: { + kind: 'input', + orderKey: '0000000000100:message-source', + dispatchRefs: [{ targetId: 'kimi', phase: 'dispatched', statusMessageId: 'message-response' }], + }, + }; + const responseMessage = { + id: 'message-response', + threadId: 'thread-1', + from: { kind: 'agent', catId: 'kimi' }, + lifecycle: { + kind: 'response', + orderKey: '0000000000200:message-response', + invocationId: 'invocation-kimi', + targetId: 'kimi', + inputEntryIds: ['entry-kimi'], + inputMessageIds: ['message-source'], + status: 'processing', + startedAt: 200, + }, + }; + const activeRun = { + threadId: 'thread-1', + targetId: 'kimi', + invocationId: 'invocation-kimi', + responseMessageId: 'message-response', + inputEntryIds: ['entry-kimi'], + inputMessageIds: ['message-source'], + privateInputEntryIds: [], + startedAt: 200, + }; + return { sourceMessage, responseMessage, activeRun }; +} + +function sourceFor(messages, activeRuns) { + const byId = new Map(messages.map((message) => [message.id, message])); + return createThreadExecutionSituationSource({ + messageStore: { + async getById(messageId) { + return byId.get(messageId); + }, + }, + listActiveRuns: () => activeRuns, + }); +} + +describe('thread execution situation', () => { + test('projects one active member only when source, response, and active run identities all agree', async () => { + const { sourceMessage, responseMessage, activeRun } = exactFixture(); + const situation = await sourceFor([sourceMessage, responseMessage], [activeRun]).resolve('thread-1'); + + assert.deepEqual(situation, { + kind: 'thread_execution_situation.v1', + complete: true, + activeRuns: [ + { + phase: 'processing', + targetId: 'kimi', + invocationId: 'invocation-kimi', + responseMessageId: 'message-response', + startedAt: 200, + sources: [{ messageId: 'message-source', from: { kind: 'agent', catId: 'opus' } }], + }, + ], + }); + }); + + test('fails closed instead of treating an unjoined runtime witness as active', async () => { + const { sourceMessage, responseMessage, activeRun } = exactFixture(); + const mismatchedRun = { ...activeRun, invocationId: 'different-invocation' }; + const situation = await sourceFor([sourceMessage, responseMessage], [mismatchedRun]).resolve('thread-1'); + + assert.deepEqual(situation, { + kind: 'thread_execution_situation.v1', + complete: false, + activeRuns: [], + }); + }); + + test('fails closed on ambiguous source-to-target dispatch identity', async () => { + const { sourceMessage, responseMessage, activeRun } = exactFixture(); + sourceMessage.lifecycle.dispatchRefs.push({ targetId: 'kimi', phase: 'assigned' }); + const situation = await sourceFor([sourceMessage, responseMessage], [activeRun]).resolve('thread-1'); + + assert.equal(situation.complete, false); + assert.deepEqual(situation.activeRuns, []); + }); + + test('fails closed rather than joining lifecycle records across thread boundaries', async () => { + const { sourceMessage, responseMessage, activeRun } = exactFixture(); + for (const messages of [ + [{ ...sourceMessage, threadId: 'another-thread' }, responseMessage], + [sourceMessage, { ...responseMessage, threadId: 'another-thread' }], + ]) { + const situation = await sourceFor(messages, [activeRun]).resolve('thread-1'); + assert.deepEqual(situation, { + kind: 'thread_execution_situation.v1', + complete: false, + activeRuns: [], + }); + } + }); + + test('does not invent a public situation claim for private-only execution', async () => { + const { responseMessage, activeRun } = exactFixture(); + const privateRun = { ...activeRun, inputMessageIds: [] }; + const situation = await sourceFor([responseMessage], [privateRun]).resolve('thread-1'); + + assert.deepEqual(situation, { + kind: 'thread_execution_situation.v1', + complete: true, + activeRuns: [], + }); + }); +}); diff --git a/packages/api/test/threads-endpoint.test.js b/packages/api/test/threads-endpoint.test.js index b583f3581f..c5786ddc74 100644 --- a/packages/api/test/threads-endpoint.test.js +++ b/packages/api/test/threads-endpoint.test.js @@ -11,6 +11,7 @@ import { mkdtemp, realpath, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('Thread API', () => { let app; @@ -1088,22 +1089,26 @@ describe('Thread soft-delete preserves data (Phase D)', () => { const threadId = thread.id; // Add some messages - messageStore.append({ - userId: 'alice', - catId: null, - content: 'test message 1', - mentions: [], - timestamp: Date.now(), - threadId, - }); - messageStore.append({ - userId: 'alice', - catId: null, - content: 'test message 2', - mentions: [], - timestamp: Date.now() + 1, - threadId, - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'test message 1', + mentions: [], + timestamp: Date.now(), + threadId, + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'test message 2', + mentions: [], + timestamp: Date.now() + 1, + threadId, + }), + ); // Add a task taskStore.create({ @@ -1540,22 +1545,26 @@ describe('GET /api/messages with threadId', () => { }); it('returns only messages for the specified thread', async () => { - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'thread-a msg', - mentions: [], - timestamp: 1000, - threadId: 'thread-a', - }); - messageStore.append({ - userId: 'default-user', - catId: null, - content: 'thread-b msg', - mentions: [], - timestamp: 2000, - threadId: 'thread-b', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'thread-a msg', + mentions: [], + timestamp: 1000, + threadId: 'thread-a', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: 'thread-b msg', + mentions: [], + timestamp: 2000, + threadId: 'thread-b', + }), + ); const res = await app.inject({ method: 'GET', @@ -1567,22 +1576,26 @@ describe('GET /api/messages with threadId', () => { }); it('thread query filters by userId (regression: cross-user leak)', async () => { - messageStore.append({ - userId: 'alice', - catId: null, - content: 'alice in thread', - mentions: [], - timestamp: 1000, - threadId: 'shared-thread', - }); - messageStore.append({ - userId: 'bob', - catId: null, - content: 'bob in thread', - mentions: [], - timestamp: 2000, - threadId: 'shared-thread', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'alice', + catId: null, + content: 'alice in thread', + mentions: [], + timestamp: 1000, + threadId: 'shared-thread', + }), + ); + messageStore.append( + canonicalTestMessageInput({ + userId: 'bob', + catId: null, + content: 'bob in thread', + mentions: [], + timestamp: 2000, + threadId: 'shared-thread', + }), + ); const res = await app.inject({ method: 'GET', @@ -1596,14 +1609,16 @@ describe('GET /api/messages with threadId', () => { it('thread-scoped pagination with before cursor', async () => { for (let i = 0; i < 5; i++) { - messageStore.append({ - userId: 'default-user', - catId: null, - content: `t-msg ${i}`, - mentions: [], - timestamp: 1000 + i * 100, - threadId: 'paginated-thread', - }); + messageStore.append( + canonicalTestMessageInput({ + userId: 'default-user', + catId: null, + content: `t-msg ${i}`, + mentions: [], + timestamp: 1000 + i * 100, + threadId: 'paginated-thread', + }), + ); } const res = await app.inject({ diff --git a/packages/api/test/tmux-spawn-override.test.js b/packages/api/test/tmux-spawn-override.test.js index de6c7044a3..a158a7b86f 100644 --- a/packages/api/test/tmux-spawn-override.test.js +++ b/packages/api/test/tmux-spawn-override.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { after, before, describe, it } from 'node:test'; import { AgentPaneRegistry } from '../dist/domains/terminal/agent-pane-registry.js'; +import { buildTmuxAgentCarrierExitError } from '../dist/domains/terminal/tmux-agent-carrier-session.js'; import { createTmuxSpawnOverride } from '../dist/domains/terminal/tmux-agent-spawner.js'; import { TmuxGateway } from '../dist/domains/terminal/tmux-gateway.js'; @@ -58,6 +59,15 @@ describe('createTmuxSpawnOverride', () => { assert.equal(jsonEvents.length, 1); }); + it('sanitizes tmux carrier stderr before taking its tail excerpt', async () => { + const token = `sk-${'X'.repeat(40)}`; + const stderr = `${'A'.repeat(1_100)}${token}${'B'.repeat(980)}`; + const error = buildTmuxAgentCarrierExitError(1, stderr); + + assert.doesNotMatch(error.message, /X{8,}/); + assert.match(error.message, /\[TOKEN_REDACTED\]/); + }); + it('the app-server tmux carrier applies the same cat CLI process context', () => { const source = readFileSync( new URL('../src/domains/terminal/tmux-agent-carrier-session.ts', import.meta.url), diff --git a/packages/api/test/turn-custody-projection.test.js b/packages/api/test/turn-custody-projection.test.js index 17b28b11e4..403b798a83 100644 --- a/packages/api/test/turn-custody-projection.test.js +++ b/packages/api/test/turn-custody-projection.test.js @@ -1,8 +1,9 @@ /** - * F167 Phase T — turn-scoped custody stop-gate projection. + * F167 turn-scoped custody projection. * - * The adapter only follows the protocol ball that woke this invocation. Other - * open work is deliberately absent from the harness. + * Message lifecycle owns ordinary A2A, managed-hold, and event-wait wake + * completion. The stop gate remains only for durable action successors and + * genuinely unclassified legacy wakes. */ import assert from 'node:assert/strict'; @@ -25,47 +26,29 @@ function lease(overrides = {}) { }; } -function harness({ currentLease = lease(), projection = { state: 'active', holder: 'codex-sol' }, events = [] } = {}) { +function harness(currentLease = lease()) { let activeLease = currentLease; - const eventLog = [...events]; - let threadProjection = projection; const service = new TurnCustodyProjectionService({ actionSuccessorLeaseStore: { async get() { return activeLease; }, }, - ballCustodyProjectionStore: { - async get() { - return threadProjection; - }, - }, - ballCustodyEventLog: { - async read(_subjectKey, fromSequence = 0) { - return eventLog.slice(fromSequence); - }, - }, }); return { service, setLease(next) { activeLease = next; }, - setProjection(next) { - threadProjection = next; - }, - addEvent(event) { - eventLog.push(event); - }, }; } -describe('F167 Phase T TurnCustodyProjectionService', () => { - test('user chat, roam, cron, and protocol decline are covered_empty with zero stop obligation', async () => { +describe('F167 TurnCustodyProjectionService', () => { + test('user chat, roam, cron, and protocol decline have zero stop obligation', async () => { const { service } = harness(); for (const source of ['user_chat', 'roam', 'cron', 'protocol_decline']) { const opened = await service.open({ kind: 'unstructured', source }); - assert.equal(opened.state, 'covered_empty'); + assert.deepEqual(opened, { state: 'covered_empty', evidenceRefs: [`wake:${source}`] }); assert.deepEqual(await service.close(opened), { state: 'covered_empty', shouldBlock: false, @@ -75,24 +58,74 @@ describe('F167 Phase T TurnCustodyProjectionService', () => { } }); - test('machine-proven FYI and coordination clean-stops are covered_empty', async () => { + test('machine-proven FYI and coordination clean-stops are non-obligating', async () => { const { service } = harness(); for (const source of ['cross_thread_fyi', 'cross_thread_coordinate', 'coordination_terminal']) { const opened = await service.open({ kind: 'non_obligation', source }); - assert.deepEqual(opened, { + assert.deepEqual(opened, { state: 'covered_empty', evidenceRefs: [`wake:${source}`] }); + assert.equal((await service.close(opened)).shouldBlock, false); + } + }); + + test('message lifecycle owns ordinary A2A, hold, and event-wait wake completion', async () => { + const { service } = harness(); + const wakes = [ + { + kind: 'structured', + protocol: 'dispatch', + subjectKey: 'ball:thread:thread-1', + holderCatId: 'codex-sol', + handoff: { sourceEventId: 'handoff-1', messageId: 'message-1', fromCatId: 'opus' }, + }, + { + kind: 'structured', + protocol: 'hold', + subjectKey: 'ball:thread:thread-1', + holderCatId: 'codex-sol', + sourceMessageId: 'message-2', + taskId: 'hold-ball-2', + }, + { + kind: 'structured', + protocol: 'event_wait', + subjectKey: 'ball:thread:thread-1', + holderCatId: 'codex-sol', + waitContinuationCarrier: {}, + }, + ]; + for (const wake of wakes) { + const opened = await service.open(wake); + assert.deepEqual(opened, { state: 'covered_empty', evidenceRefs: [`lifecycle:${wake.protocol}`] }); + assert.deepEqual(await service.close(opened), { state: 'covered_empty', - evidenceRefs: [`wake:${source}`], + shouldBlock: false, + transitionObserved: false, + evidenceRefs: [`lifecycle:${wake.protocol}`], }); - assert.equal((await service.close(opened)).shouldBlock, false); } }); - test('legacy text wake and query failure stay unknown_legacy fail-closed', async () => { + test('structured message wakes never consult legacy Ball holder/event truth', async () => { + const service = new TurnCustodyProjectionService({ + ballCustodyProjectionStore: { get: async () => Promise.reject(new Error('must not read')) }, + ballCustodyEventLog: { read: async () => Promise.reject(new Error('must not read')) }, + }); + const opened = await service.open({ + kind: 'structured', + protocol: 'hold', + subjectKey: 'ball:thread:thread-1', + holderCatId: 'codex-sol', + sourceMessageId: 'message-2', + taskId: 'hold-ball-2', + }); + assert.deepEqual(opened, { state: 'covered_empty', evidenceRefs: ['lifecycle:hold'] }); + }); + + test('legacy text wake and action query failure stay fail-closed', async () => { const { service } = harness(); const legacy = await service.open({ kind: 'legacy', reason: 'text_mention' }); assert.equal(legacy.state, 'unknown_legacy'); assert.equal((await service.close(legacy)).shouldBlock, true); - const failing = new TurnCustodyProjectionService({ actionSuccessorLeaseStore: { get: async () => Promise.reject(new Error('redis unavailable')) }, }); @@ -102,7 +135,7 @@ describe('F167 Phase T TurnCustodyProjectionService', () => { generation: 3, holderCatId: 'codex-sol', }); - assert.equal(unavailable.state, 'unknown_legacy'); + assert.deepEqual(unavailable, { state: 'unknown_legacy', evidenceRefs: ['unknown:query_failed'] }); assert.equal((await failing.close(unavailable)).shouldBlock, true); }); @@ -123,7 +156,7 @@ describe('F167 Phase T TurnCustodyProjectionService', () => { }); }); - test('candidate, holder outcome, transfer, and completion are legitimate action transitions', async () => { + test('candidate, holder outcome, transfer, and completion are action transitions', async () => { for (const next of [ lease({ completionCandidates: { 'codex-sol': { candidateRevision: 1, evidenceDigest: 'digest-1' } } }), lease({ holderOutcomes: { 'codex-sol': { outcome: 'failed', evidenceRef: 'invocation:1', at: 2 } } }), @@ -144,7 +177,7 @@ describe('F167 Phase T TurnCustodyProjectionService', () => { } }); - test('return transport retries alone do not satisfy the custody transition', async () => { + test('return transport retries alone do not satisfy an action transition', async () => { const h = harness(); const opened = await h.service.open({ kind: 'action_successor', @@ -152,312 +185,14 @@ describe('F167 Phase T TurnCustodyProjectionService', () => { generation: 3, holderCatId: 'codex-sol', }); - h.setLease( - lease({ - returnDeliveryAttemptCount: 2, - returnDeliveryLastAttemptAt: 5, - revision: 9, - updatedAt: 5, - }), - ); - assert.equal((await h.service.close(opened)).shouldBlock, true); - }); - - test('structured hold wake ignores unrelated truth but accepts hold/transfer/exact disposition transitions', async () => { - const h = harness(); - const opened = await h.service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - }); - assert.equal(opened.state, 'covered_active'); - h.addEvent({ kind: 'invocation.started', sourceEventId: 'inv:1:started' }); + h.setLease(lease({ returnDeliveryAttemptCount: 2, returnDeliveryLastAttemptAt: 5, revision: 9, updatedAt: 5 })); assert.equal((await h.service.close(opened)).shouldBlock, true); - h.addEvent({ kind: 'task.done', sourceEventId: 'task:other-work:done', payload: { taskId: 'other-work' } }); - assert.equal( - (await h.service.close(opened)).shouldBlock, - true, - 'TaskStore work items are not protocol-ball progress for this thread hold', - ); - h.addEvent({ - kind: 'ball.handed', - sourceEventId: 'route:incoming:codex-sol', - payload: { fromCatId: 'opus', toCatId: 'codex-sol' }, - }); - assert.equal((await h.service.close(opened)).shouldBlock, true, 'receiving the wake is not turn progress'); - - const exact = harness(); - const exactOpened = await exact.service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: 'message-1', - taskId: 'task-1', - }); - exact.addEvent({ - kind: 'ball.hold_dispositioned', - sourceEventId: 'hold-disposition:inv-1:message-1:task-1', - payload: { - catId: 'codex-sol', - invocationId: 'inv-1', - sourceMessageId: 'message-1', - taskId: 'task-1', - disposition: 'completed', - }, - }); - assert.deepEqual(await exact.service.close(exactOpened), { - state: 'covered_active', - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'hold_dispositioned', - evidenceRefs: ['hold:ball:thread:thread-1'], - }); - - for (const kind of ['ball.held', 'ball.handed', 'ball.handed_cvo']) { - const next = harness(); - const nextOpened = await next.service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - }); - next.addEvent({ - kind, - sourceEventId: `${kind}:1`, - payload: - kind === 'ball.held' - ? { catId: 'codex-sol' } - : kind === 'ball.handed' || kind === 'ball.handed_cvo' - ? { fromCatId: 'codex-sol' } - : {}, - }); - const decision = await next.service.close(nextOpened); - assert.equal(decision.shouldBlock, false, kind); - assert.equal(decision.transitionObserved, true, kind); - } - }); - - test('structured wake with a different or missing holder stays unknown_legacy', async () => { - for (const projection of [ - { state: 'active', holder: 'codex-terra' }, - { state: 'blocked', holder: null }, - ]) { - const { service } = harness({ projection }); - const opened = await service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - }); - assert.equal(opened.state, 'unknown_legacy'); - assert.equal((await service.close(opened)).shouldBlock, true); - } - }); - - test('an exact structured wake released by a later handoff is covered_empty for the stale carrier', async () => { - const dispatch = harness({ - projection: { state: 'active', holder: 'codex-terra' }, - events: [ - { - kind: 'ball.handed', - sourceEventId: 'route:message-1:codex-sol', - payload: { fromCatId: 'opus', toCatId: 'codex-sol' }, - }, - { - kind: 'ball.handed', - sourceEventId: 'route:successor-message:codex-terra', - payload: { fromCatId: 'codex-sol', toCatId: 'codex-terra' }, - }, - ], - }); - const dispatchOpened = await dispatch.service.open({ - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - handoff: { - sourceEventId: 'route:message-1:codex-sol', - messageId: 'message-1', - fromCatId: 'opus', - }, - }); - assert.deepEqual(dispatchOpened, { - state: 'covered_empty', - evidenceRefs: [ - 'dispatch:ball:thread:thread-1', - 'route:message-1:codex-sol', - 'released:route:successor-message:codex-terra', - ], - }); - assert.equal((await dispatch.service.close(dispatchOpened)).shouldBlock, false); - - const hold = harness({ - projection: { state: 'active', holder: 'codex-terra' }, - events: [ - { - kind: 'ball.wake_condition_met', - sourceEventId: 'wakecond:task-1', - payload: { catId: 'codex-sol', taskId: 'task-1' }, - }, - { - kind: 'ball.handed', - sourceEventId: 'route:message-1:codex-sol', - payload: { toCatId: 'codex-sol' }, - }, - { - kind: 'ball.handed', - sourceEventId: 'route:successor-message:codex-terra', - payload: { fromCatId: 'codex-sol', toCatId: 'codex-terra' }, - }, - ], - }); - const holdOpened = await hold.service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - sourceMessageId: 'message-1', - taskId: 'task-1', - }); - assert.deepEqual(holdOpened, { - state: 'covered_empty', - evidenceRefs: [ - 'hold:ball:thread:thread-1', - 'route:message-1:codex-sol', - 'released:route:successor-message:codex-terra', - ], - }); - assert.equal((await hold.service.close(holdOpened)).shouldBlock, false); - }); - - test('a superseded adopted hold stays non-blocking but observes the exact holder re-hold after adoption', async () => { - const h = harness({ - projection: { state: 'active', holder: 'codex-sol' }, - events: [ - { - kind: 'ball.wake_condition_met', - sourceEventId: 'wakecond:task-managed-predecessor', - payload: { catId: 'codex-sol', taskId: 'task-managed-predecessor' }, - }, - { - kind: 'ball.handed', - sourceEventId: 'route:ordinary-trigger:codex-sol', - payload: { fromCatId: 'opus', toCatId: 'codex-sol' }, - }, - ], - }); - const opened = await h.service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-rehold', - holderCatId: 'codex-sol', - sourceMessageId: 'message-managed-predecessor', - taskId: 'task-managed-predecessor', - }); - - assert.equal(opened.state, 'covered_empty', 'pre-adoption supersession must not revive a blocking obligation'); - h.addEvent({ - kind: 'ball.held', - sourceEventId: 'hold:thread-managed-rehold:codex-sol:2000', - payload: { catId: 'codex-sol', fireAt: 2_000 }, - }); - - assert.deepEqual(await h.service.close(opened), { - state: 'covered_empty', - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'held', - evidenceRefs: ['hold:ball:thread:thread-managed-rehold', 'superseded:route:ordinary-trigger:codex-sol'], - }); }); - test('a superseded adopted hold stays non-blocking when its post-adoption event read fails', async () => { - const events = [ - { - kind: 'ball.wake_condition_met', - sourceEventId: 'wakecond:task-managed-predecessor', - payload: { catId: 'codex-sol', taskId: 'task-managed-predecessor' }, - }, - { - kind: 'ball.handed', - sourceEventId: 'route:ordinary-trigger:codex-sol', - payload: { fromCatId: 'opus', toCatId: 'codex-sol' }, - }, - ]; - let readCount = 0; - const service = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: { - async get() { - return { state: 'active', holder: 'codex-sol' }; - }, - }, - ballCustodyEventLog: { - async read(_subjectKey, fromSequence = 0) { - readCount += 1; - if (readCount > 1) throw new Error('transient event-log fault'); - return events.slice(fromSequence); - }, - }, - }); - const opened = await service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-rehold', - holderCatId: 'codex-sol', - sourceMessageId: 'message-managed-predecessor', - taskId: 'task-managed-predecessor', - }); - - assert.equal(opened.state, 'covered_empty'); - assert.deepEqual(await service.close(opened), { - state: 'covered_empty', - shouldBlock: false, - transitionObserved: false, - evidenceRefs: [ - 'hold:ball:thread:thread-managed-rehold', - 'superseded:route:ordinary-trigger:codex-sol', - 'unknown:query_failed', - ], - }); - - const liveService = new TurnCustodyProjectionService({ - ballCustodyEventLog: { - async read() { - throw new Error('transient event-log fault'); - }, - }, - }); - assert.deepEqual( - await liveService.close({ - state: 'covered_active', - evidenceRefs: ['hold:ball:thread:thread-live'], - baseline: { - kind: 'structured', - subjectKey: 'ball:thread:thread-live', - holderCatId: 'codex-sol', - fromSequence: 1, - protocol: 'hold', - sourceMessageId: 'message-live', - taskId: 'task-live', - }, - }), - { - state: 'unknown_legacy', - shouldBlock: true, - transitionObserved: false, - evidenceRefs: ['hold:ball:thread:thread-live', 'unknown:query_failed'], - }, - ); - }); - - test('unknown projections preserve bounded machine-readable failure reasons', async () => { - const actionMissing = new TurnCustodyProjectionService({ - actionSuccessorLeaseStore: { get: async () => null }, - }); + test('unknown action projections preserve bounded machine-readable reasons', async () => { + const service = new TurnCustodyProjectionService({ actionSuccessorLeaseStore: { get: async () => null } }); assert.deepEqual( - await actionMissing.open({ + await service.open({ kind: 'action_successor', leaseId: 'lease-missing', generation: 1, @@ -465,79 +200,6 @@ describe('F167 Phase T TurnCustodyProjectionService', () => { }), { state: 'unknown_legacy', evidenceRefs: ['unknown:action_lease_missing'] }, ); - - const structuredMismatch = harness({ - projection: { state: 'active', holder: 'codex-terra' }, - }); - assert.deepEqual( - await structuredMismatch.service.open({ - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - }), - { state: 'unknown_legacy', evidenceRefs: ['unknown:structured_holder_mismatch'] }, - ); - - const dispatchMissing = harness({ - projection: { state: 'active', holder: 'codex-sol' }, - events: [], - }); - assert.deepEqual( - await dispatchMissing.service.open({ - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - handoff: { - sourceEventId: 'route:message-missing:codex-sol', - messageId: 'message-missing', - fromCatId: 'opus', - }, - }), - { state: 'unknown_legacy', evidenceRefs: ['unknown:dispatch_handoff_missing'] }, - ); - }); - - test('dispatch requires the exact current handoff even when an old projection has the same holder', async () => { - const wake = { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-1', - holderCatId: 'codex-sol', - handoff: { - sourceEventId: 'route:current-message:codex-sol', - messageId: 'current-message', - fromCatId: 'opus', - }, - }; - const stale = harness({ - projection: { state: 'active', holder: 'codex-sol' }, - events: [ - { - kind: 'ball.handed', - sourceEventId: 'route:old-message:codex-sol', - payload: { fromCatId: 'opus', toCatId: 'codex-sol' }, - }, - ], - }); - const staleOpened = await stale.service.open(wake); - assert.equal(staleOpened.state, 'unknown_legacy'); - assert.equal((await stale.service.close(staleOpened)).shouldBlock, true); - - const exact = harness({ - projection: { state: 'active', holder: 'codex-sol' }, - events: [ - { - kind: 'ball.handed', - sourceEventId: 'route:current-message:codex-sol', - payload: { fromCatId: 'opus', toCatId: 'codex-sol' }, - }, - ], - }); - const exactOpened = await exact.service.open(wake); - assert.equal(exactOpened.state, 'covered_active'); - assert.ok(exactOpened.evidenceRefs.includes('route:current-message:codex-sol')); }); test('shadow comparison exposes old/new agreement and both disagreement directions', () => { diff --git a/packages/api/test/turn-custody-shadow-route-metric-eval.test.js b/packages/api/test/turn-custody-shadow-route-metric-eval.test.js index 03ef02bb6a..555560b2bf 100644 --- a/packages/api/test/turn-custody-shadow-route-metric-eval.test.js +++ b/packages/api/test/turn-custody-shadow-route-metric-eval.test.js @@ -33,24 +33,6 @@ after(async () => { }); const liveCohorts = [ - { - count: 2, - state: 'covered_active', - reason: 'not_applicable', - sourceCategory: 'a2a', - sourceSemantic: 'cross_thread_investigate', - wakeProvenance: 'structured:dispatch', - checkpoint: 'route_settled', - }, - { - count: 6, - state: 'covered_active', - reason: 'not_applicable', - sourceCategory: 'a2a', - sourceSemantic: 'not_recorded', - wakeProvenance: 'structured:dispatch', - checkpoint: 'route_settled', - }, { count: 4, state: 'covered_active', @@ -96,24 +78,6 @@ const liveCohorts = [ wakeProvenance: 'legacy:carrier_missing', checkpoint: 'route_settled', }, - { - count: 3, - state: 'unknown_legacy', - reason: 'structured_holder_mismatch', - sourceCategory: 'a2a', - sourceSemantic: 'not_recorded', - wakeProvenance: 'structured:dispatch', - checkpoint: 'next_turn_boundary', - }, - { - count: 2, - state: 'unknown_legacy', - reason: 'structured_holder_mismatch', - sourceCategory: 'a2a', - sourceSemantic: 'not_recorded', - wakeProvenance: 'structured:dispatch', - checkpoint: 'route_settled', - }, ]; const holdouts = [ @@ -202,10 +166,10 @@ describe('F167 Phase T route-to-eval metric binding', () => { assert.equal( liveCohorts.reduce((total, cohort) => total + cohort.count, 0), - 36, + 23, ); - assert.equal(component.activationCounts['turn_custody.new_only_block_total'], 39); - assert.equal(component.activationCounts['turn_custody.new_only_justified_total'], 36); + assert.equal(component.activationCounts['turn_custody.new_only_block_total'], 26); + assert.equal(component.activationCounts['turn_custody.new_only_justified_total'], 23); assert.equal(component.frictionCounts['turn_custody.new_only_unjustified_total'], 2); assert.equal(component.frictionCounts['turn_custody.new_only_unexplained_total'], 1); assert.equal(component.frictionCounts['turn_custody.new_only_classification_gap_total'], 0); diff --git a/packages/api/test/turn-custody-shadow-route-telemetry.test.js b/packages/api/test/turn-custody-shadow-route-telemetry.test.js index d26ed2f3fa..9efd001a45 100644 --- a/packages/api/test/turn-custody-shadow-route-telemetry.test.js +++ b/packages/api/test/turn-custody-shadow-route-telemetry.test.js @@ -48,10 +48,40 @@ function assertRedactedIds(attributes, raw) { } describe('F167 Phase T route shadow telemetry', () => { + test('ordinary A2A dispatch bypasses the legacy turn-custody projection entirely', async () => { + const result = await runTurnCustodyRoute({ + output: ['ordinary lifecycle-owned reply'], + triggerMessage: turnCustodyTriggerMessage('message-lifecycle-owned', 'thread-lifecycle-owned'), + wake: { + kind: 'structured', + protocol: 'dispatch', + subjectKey: 'ball:thread:thread-lifecycle-owned', + holderCatId: 'codex', + handoff: { + sourceEventId: 'route:message-lifecycle-owned:codex', + messageId: 'message-lifecycle-owned', + fromCatId: 'opus', + }, + }, + projection: { + state: 'covered_empty', + shouldBlock: false, + transitionObserved: false, + evidenceRefs: ['lifecycle:dispatch'], + }, + }); + + assert.equal(result.projectionOpenCount, 0); + assert.equal( + exporter.getFinishedSpans().filter((span) => span.name === 'cat_cafe.a2a.turn_custody_shadow_sample').length, + 0, + ); + }); + test('emits a redacted bounded sample for unknown_legacy + agree_block', async () => { const threadId = 'raw-thread-agree-block'; const messageId = 'raw-message-agree-block'; - await runTurnCustodyRoute({ + const result = await runTurnCustodyRoute({ output: ['ordinary answer without an outlet', '@co-creator'], triggerMessage: turnCustodyTriggerMessage(messageId, threadId, 'investigate'), wake: { kind: 'legacy', reason: 'carrier_missing', sourceCategory: 'review' }, @@ -88,6 +118,11 @@ describe('F167 Phase T route shadow telemetry', () => { sourceSemantic: 'cross_thread_investigate', }, ); + assert.equal( + result.appendedMessages.some((message) => message.source?.connector === 'routing-guard-failure'), + false, + 'a failed internal remediation must stay in telemetry instead of creating a hidden History message', + ); }); test('keeps disagreement emission with the added bounded fields', async () => { diff --git a/packages/api/test/turn-custody-stop-gate-route.test.js b/packages/api/test/turn-custody-stop-gate-route.test.js deleted file mode 100644 index 0729b4c500..0000000000 --- a/packages/api/test/turn-custody-stop-gate-route.test.js +++ /dev/null @@ -1,1528 +0,0 @@ -/** - * F167 Phase T cutover — routeSerial consumes turn-scoped custody truth. - * - * These fixtures intentionally do not grade reply text. The authoritative - * inputs are wake provenance, projection transitions, a verified typed PR wait, - * and durable child-execution records. - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { catRegistry } from '@cat-cafe/shared'; -import { createTypedWaitRegistration } from '../dist/domains/ball-custody/TypedWaitRegistration.js'; -import { TaskStore } from '../dist/domains/cats/services/stores/ports/TaskStore.js'; - -const { TurnCustodyAdoptionRegistry, turnCustodyAdoptionRegistry } = await import( - '../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js' -); - -let catRegistryLock = Promise.resolve(); - -async function withCatRegistryLock(fn) { - const previous = catRegistryLock; - let release; - catRegistryLock = new Promise((resolve) => { - release = resolve; - }); - await previous; - try { - return await fn(); - } finally { - release(); - } -} - -function createSequenceService(catId, turns) { - const calls = []; - return { - calls, - async *invoke(prompt) { - calls.push(prompt); - const turn = turns[Math.min(calls.length - 1, turns.length - 1)] ?? ''; - yield { - type: 'system_info', - catId, - content: JSON.stringify({ type: 'invocation_created', invocationId: `${catId}-inv-${calls.length}` }), - timestamp: Date.now(), - }; - const events = Array.isArray(turn) ? turn : [{ type: 'text', content: turn }]; - for (const event of events) { - yield { catId, timestamp: Date.now(), ...event }; - } - yield { type: 'done', catId, timestamp: Date.now() }; - }, - }; -} - -function createProjectionService({ state, closeDecisions }) { - const opens = []; - const closes = []; - return { - opens, - closes, - async open(wake) { - opens.push(wake); - return { - state, - evidenceRefs: [`wake:${wake.kind}`], - ...(state === 'covered_active' ? { baseline: { kind: 'test' } } : {}), - }; - }, - async close(projection) { - const next = closeDecisions[Math.min(closes.length, closeDecisions.length - 1)]; - const decision = { - state, - ...next, - evidenceRefs: [...projection.evidenceRefs], - }; - closes.push(decision); - return decision; - }, - }; -} - -function createEventWaitTaskStore( - threadId, - sourceMessageId = 'message-owner-task', - holdTaskId, - invocationId = 'outer-inv-1', -) { - const store = new TaskStore(); - const task = store.create({ - kind: 'pr_tracking', - subjectKey: 'pr:zts212653/cat-cafe#3282', - threadId, - title: 'Registered review', - ownerCatId: 'codex', - userId: 'user1', - createdBy: 'codex', - }); - const active = { - v: 1, - generation: 1, - subjectRef: task.subjectKey, - ownerFence: { kind: 'containing_task', generation: 1 }, - baseline: { capturedAt: Date.now(), headSha: 'head-1', review: { resultTriggerCommentId: 4936000000 } }, - continuation: { - when: [{ kind: 'pr_review_result_available', triggerCommentId: 4936000000 }], - // biome-ignore lint/suspicious/noThenProperty: F280 frozen continuation field. - then: 'Read the exact review.', - }, - expiresAt: Date.now() + 60000, - createdAt: Date.now(), - provenance: 'explicit_registration', - }; - const receipt = createTypedWaitRegistration({ - task, - active, - invocationId, - source: { kind: 'primary', sourceMessageId, ...(holdTaskId ? { holdTaskId } : {}) }, - }); - store.replaceAutomationStateIfGeneration(task.id, { - expectedGeneration: null, - automationState: { await: active }, - waitRegistration: receipt, - }); - return store; -} - -function createMockDeps( - service, - appended, - { - triggerMessage, - taskStore, - turnCustodyProjectionService, - turnExecutionStore, - metadataAugments, - sessionChainStore, - sessionSealer, - transcriptReader, - sessionManager, - } = {}, -) { - let sequence = 0; - return { - services: { codex: service }, - invocationDeps: { - registry: { - create: () => ({ invocationId: `outer-inv-${++sequence}`, callbackToken: `tok-${sequence}` }), - verify: async () => ({ ok: false, reason: 'unknown_invocation' }), - }, - sessionManager: { - getOrCreate: async () => ({}), - get: async () => null, - delete: async () => {}, - resolveWorkingDirectory: () => '/tmp/test', - ...sessionManager, - }, - threadStore: null, - apiUrl: 'http://127.0.0.1:3004', - ...(taskStore ? { taskStore } : {}), - ...(turnExecutionStore ? { turnExecutionStore } : {}), - ...(sessionChainStore ? { sessionChainStore } : {}), - ...(sessionSealer ? { sessionSealer } : {}), - ...(transcriptReader ? { transcriptReader } : {}), - }, - messageStore: { - append: async (message) => { - const stored = { - id: `stored-${++sequence}`, - userId: message.userId ?? '', - catId: message.catId ?? null, - content: message.content ?? '', - mentions: message.mentions ?? [], - timestamp: message.timestamp ?? 0, - source: message.source, - origin: message.origin, - mentionsUser: message.mentionsUser, - toolEvents: message.toolEvents, - extra: message.extra, - }; - appended.push(stored); - return stored; - }, - getById: async (messageId) => (messageId === triggerMessage?.id ? triggerMessage : null), - getRecent: async () => [], - getMentionsFor: async () => [], - getBefore: async () => [], - getByThread: async () => [], - getByThreadAfter: async () => [], - getByThreadBefore: async () => [], - augmentStreamMetadata: async (messageId, patch) => { - metadataAugments.push({ messageId, patch }); - return true; - }, - }, - draftStore: { - upsert: () => {}, - touch: () => {}, - delete: async () => {}, - deleteByThread: () => {}, - getByThread: () => [], - }, - socketManager: { broadcastToRoom: () => {} }, - ...(taskStore ? { taskStore } : {}), - turnCustodyProjectionService, - }; -} - -async function runRoute( - service, - threadId, - { - projectionService, - routeOptions = {}, - taskStore, - triggerMessage, - turnExecutionStore, - beforeRoute, - sessionChainStore, - sessionSealer, - transcriptReader, - sessionManager, - throwAfterInvocationCreated = false, - } = {}, -) { - return withCatRegistryLock(async () => { - const original = catRegistry.getAllConfigs(); - const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); - const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - catRegistry.reset(); - const canonicalTemplatePath = fileURLToPath(new URL('../../../cat-template.json', import.meta.url)); - for (const [id, config] of Object.entries(toAllCatConfigs(loadCatConfig(canonicalTemplatePath)))) { - catRegistry.register(id, config); - } - beforeRoute?.(); - const appended = []; - const metadataAugments = []; - try { - const deps = createMockDeps(service, appended, { - turnCustodyProjectionService: projectionService, - taskStore, - triggerMessage, - turnExecutionStore, - metadataAugments, - sessionChainStore, - sessionSealer, - transcriptReader, - sessionManager, - }); - const yielded = []; - for await (const message of routeSerial(deps, ['codex'], 'custody gate test', 'user1', threadId, { - invocationController: new AbortController(), - trackA2ASlot: () => true, - completeA2ASlots: () => {}, - ...routeOptions, - })) { - yielded.push(message); - if ( - throwAfterInvocationCreated && - message.type === 'system_info' && - message.extra?.turnExecution?.invocationId - ) { - throw new Error('simulated route failure after invocation creation'); - } - } - return { appended, yielded, metadataAugments }; - } finally { - catRegistry.reset(); - for (const [id, config] of Object.entries(original)) { - catRegistry.register(id, config); - } - } - }); -} - -describe('F167 Phase T route custody stop gate', () => { - test('covered_empty naturally stops without manufacturing a routing outlet', async () => { - const projection = createProjectionService({ - state: 'covered_empty', - closeDecisions: [{ shouldBlock: false, transitionObserved: false }], - }); - const service = createSequenceService('codex', ['Ordinary answer with no protocol ball.']); - - const { appended } = await runRoute(service, 'thread-covered-empty', { - projectionService: projection, - routeOptions: { turnCustodyWake: { kind: 'unstructured', source: 'user_chat' } }, - }); - - assert.equal(service.calls.length, 1); - assert.equal(projection.closes.length, 1); - assert.equal( - appended.some((message) => message.source?.connector === 'routing-guard-failure'), - false, - ); - }); - - test('covered_active without a transition runs one structured remedial child and stops retrying', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { shouldBlock: true, transitionObserved: false }, - { shouldBlock: true, transitionObserved: false }, - ], - }); - const service = createSequenceService('codex', ['Text-only completion.', '@co-creator']); - - const { appended, yielded } = await runRoute(service, 'thread-covered-active-blocked', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'action_successor', - leaseId: 'lease-1', - generation: 1, - holderCatId: 'codex', - }, - }, - }); - - assert.equal(service.calls.length, 2); - assert.match(service.calls[1], /结构化/); - assert.match(service.calls[1], /不要用纯文本/); - assert.equal(projection.closes.length, 2); - assert.equal(appended.filter((message) => message.source?.connector === 'routing-guard-failure').length, 1); - assert.equal( - yielded.some((message) => message.type === 'a2a_handoff' || message.mentionsUser), - false, - 'remedial prose and line-start mentions are not structured custody transitions', - ); - }); - - test('managed hold mounts its disposition producer on the exact turn and fails without a second invocation', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [{ shouldBlock: true, transitionObserved: false }], - }); - const service = createSequenceService('codex', ['Text-only completion.']); - - const { appended, yielded } = await runRoute(service, 'thread-managed-hold-remedial', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-hold-remedial', - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold', - taskId: 'task-managed-hold', - }, - }, - }); - - assert.equal(service.calls.length, 1); - assert.match(service.calls[0], /cat_cafe_complete_managed_hold/); - assert.doesNotMatch(service.calls[0], /完成候选/); - assert.doesNotMatch(service.calls[0], /returnToPredecessor/); - assert.match(service.calls[0], /command exit/); - assert.equal(projection.closes.length, 1); - assert.equal(appended.filter((message) => message.source?.connector === 'routing-guard-failure').length, 1); - assert.equal(yielded.find((message) => message.type === 'done')?.errorCode, 'managed_hold_disposition_missing'); - }); - - test('ordinary A2A dispatch mounts its exact producer and never spawns a stale remedial child', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [{ shouldBlock: true, transitionObserved: false }], - }); - const service = createSequenceService('codex', ['Completed the requested review.']); - - const { appended, yielded } = await runRoute(service, 'thread-a2a-dispatch-disposition', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-a2a-dispatch-disposition', - holderCatId: 'codex', - handoff: { - sourceEventId: 'route:message-a2a:codex', - messageId: 'message-a2a', - fromCatId: 'fable5', - }, - }, - }, - }); - - assert.equal(service.calls.length, 1); - assert.match(service.calls[0], /cat_cafe_complete_a2a_dispatch/); - assert.doesNotMatch(service.calls[0], /完成候选/); - assert.doesNotMatch(service.calls[0], /returnToPredecessor/); - assert.match(service.calls[0], /merge truth/); - assert.equal(projection.closes.length, 1); - assert.equal(appended.filter((message) => message.source?.connector === 'routing-guard-failure').length, 1); - assert.equal(yielded.find((message) => message.type === 'done')?.errorCode, 'a2a_dispatch_disposition_missing'); - }); - - test('handled dispatch plus immediate provider termination emits one typed continuation witness', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'dispatch_dispositioned', - dispatchDisposition: 'handled', - dispatchDispositionEventId: 'dispatch-disposition:codex-inv-1:message-a2a', - dispatchDispositionAt: 2_000, - }, - ], - }); - const service = createSequenceService('codex', [ - [ - { - type: 'tool_use', - toolName: 'mcp:cat-cafe/complete_a2a_dispatch', - toolUseId: 'dispose-1', - toolInput: { disposition: 'handled' }, - }, - { - type: 'tool_result', - toolName: 'mcp:cat-cafe/complete_a2a_dispatch', - toolUseId: 'dispose-1', - toolResultStatus: 'ok', - content: '{"outcome":"applied","disposition":"handled"}', - }, - ], - ]); - - const { yielded } = await runRoute(service, 'thread-a2a-handled-provider-terminal', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-a2a-handled-provider-terminal', - holderCatId: 'codex', - handoff: { - sourceEventId: 'route:message-a2a:codex', - messageId: 'message-a2a', - fromCatId: 'fable5', - }, - }, - }, - }); - - assert.deepEqual(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitness, { - kind: 'dispatch_handled_continuation', - sourceMessageId: 'message-a2a', - dispositionEventId: 'dispatch-disposition:codex-inv-1:message-a2a', - dispositionAt: 2_000, - }); - }); - - test('completed dispatch is terminal and never emits a continuation witness', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'dispatch_dispositioned', - dispatchDisposition: 'completed', - dispatchDispositionEventId: 'dispatch-disposition:codex-inv-1:message-a2a', - dispatchDispositionAt: 2_000, - }, - ], - }); - const service = createSequenceService('codex', [ - [ - { - type: 'tool_use', - toolName: 'mcp:cat-cafe/complete_a2a_dispatch', - toolUseId: 'dispose-1', - toolInput: { disposition: 'completed' }, - }, - { - type: 'tool_result', - toolName: 'mcp:cat-cafe/complete_a2a_dispatch', - toolUseId: 'dispose-1', - toolResultStatus: 'ok', - content: '{"outcome":"applied","disposition":"completed"}', - }, - ], - ]); - - const { yielded } = await runRoute(service, 'thread-a2a-completed-terminal', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-a2a-completed-terminal', - holderCatId: 'codex', - handoff: { - sourceEventId: 'route:message-a2a:codex', - messageId: 'message-a2a', - fromCatId: 'fable5', - }, - }, - }, - }); - - assert.equal(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitness, undefined); - }); - - test('handled dispatch with same-invocation post-tool progress does not enqueue an extra continuation', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'dispatch_dispositioned', - dispatchDisposition: 'handled', - dispatchDispositionEventId: 'dispatch-disposition:codex-inv-1:message-a2a', - dispatchDispositionAt: 2_000, - }, - ], - }); - const service = createSequenceService('codex', [ - [ - { - type: 'tool_use', - toolName: 'mcp:cat-cafe/complete_a2a_dispatch', - toolUseId: 'dispose-1', - toolInput: { disposition: 'handled' }, - }, - { - type: 'tool_result', - toolName: 'mcp:cat-cafe/complete_a2a_dispatch', - toolUseId: 'dispose-1', - toolResultStatus: 'ok', - content: '{"outcome":"applied","disposition":"handled"}', - }, - { - type: 'tool_use', - toolName: 'command_execution', - toolUseId: 'merge-1', - toolInput: { command: 'gh pr merge' }, - }, - { - type: 'tool_result', - toolName: 'command_execution', - toolUseId: 'merge-1', - toolResultStatus: 'ok', - content: 'merged', - }, - ], - ]); - - const { yielded } = await runRoute(service, 'thread-a2a-handled-same-turn-progress', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-a2a-handled-same-turn-progress', - holderCatId: 'codex', - handoff: { - sourceEventId: 'route:message-a2a:codex', - messageId: 'message-a2a', - fromCatId: 'fable5', - }, - }, - }, - }); - - assert.equal(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitness, undefined); - }); - - test('a released structured carrier does not mount a stale disposition producer or hard-fail', async () => { - const projection = createProjectionService({ - state: 'covered_empty', - closeDecisions: [{ shouldBlock: false, transitionObserved: false }], - }); - const service = createSequenceService('codex', ['The successor now owns this protocol ball.']); - - const { appended, yielded } = await runRoute(service, 'thread-a2a-dispatch-released', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-a2a-dispatch-released', - holderCatId: 'codex', - handoff: { - sourceEventId: 'route:message-a2a:codex', - messageId: 'message-a2a', - fromCatId: 'fable5', - }, - }, - }, - }); - - assert.doesNotMatch(service.calls[0], /cat_cafe_complete_a2a_dispatch/); - assert.equal( - appended.some((message) => message.source?.connector === 'routing-guard-failure'), - false, - ); - assert.equal(yielded.find((message) => message.type === 'done')?.errorCode, undefined); - }); - - test('an unproven structured holder mismatch fails once without spawning a retry child', async () => { - const projection = createProjectionService({ - state: 'unknown_legacy', - closeDecisions: [{ shouldBlock: true, transitionObserved: false }], - }); - const service = createSequenceService('codex', ['The third-party holder change is not proven as my release.']); - - const { appended, yielded } = await runRoute(service, 'thread-a2a-dispatch-unproven-holder', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: 'ball:thread:thread-a2a-dispatch-unproven-holder', - holderCatId: 'codex', - handoff: { - sourceEventId: 'route:message-a2a:codex', - messageId: 'message-a2a', - fromCatId: 'fable5', - }, - }, - }, - }); - - assert.equal(service.calls.length, 1); - assert.match(service.calls[0], /cat_cafe_complete_a2a_dispatch/); - assert.equal(appended.filter((message) => message.source?.connector === 'routing-guard-failure').length, 1); - assert.equal(yielded.find((message) => message.type === 'done')?.errorCode, 'a2a_dispatch_disposition_missing'); - }); - - test('managed re-hold emits exact continuation receipt proof without terminal completion', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [{ shouldBlock: false, transitionObserved: true, structuredTransitionKind: 'held' }], - }); - const service = createSequenceService('codex', ['Established a new structured hold.']); - - const { yielded } = await runRoute(service, 'thread-managed-hold-continued', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-hold-continued', - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold', - taskId: 'task-managed-hold', - }, - }, - }); - - assert.deepEqual(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitness, { - kind: 'managed_hold_continued', - sourceMessageId: 'message-managed-hold', - taskId: 'task-managed-hold', - transition: 'reheld', - }); - }); - - test('a superseded adopted managed hold emits the exact re-hold witness from the real projection', async () => { - const { TurnCustodyProjectionService } = await import( - '../dist/domains/ball-custody/TurnCustodyProjectionService.js' - ); - const events = [ - { - kind: 'ball.wake_condition_met', - sourceEventId: 'wakecond:task-managed-adopted-rehold', - payload: { catId: 'codex', taskId: 'task-managed-adopted-rehold' }, - }, - { - kind: 'ball.handed', - sourceEventId: 'route:ordinary-trigger:codex', - payload: { fromCatId: 'opus', toCatId: 'codex' }, - }, - ]; - const projection = new TurnCustodyProjectionService({ - ballCustodyProjectionStore: { get: async () => ({ state: 'active', holder: 'codex' }) }, - ballCustodyEventLog: { read: async (_subjectKey, fromSequence = 0) => events.slice(fromSequence) }, - }); - const wake = { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-adopted-rehold', - holderCatId: 'codex', - sourceMessageId: 'message-managed-adopted-rehold', - taskId: 'task-managed-adopted-rehold', - }; - const service = { - calls: [], - async *invoke(prompt) { - this.calls.push(prompt); - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'invocation_created', invocationId: 'codex-inv-adopted-rehold' }), - timestamp: Date.now(), - }; - events.push({ - kind: 'ball.held', - sourceEventId: 'hold:thread-managed-adopted-rehold:codex:2000', - payload: { catId: 'codex', fireAt: 2_000 }, - }); - yield { type: 'text', catId: 'codex', content: 'Established the successor hold.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }; - - const { yielded } = await runRoute(service, 'thread-managed-adopted-rehold', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { kind: 'unstructured', source: 'user_chat' }, - persistedPromptMessageIds: [wake.sourceMessageId], - onPromptMessagesExposed: async () => [wake], - }, - }); - - assert.deepEqual(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitnesses, [ - { - kind: 'managed_hold_continued', - sourceMessageId: wake.sourceMessageId, - taskId: wake.taskId, - transition: 'reheld', - }, - ]); - }); - - test('a managed hold body adopted by an already-running turn emits its own continuation proof', async () => { - const opens = []; - const projection = { - opens, - async open(wake) { - opens.push(wake); - return wake.kind === 'structured' && wake.protocol === 'hold' - ? { state: 'covered_active', evidenceRefs: [`hold:${wake.sourceMessageId}`], baseline: { kind: 'test' } } - : { state: 'covered_empty', evidenceRefs: [`wake:${wake.kind}`] }; - }, - async close(opened) { - return opened.state === 'covered_active' - ? { - state: 'covered_active', - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'held', - evidenceRefs: [...opened.evidenceRefs], - } - : { - state: 'covered_empty', - shouldBlock: false, - transitionObserved: false, - evidenceRefs: [...opened.evidenceRefs], - }; - }, - }; - const service = createSequenceService('codex', ['Consumed the queued callback and established a new hold.']); - - const { yielded } = await runRoute(service, 'thread-managed-hold-adopted', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { kind: 'unstructured', source: 'user_chat' }, - persistedPromptMessageIds: ['message-managed-hold-adopted'], - onPromptMessagesExposed: async () => [ - { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-hold-adopted', - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold-adopted', - taskId: 'task-managed-hold-adopted', - }, - ], - }, - }); - - assert.equal(opens.length, 2, 'the primary turn and adopted Queue obligation must both open projections'); - assert.deepEqual(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitnesses, [ - { - kind: 'managed_hold_continued', - sourceMessageId: 'message-managed-hold-adopted', - taskId: 'task-managed-hold-adopted', - transition: 'reheld', - }, - ]); - }); - - test('an adopted hold consumes its own newly registered CI wait at route close', async () => { - const threadId = 'thread-adopted-typed-wait'; - const taskStore = new TaskStore(); - const wake = { - kind: 'structured', - protocol: 'hold', - subjectKey: `ball:thread:${threadId}`, - holderCatId: 'codex', - sourceMessageId: 'adopted-ci-source', - taskId: 'adopted-ci-command', - }; - const projection = { - async open(source) { - return { - state: source.kind === 'structured' ? 'covered_active' : 'covered_empty', - evidenceRefs: [], - baseline: { kind: 'test' }, - }; - }, - async close(opened) { - return { ...opened, shouldBlock: opened.state === 'covered_active', transitionObserved: false }; - }, - }; - const service = { - calls: [], - async *invoke(prompt) { - this.calls.push(prompt); - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'invocation_created', invocationId: 'provider-child' }), - timestamp: Date.now(), - }; - assert.equal(await turnCustodyAdoptionRegistry.adopt('outer-inv-1', [wake]), true); - const task = taskStore.create({ - kind: 'pr_tracking', - subjectKey: 'pr:owner/repo#4513', - threadId, - title: 'CI', - userId: 'user1', - ownerCatId: 'codex', - createdBy: 'codex', - }); - const active = { - v: 1, - generation: 1, - subjectRef: task.subjectKey, - ownerFence: { kind: 'containing_task', generation: 1 }, - baseline: { capturedAt: Date.now(), headSha: 'head-1' }, - continuation: { - when: [{ kind: 'pr_ci_terminal' }], - // biome-ignore lint/suspicious/noThenProperty: F280 frozen continuation field. - then: 'Read CI.', - }, - createdAt: Date.now(), - expiresAt: Date.now() + 60000, - }; - const receipt = createTypedWaitRegistration({ - task, - active, - invocationId: 'outer-inv-1', - source: { kind: 'adopted_hold', sourceMessageId: wake.sourceMessageId, holdTaskId: wake.taskId }, - }); - taskStore.replaceAutomationStateIfGeneration(task.id, { - expectedGeneration: null, - automationState: { await: active }, - waitRegistration: receipt, - }); - yield { type: 'text', catId: 'codex', content: 'Registered CI wait.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }; - const { yielded } = await runRoute(service, threadId, { - taskStore, - projectionService: projection, - routeOptions: { turnCustodyWake: { kind: 'unstructured', source: 'user_chat' } }, - }); - const witnesses = yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitnesses; - assert.equal(service.calls.length, 1); - assert.equal(witnesses.length, 1); - assert.equal(witnesses[0].sourceMessageId, wake.sourceMessageId); - assert.equal(witnesses[0].taskId, wake.taskId); - assert.equal(witnesses[0].transition, 'event_wait'); - assert.deepEqual(witnesses[0].waitRegistration, { taskId: taskStore.listByThread(threadId)[0].id, generation: 1 }); - }); - - test('a full-context tool read can adopt a managed hold after provider execution started', async () => { - turnCustodyAdoptionRegistry.resetForTest(); - const opens = []; - const projection = { - async open(wake) { - opens.push(wake); - return wake.kind === 'structured' - ? { state: 'covered_active', evidenceRefs: [`hold:${wake.sourceMessageId}`], baseline: { kind: 'test' } } - : { state: 'covered_empty', evidenceRefs: [`wake:${wake.kind}`] }; - }, - async close(opened) { - return opened.state === 'covered_active' - ? { - state: 'covered_active', - shouldBlock: false, - transitionObserved: true, - structuredTransitionKind: 'held', - evidenceRefs: [...opened.evidenceRefs], - } - : { - state: 'covered_empty', - shouldBlock: false, - transitionObserved: false, - evidenceRefs: [...opened.evidenceRefs], - }; - }, - }; - const wake = { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-hold-tool-adopted', - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold-tool-adopted', - taskId: 'task-managed-hold-tool-adopted', - }; - const service = { - calls: [], - async *invoke(prompt) { - this.calls.push(prompt); - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'invocation_created', invocationId: 'codex-inv-tool-adoption' }), - timestamp: Date.now(), - }; - assert.equal(await turnCustodyAdoptionRegistry.adopt('outer-inv-1', [wake]), true); - yield { type: 'text', catId: 'codex', content: 'Read the new callback and re-held.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }; - - try { - const { yielded } = await runRoute(service, 'thread-managed-hold-tool-adopted', { - projectionService: projection, - routeOptions: { turnCustodyWake: { kind: 'unstructured', source: 'user_chat' } }, - }); - assert.equal(opens.length, 2); - assert.deepEqual(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitnesses, [ - { - kind: 'managed_hold_continued', - sourceMessageId: wake.sourceMessageId, - taskId: wake.taskId, - transition: 'reheld', - }, - ]); - } finally { - turnCustodyAdoptionRegistry.resetForTest(); - } - }); - - test('adoption owner release drains accepted work and rejects later work', async () => { - const registry = new TurnCustodyAdoptionRegistry(); - let startAdoption; - const adoptionStarted = new Promise((resolve) => { - startAdoption = resolve; - }); - let finishAdoption; - const adoptionFinished = new Promise((resolve) => { - finishAdoption = resolve; - }); - const unregister = registry.register('invocation-draining-adoption', async () => { - startAdoption(); - await adoptionFinished; - }); - const wake = { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-draining-adoption', - holderCatId: 'codex', - sourceMessageId: 'message-draining-adoption', - taskId: 'task-draining-adoption', - }; - - const accepted = registry.adopt('invocation-draining-adoption', [wake]); - await adoptionStarted; - let released = false; - const release = unregister().then(() => { - released = true; - }); - - assert.equal(await registry.adopt('invocation-draining-adoption', [wake]), false); - assert.equal(released, false, 'release must wait until already accepted adoption finishes'); - finishAdoption(); - assert.equal(await accepted, true); - await release; - assert.equal(released, true); - }); - - test('an abnormal route exit unregisters its adoption owner before a later full-context read', async () => { - turnCustodyAdoptionRegistry.resetForTest(); - const projection = { - opens: [], - async open(wake) { - this.opens.push(wake); - return { state: 'covered_empty', evidenceRefs: [`wake:${wake.kind}`] }; - }, - async close(opened) { - return { - state: 'covered_empty', - shouldBlock: false, - transitionObserved: false, - evidenceRefs: [...opened.evidenceRefs], - }; - }, - }; - const wake = { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-hold-abnormal-exit', - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold-after-abnormal-exit', - taskId: 'task-managed-hold-after-abnormal-exit', - }; - const service = createSequenceService('codex', ['Provider output must never be reached.']); - - try { - await assert.rejects( - runRoute(service, 'thread-managed-hold-abnormal-exit', { - projectionService: projection, - throwAfterInvocationCreated: true, - routeOptions: { turnCustodyWake: { kind: 'unstructured', source: 'user_chat' } }, - }), - /simulated route failure after invocation creation/, - ); - - assert.equal( - await turnCustodyAdoptionRegistry.adopt('outer-inv-1', [wake]), - false, - 'a later full-context callback must observe no live route owner and return 409', - ); - assert.equal(projection.opens.length, 1, 'the exited route must not accept a new projection'); - } finally { - turnCustodyAdoptionRegistry.resetForTest(); - } - }); - - test('an adopted managed hold without a successor fails the same child closed', async () => { - const projection = { - async open(wake) { - return { - state: wake.kind === 'structured' ? 'covered_active' : 'covered_empty', - evidenceRefs: [`wake:${wake.kind}`], - }; - }, - async close(opened) { - return opened.state === 'covered_active' - ? { - state: 'covered_active', - shouldBlock: true, - transitionObserved: false, - evidenceRefs: [...opened.evidenceRefs], - } - : { - state: 'covered_empty', - shouldBlock: false, - transitionObserved: false, - evidenceRefs: [...opened.evidenceRefs], - }; - }, - }; - const service = createSequenceService('codex', ['Read the callback but did not establish a successor.']); - const { appended, yielded } = await runRoute(service, 'thread-managed-hold-adopted-blocked', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { kind: 'unstructured', source: 'user_chat' }, - persistedPromptMessageIds: ['message-managed-hold-adopted-blocked'], - onPromptMessagesExposed: async () => [ - { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-hold-adopted-blocked', - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold-adopted-blocked', - taskId: 'task-managed-hold-adopted-blocked', - }, - ], - }, - }); - - const done = yielded.find((message) => message.type === 'done'); - assert.equal(done?.errorCode, 'managed_hold_disposition_missing'); - assert.equal(done?.turnCustodyTerminalWitnesses, undefined); - assert.equal(appended.filter((message) => message.source?.connector === 'routing-guard-failure').length, 1); - const notice = appended.find((message) => message.source?.connector === 'routing-guard-failure'); - assert.doesNotMatch(notice.content, /结构化补救后/, 'this path never starts a remedial child'); - assert.match(notice.content, /通知.*结算/); - assert.equal(service.calls.length, 1, 'the exact child must not be replaced by a remedial child'); - }); - - test('managed transfer emits exact continuation receipt proof', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [{ shouldBlock: false, transitionObserved: true, structuredTransitionKind: 'handed' }], - }); - const service = createSequenceService('codex', ['Transferred through a structured action.']); - - const { yielded } = await runRoute(service, 'thread-managed-hold-transferred', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'hold', - subjectKey: 'ball:thread:thread-managed-hold-transferred', - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold', - taskId: 'task-managed-hold', - }, - }, - }); - - assert.equal( - yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitness?.transition, - 'transferred', - ); - }); - - test('managed eventWait emits exact continuation receipt proof', async () => { - const threadId = 'thread-managed-hold-event-wait'; - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [{ shouldBlock: true, transitionObserved: false }], - }); - const service = createSequenceService('codex', ['Registered the exact event wait.']); - - const { yielded } = await runRoute(service, threadId, { - projectionService: projection, - taskStore: createEventWaitTaskStore(threadId, 'message-managed-hold', 'task-managed-hold'), - routeOptions: { - turnCustodyWake: { - kind: 'structured', - protocol: 'hold', - subjectKey: `ball:thread:${threadId}`, - holderCatId: 'codex', - sourceMessageId: 'message-managed-hold', - taskId: 'task-managed-hold', - }, - }, - }); - - assert.equal( - yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitness?.transition, - 'event_wait', - ); - }); - - test('a structured remedial transition closes the same projection without a failure notice', async () => { - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { shouldBlock: true, transitionObserved: false }, - { shouldBlock: false, transitionObserved: true }, - ], - }); - const service = createSequenceService('codex', ['Text-only completion.', 'Structured tool completed.']); - - const { appended } = await runRoute(service, 'thread-covered-active-repaired', { - projectionService: projection, - routeOptions: { - turnCustodyWake: { - kind: 'action_successor', - leaseId: 'lease-2', - generation: 1, - holderCatId: 'codex', - }, - }, - }); - - assert.equal(service.calls.length, 2); - assert.equal(projection.closes.length, 2); - assert.equal( - appended.some((message) => message.source?.connector === 'routing-guard-failure'), - false, - ); - }); - - test('the stop-gate remedial child reads a fresh member capacity snapshot', async () => { - const replaceCodexWindow = (contextWindow) => { - const configs = catRegistry.getAllConfigs(); - catRegistry.reset(); - for (const [id, config] of Object.entries(configs)) { - catRegistry.register(id, id === 'codex' ? { ...config, contextWindow } : config); - } - }; - const calls = []; - const service = { - async *invoke(prompt, options) { - calls.push({ prompt, capacity: options?.contextCapacity }); - if (calls.length === 1) replaceCodexWindow(256_000); - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'invocation_created', invocationId: `codex-capacity-${calls.length}` }), - timestamp: Date.now(), - }; - yield { type: 'text', catId: 'codex', content: 'No structured transition.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }; - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { shouldBlock: true, transitionObserved: false }, - { shouldBlock: true, transitionObserved: false }, - ], - }); - - await runRoute(service, 'thread-remedial-capacity', { - projectionService: projection, - beforeRoute: () => replaceCodexWindow(1_000_000), - routeOptions: { - turnCustodyWake: { - kind: 'action_successor', - leaseId: 'lease-remedial-capacity', - generation: 1, - holderCatId: 'codex', - }, - }, - }); - - assert.equal(calls.length, 2); - assert.equal(calls[0].capacity?.windowTokens, 1_000_000); - assert.equal(calls[1].capacity?.windowTokens, 256_000); - }); - - test('the stop-gate remedial child rebuilds its fixed prompt after a fresh capacity seal', async () => { - const { SessionChainStore } = await import('../dist/domains/cats/services/stores/ports/SessionChainStore.js'); - const { _setTestStrategyOverride, _clearTestStrategyOverrides, getSessionStrategyWithSource } = await import( - '../dist/config/session-strategy.js' - ); - const sessionChainStore = new SessionChainStore(); - const threadId = 'thread-remedial-capacity-seal'; - const active = sessionChainStore.create({ - cliSessionId: 'cli-remedial-capacity-old', - threadId, - catId: 'codex', - userId: 'user1', - }); - sessionChainStore.update(active.id, { - contextHealth: { - usedTokens: 240_000, - windowTokens: 2_000_000, - fillRatio: 0.12, - source: 'exact', - usedFrom: 'last_turn', - measuredAt: Date.now(), - }, - }); - const replaceCodexWindow = (contextWindow) => { - const configs = catRegistry.getAllConfigs(); - catRegistry.reset(); - for (const [id, config] of Object.entries(configs)) { - catRegistry.register(id, id === 'codex' ? { ...config, contextWindow } : config); - } - }; - const prompts = []; - const service = { - contextCapability() { - return { - provider: 'openai', - carrier: 'test_stream', - reportsRuntimeWindow: false, - authoritativeUsage: true, - usageTelemetry: 'available', - nativeWindowControl: false, - nativeCompressionControl: false, - observesCompression: true, - reason: 'test carrier', - }; - }, - async *invoke(prompt) { - prompts.push(prompt); - if (prompts.length === 1) replaceCodexWindow(256_000); - yield { - type: 'system_info', - catId: 'codex', - content: JSON.stringify({ type: 'invocation_created', invocationId: `codex-seal-${prompts.length}` }), - timestamp: Date.now(), - }; - yield { type: 'text', catId: 'codex', content: 'No structured transition.', timestamp: Date.now() }; - yield { type: 'done', catId: 'codex', timestamp: Date.now() }; - }, - }; - const sessionSealer = { - async requestSeal({ sessionId, reason }) { - sessionChainStore.update(sessionId, { status: 'sealing', sealReason: reason }); - return { accepted: true, status: 'sealing', sessionId }; - }, - async finalize({ sessionId }) { - sessionChainStore.update(sessionId, { status: 'sealed' }); - }, - }; - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { shouldBlock: true, transitionObserved: false }, - { shouldBlock: true, transitionObserved: false }, - ], - }); - - try { - await runRoute(service, threadId, { - projectionService: projection, - beforeRoute: () => { - _setTestStrategyOverride('codex', { - strategy: 'hybrid', - thresholds: { warn: 0.75, action: 0.85 }, - turnBudget: 12_000, - safetyMargin: 4_000, - hybrid: { maxCompressions: 1 }, - }); - const policy = getSessionStrategyWithSource('codex'); - sessionChainStore.update(active.id, { - appliedPolicy: { - config: policy.effective, - source: policy.source, - revision: policy.revision, - changedAt: policy.changedAt, - execution: { status: 'active', missingCapabilities: [] }, - }, - hybridProgress: { - policyRevision: policy.revision, - observedCount: 1, - startedAt: new Date().toISOString(), - }, - }); - replaceCodexWindow(2_000_000); - }, - sessionChainStore, - sessionSealer, - transcriptReader: { readDigest: async () => null }, - sessionManager: { get: async () => 'cli-remedial-capacity-old' }, - routeOptions: { - turnCustodyWake: { - kind: 'action_successor', - leaseId: 'lease-remedial-capacity-seal', - generation: 1, - holderCatId: 'codex', - }, - }, - }); - } finally { - _clearTestStrategyOverrides(); - } - - assert.equal(prompts.length, 2); - assert.match(prompts[1], /\[Session Continuity/); - assert.match(prompts[1], /F167 球权停止门/); - }); - - test('verified typed PR wait is a structured transition and suppresses the remedial child', async () => { - const threadId = 'thread-event-wait'; - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [{ shouldBlock: true, transitionObserved: false }], - }); - const service = createSequenceService('codex', ['Waiting for the registered review callback.']); - - await runRoute(service, threadId, { - projectionService: projection, - taskStore: createEventWaitTaskStore(threadId), - routeOptions: { - currentUserMessageId: 'message-owner-task', - turnCustodyWake: { - kind: 'action_successor', - leaseId: 'lease-review', - generation: 1, - holderCatId: 'codex', - }, - }, - }); - - assert.equal(service.calls.length, 1); - assert.equal(projection.closes.length, 1); - }); - - test('terminal coordination release remains obligation-free', async () => { - const threadId = 'thread-terminal-release'; - const triggerMessage = { - id: 'msg-terminal-release', - threadId, - catId: 'opus', - content: 'Release complete.', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - senderCatId: 'opus', - effectClass: 'coordinate', - coordination: { id: 'coord-1', phase: 'terminal', hop: 2 }, - }, - }, - }; - const projection = createProjectionService({ - state: 'covered_empty', - closeDecisions: [{ shouldBlock: false, transitionObserved: false }], - }); - const service = createSequenceService('codex', [[]]); - - const { yielded } = await runRoute(service, threadId, { - projectionService: projection, - triggerMessage, - routeOptions: { - currentUserMessageId: triggerMessage.id, - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: `ball:thread:${threadId}`, - holderCatId: 'codex', - handoff: { - sourceEventId: 'message:msg-terminal-release:to:codex', - messageId: triggerMessage.id, - fromCatId: 'opus', - }, - }, - }, - }); - - assert.equal(service.calls.length, 1); - assert.deepEqual(projection.opens[0], { - kind: 'non_obligation', - source: 'coordination_terminal', - }); - const terminal = yielded.find((message) => message.type === 'done'); - assert.deepEqual(terminal.turnCustodyTerminalWitness, { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }); - }); - - test('legacy typed local-review terminal handback no longer creates a structured dispatch obligation', async () => { - const threadId = 'thread-typed-review-handback'; - const triggerMessage = { - id: 'msg-typed-review-handback', - threadId, - catId: 'opus', - content: 'Exact-HEAD review approved.', - extra: { - crossPost: { - sourceThreadId: 'thread-source', - senderCatId: 'opus', - effectClass: 'coordinate', - coordination: { id: 'coord-review-1', phase: 'terminal', hop: 2 }, - }, - localReviewVerdict: { - verdict: 'approved', - clientMessageId: 'typed-review-handback-1', - reviewedHeadSha: 'a'.repeat(40), - }, - }, - }; - const projection = createProjectionService({ - state: 'covered_empty', - closeDecisions: [{ shouldBlock: false, transitionObserved: false }], - }); - const service = createSequenceService('codex', [[]]); - - const { yielded } = await runRoute(service, threadId, { - projectionService: projection, - triggerMessage, - routeOptions: { - currentUserMessageId: triggerMessage.id, - turnCustodyWake: { - kind: 'structured', - protocol: 'dispatch', - subjectKey: `ball:thread:${threadId}`, - holderCatId: 'codex', - handoff: { - sourceEventId: 'message:msg-typed-review-handback:to:codex', - messageId: triggerMessage.id, - fromCatId: 'opus', - }, - }, - }, - }); - - assert.equal(service.calls.length, 1); - assert.deepEqual(projection.opens[0], { - kind: 'non_obligation', - source: 'coordination_terminal', - }); - assert.deepEqual(yielded.find((message) => message.type === 'done')?.turnCustodyTerminalWitness, { - kind: 'terminal_silent', - projectionState: 'covered_empty', - wake: 'coordination_terminal', - }); - }); - - test('ordinary and stop-gate remedial invocations retain separate durable child truth', async () => { - const { InMemoryTurnExecutionStore } = await import( - '../dist/domains/cats/services/stores/memory/InMemoryTurnExecutionStore.js' - ); - const turnExecutionStore = new InMemoryTurnExecutionStore(); - const projection = createProjectionService({ - state: 'covered_active', - closeDecisions: [ - { shouldBlock: true, transitionObserved: false }, - { shouldBlock: false, transitionObserved: true }, - ], - }); - const service = createSequenceService('codex', ['Original answer.', 'Structured transition complete.']); - - const { metadataAugments } = await runRoute(service, 'thread-child-truth', { - projectionService: projection, - turnExecutionStore, - routeOptions: { - parentInvocationId: 'parent-stop-gate', - currentUserMessageId: 'msg-trigger', - turnCustodyWake: { - kind: 'action_successor', - leaseId: 'lease-child-truth', - generation: 1, - holderCatId: 'codex', - }, - }, - }); - - assert.deepEqual( - (await turnExecutionStore.listByParent('parent-stop-gate')).map((record) => ({ - executionKind: record.executionKind, - status: record.status, - routingGuardReason: record.causal?.routingGuardReason, - })), - [ - { executionKind: 'ordinary', status: 'succeeded', routingGuardReason: undefined }, - { executionKind: 'routing_guard', status: 'succeeded', routingGuardReason: 'missing_routing_exit' }, - ], - ); - assert.ok( - metadataAugments.some((entry) => - entry.patch.extra?.auxiliaryTurnExecutions?.some((execution) => execution.executionKind === 'routing_guard'), - ), - 'persisted visible output must point to the structured remedial child', - ); - }); -}); diff --git a/packages/api/test/turn-custody-wake-provenance.test.js b/packages/api/test/turn-custody-wake-provenance.test.js index b8e06a7734..5c143597db 100644 --- a/packages/api/test/turn-custody-wake-provenance.test.js +++ b/packages/api/test/turn-custody-wake-provenance.test.js @@ -7,22 +7,57 @@ import { } from '../dist/domains/ball-custody/turn-custody-wake-provenance.js'; function entry(overrides = {}) { + const { + source = 'connector', + callerCatId: overriddenCallerCatId, + sourceCategory = 'review', + actionSuccessorFence, + waitContinuationCarrier, + a2aTriggerMessageId: overriddenA2ATriggerMessageId, + ...canonicalOverrides + } = overrides; + const callerCatId = Object.hasOwn(overrides, 'callerCatId') ? overriddenCallerCatId : 'codex-terra'; + const a2aTriggerMessageId = Object.hasOwn(overrides, 'a2aTriggerMessageId') + ? overriddenA2ATriggerMessageId + : 'message-1'; + const from = + source === 'user' + ? { kind: 'user', userId: 'user-1' } + : sourceCategory === 'scheduled' + ? { kind: 'system', service: 'test-scheduler' } + : callerCatId + ? { kind: 'agent', catId: callerCatId } + : { kind: 'external', connectorId: 'test-connector' }; return { + version: 1, + id: 'queue-1', threadId: 'thread-1', - messageId: 'message-1', - source: 'connector', - sourceCategory: 'review', - targetCats: ['codex-sol'], - callerCatId: 'codex-terra', - a2aTriggerMessageId: 'message-1', - ...overrides, + owner: { kind: 'user', userId: 'user-1' }, + kind: 'message_wake', + from, + sourceCategory, + targets: ['codex-sol'], + payload: { sourceId: 'message-1', messageId: 'message-1', content: 'wake' }, + execution: { + intent: 'execute', + ownerAuthProvenance: 'strict', + autoExecute: true, + ...(a2aTriggerMessageId ? { a2aTriggerMessageId } : {}), + ...(actionSuccessorFence ? { actionSuccessorFence } : {}), + ...(waitContinuationCarrier ? { waitContinuationCarrier } : {}), + }, + delivery: {}, + status: 'queued', + enqueuedAt: 1, + priority: 'normal', + ...canonicalOverrides, }; } const noMessage = { getById: async () => null }; describe('F167 Phase T queue wake provenance', () => { - it('binds exact action successor and hold-ball carriers', async () => { + it('binds an exact action successor while managed-hold wakes remain lifecycle-owned', async () => { assert.deepEqual( await resolveQueueTurnCustodyWake( entry({ actionSuccessorFence: { leaseId: 'lease-1', generation: 3 } }), @@ -37,7 +72,13 @@ describe('F167 Phase T queue wake provenance', () => { id: 'message-1', source: { connector: 'hold-ball', - meta: { taskId: 'task-hold-1', threadId: 'thread-1', catId: 'codex-sol', wakeWhen: true }, + meta: { + managedHold: true, + phase: 'wake', + taskId: 'task-hold-1', + threadId: 'thread-1', + catId: 'codex-sol', + }, }, }), }), @@ -132,7 +173,7 @@ describe('F167 Phase T queue wake provenance', () => { ); }); - it('binds A2A to the thread dispatch carrier while missing carriers stay legacy fail-closed', async () => { + it('binds A2A to the thread dispatch carrier while ordinary Queue delivery bypasses legacy custody', async () => { const expectedDispatch = { kind: 'structured', protocol: 'dispatch', @@ -160,16 +201,15 @@ describe('F167 Phase T queue wake provenance', () => { callerCatId: undefined, a2aTriggerMessageId: undefined, }), - { getById: async () => ({ catId: 'opus' }) }, - ), - { - ...expectedDispatch, - handoff: { - sourceEventId: 'route:message-1:codex-sol', - messageId: 'message-1', - fromCatId: 'opus', + { + getById: async () => ({ + threadId: 'thread-1', + from: { kind: 'agent', catId: 'opus' }, + extra: { crossPost: { sourceThreadId: 'thread-source' } }, + }), }, - }, + ), + { kind: 'legacy', reason: 'carrier_missing', sourceCategory: 'a2a' }, ); assert.deepEqual( await resolveQueueTurnCustodyWake( @@ -184,9 +224,8 @@ describe('F167 Phase T queue wake provenance', () => { { kind: 'legacy', reason: 'carrier_missing', sourceCategory: 'a2a' }, ); assert.deepEqual(await resolveQueueTurnCustodyWake(entry(), noMessage), { - kind: 'legacy', - reason: 'carrier_missing', - sourceCategory: 'review', + kind: 'unstructured', + source: 'queue_delivery', }); assert.deepEqual( await resolveQueueTurnCustodyWake(entry({ sourceCategory: 'scheduled' }), { @@ -234,7 +273,7 @@ describe('F167 Phase T queue wake provenance', () => { getById: async () => ({ id: 'message-1', threadId: 'thread-1', - catId: 'codex-terra', + from: { kind: 'agent', catId: 'codex-terra' }, extra, }), }), @@ -249,7 +288,7 @@ describe('F167 Phase T queue wake provenance', () => { getById: async () => ({ id: 'message-1', threadId: 'thread-1', - catId: 'codex-terra', + from: { kind: 'agent', catId: 'codex-terra' }, extra: { crossPost: { sourceThreadId: 'thread-source', @@ -290,6 +329,7 @@ describe('F167 Phase T queue wake provenance', () => { ); assert.equal(turnCustodyWakeSourceCategory({ kind: 'legacy', reason: 'source_missing' }), 'unknown'); assert.equal(turnCustodyWakeSourceCategory({ kind: 'unstructured', source: 'user_chat' }), 'user'); + assert.equal(turnCustodyWakeSourceCategory({ kind: 'unstructured', source: 'queue_delivery' }), 'queue'); assert.equal( turnCustodyWakeSourceCategory({ kind: 'action_successor', diff --git a/packages/api/test/typed-wait-callback-continuation.test.js b/packages/api/test/typed-wait-callback-continuation.test.js index 9c3e5b4989..f2bee55ade 100644 --- a/packages/api/test/typed-wait-callback-continuation.test.js +++ b/packages/api/test/typed-wait-callback-continuation.test.js @@ -1,16 +1,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import Fastify from 'fastify'; -import { ManagedHoldDispositionService } from '../dist/domains/ball-custody/ManagedHoldDispositionService.js'; -import { turnCustodyAdoptionRegistry } from '../dist/domains/ball-custody/TurnCustodyAdoptionRegistry.js'; import { resolveTypedWaitContinuation } from '../dist/domains/ball-custody/TypedWaitContinuation.js'; import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; -import { QueuedMessageCustodyCoordinator } from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { TaskStore } from '../dist/domains/cats/services/stores/ports/TaskStore.js'; import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; import { callbacksRoutes } from '../dist/routes/callbacks.js'; -import { makeQueuedMessageCustody } from './helpers/queued-message-custody.js'; async function harness(t, options = {}) { const registry = new InvocationRegistry(); @@ -18,77 +14,74 @@ async function harness(t, options = {}) { const messageStore = new MessageStore(); const threadStore = new ThreadStore(); const thread = threadStore.create('user-1', 'typed wait continuation'); - const events = []; - const appendHold = (suffix, invocationId) => { + const activeInputMessageIds = []; + const appendHold = (suffix) => { const holdTaskId = `hold-${suffix}`; const message = messageStore.append({ - userId: 'scheduler', - catId: null, + from: { kind: 'system', service: 'hold-ball' }, + userId: 'user-1', threadId: thread.id, content: 'Command complete.', mentions: [], timestamp: Date.now(), - deliveryStatus: 'queued', source: { connector: 'hold-ball', label: 'command', icon: 'hold-ball', - meta: { wakeWhen: true, taskId: holdTaskId, threadId: thread.id, catId: 'opus' }, + meta: { + wakeWhen: true, + managedHold: true, + phase: 'wake', + taskId: holdTaskId, + threadId: thread.id, + catId: 'opus', + }, }, - queueCustody: makeQueuedMessageCustody({ - ownerUserId: 'user-1', - allTargetCats: ['opus'], - pendingTargetCats: ['opus'], - ...(invocationId - ? { - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: invocationId }, - bodyExposures: [{ targetCatId: 'opus', invocationId, seenAt: Date.now() }], - } - : {}), - }), - }); - events.push({ - kind: 'ball.wake_condition_met', - sourceEventId: `wake-${suffix}`, - payload: { taskId: holdTaskId, catId: 'opus' }, }); return { - kind: 'structured', - protocol: 'hold', sourceMessageId: message.id, taskId: holdTaskId, - subjectKey: `ball:thread:${thread.id}`, - holderCatId: 'opus', }; }; const primary = options.primaryHold ? appendHold('primary') : undefined; const source = primary ? messageStore.getById(primary.sourceMessageId) : messageStore.append({ + from: { kind: 'user', userId: 'user-1' }, userId: 'user-1', - catId: null, threadId: thread.id, content: 'Continue the owner task.', mentions: [], timestamp: Date.now(), }); + activeInputMessageIds.push(source.id); const auth = await registry.create('user-1', 'opus', thread.id, undefined, undefined, undefined, source.id, 'strict'); - const unregister = turnCustodyAdoptionRegistry.register(auth.invocationId, async () => {}); - t.after(unregister); - const managedHoldDispositionService = new ManagedHoldDispositionService({ - registry, - messageStore, - ballCustodyEventLog: { read: async () => events }, - }); + const invocationTracker = { + getActiveSlots: () => [ + { + catId: 'opus', + startedAt: Date.now(), + activeRun: { + threadId: thread.id, + targetId: 'opus', + invocationId: auth.invocationId, + responseMessageId: 'response-1', + inputEntryIds: [], + inputMessageIds: [...activeInputMessageIds], + privateInputEntryIds: [], + startedAt: Date.now(), + }, + }, + ], + }; let baselineHook = async () => {}; const app = Fastify(); await app.register(callbacksRoutes, { registry, + invocationTracker, taskStore, messageStore, threadStore, - holdBallDeps: { registry, managedHoldDispositionService }, socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {}, getMessages: () => [] }, evidenceStore: { search: async () => [], health: async () => true }, reflectionService: { reflect: async () => '' }, @@ -118,8 +111,8 @@ async function harness(t, options = {}) { baselineHook = fn; }, async adoptHold(suffix) { - const wake = appendHold(suffix, auth.invocationId); - assert.equal(await turnCustodyAdoptionRegistry.adopt(auth.invocationId, [wake]), true); + const wake = appendHold(suffix); + activeInputMessageIds.push(wake.sourceMessageId); return wake; }, async register(when, kind = 'pr') { @@ -206,43 +199,7 @@ test('primary managed command source can establish a CI continuation', async (t) await h.register([{ kind: 'pr_ci_terminal' }]); const continuation = await h.resolve(); assert.equal(continuation.kind, 'bypass'); - const source = h.messageStore.getById(h.identity.sourceMessageId); - h.messageStore.transitionQueueCustody(source.id, { - expectedRevision: source.queueCustody.revision, - next: { - ...source.queueCustody, - revision: source.queueCustody.revision + 1, - seenByCatIds: ['opus'], - seenInvocationIdByCatId: { opus: h.identity.invocationId }, - bodyExposures: [{ targetCatId: 'opus', invocationId: h.identity.invocationId, seenAt: Date.now() }], - }, - }); - const coordinator = new QueuedMessageCustodyCoordinator({ - messageStore: h.messageStore, - readWaitRegistration: (id) => h.taskStore.getWaitRegistration(id), - }); - await coordinator.commitSuccessfulTargetForMessage( - source.queueCustody.entryId, - source.id, - 'opus', - h.identity.invocationId, - Date.now(), - { - invocationId: h.identity.invocationId, - disposition: 'managed_hold_disposition', - evidenceRef: { kind: 'turn_execution', invocationId: h.identity.invocationId }, - handledAt: Date.now(), - consumption: { - kind: 'managed_hold_continued', - sourceMessageId: source.id, - taskId: h.identity.holdTaskId, - transition: 'event_wait', - waitRegistration: continuation.reference, - }, - }, - () => true, - ); - assert.deepEqual(h.messageStore.getById(source.id).queueCustody.handledByCatIds, ['opus']); + assert.deepEqual(continuation.reference, { taskId: continuation.reference.taskId, generation: 1 }); }); test('an adopted hold binds only the exposed source, independently of the user origin', async (t) => { diff --git a/packages/api/test/typed-wait-queue-authority.test.js b/packages/api/test/typed-wait-queue-authority.test.js deleted file mode 100644 index c8c16f5ff8..0000000000 --- a/packages/api/test/typed-wait-queue-authority.test.js +++ /dev/null @@ -1,99 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, test } from 'node:test'; -import { createTypedWaitCustodyFixture as harness } from './helpers/typed-wait-custody-fixture.js'; -import { createTypedWaitMetricReader } from './helpers/typed-wait-metrics.js'; - -const telemetry = createTypedWaitMetricReader(); -after(() => telemetry.close()); - -test('Queue rejects an event_wait witness without a registration reference', async () => { - const h = await harness(); - delete h.outcome.consumption.waitRegistration; - const before = await telemetry.read(); - await assert.rejects(h.commit, /typed wait/); - assert.deepEqual(h.store.getById(h.message.id).queueCustody.handledByCatIds, []); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal(after['event_wait.rejected_other_total'], before['event_wait.rejected_other_total'] + 1); - assert.match(after.text, /routing_event_wait_reason="proof_invalid"/); - for (const value of [h.message.id, h.task.id, 'child-1', 'user-1', 'thread-wait', 'Command result']) { - assert.ok(!after.text.includes(value), `metrics must not contain source identity or body: ${value}`); - } -}); - -for (const race of ['done', 'matched', 'expired', 'superseded']) { - test(`Queue revalidates ${race} after the route obtained its witness`, async () => { - const h = await harness(); - const transition = h.store.transitionQueueCustody.bind(h.store); - h.store.transitionQueueCustody = (id, input) => { - const next = structuredClone(h.active); - if (race === 'expired') next.expiresAt = Date.now() - 1; - if (race === 'superseded') { - next.generation = 2; - next.ownerFence.generation = 2; - } - h.taskStore.update(h.task.id, { - ...(race === 'done' ? { status: 'done' } : {}), - automationState: race === 'matched' ? { waitOutcome: { generation: 1, reason: 'matched' } } : { await: next }, - }); - return transition(id, input); - }; - const before = await telemetry.read(); - await assert.rejects(h.commit, /typed wait/); - assert.deepEqual(h.store.getById(h.message.id).queueCustody.handledByCatIds, []); - assert.equal(h.store.getById(h.message.id).deliveryStatus, 'queued'); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal(after['event_wait.rejected_stale_total'], before['event_wait.rejected_stale_total'] + 1); - }); -} - -test('Queue reports a failed authority read once and keeps the source queued', async () => { - const h = await harness(); - h.taskStore.getWaitRegistration = () => { - throw new Error('store unavailable'); - }; - const before = await telemetry.read(); - await assert.rejects(h.commit); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal(after['event_wait.rejected_query_failed_total'], before['event_wait.rejected_query_failed_total'] + 1); - assert.equal(h.store.getById(h.message.id).deliveryStatus, 'queued'); -}); - -test('Queue counts a mismatched event-wait source as one invalid proof', async () => { - const h = await harness(); - h.outcome.consumption.sourceMessageId = 'foreign-source'; - const before = await telemetry.read(); - await assert.rejects(h.commit); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal(after['event_wait.rejected_other_total'], before['event_wait.rejected_other_total'] + 1); - assert.equal(h.store.getById(h.message.id).deliveryStatus, 'queued'); -}); - -test('a retried Queue revision conflict is not an authority rejection', async () => { - const h = await harness(); - const transition = h.store.transitionQueueCustody.bind(h.store); - let attempts = 0; - h.store.transitionQueueCustody = (id, input) => { - if (++attempts === 1) return { kind: 'revision_mismatch', actualRevision: input.expectedRevision + 1 }; - return transition(id, input); - }; - const before = await telemetry.read(); - await h.commit(); - assert.equal(attempts, 2); - assert.equal((await telemetry.read()).falseBypass, before.falseBypass); - assert.deepEqual(h.store.getById(h.message.id).queueCustody.handledByCatIds, ['opus']); -}); - -test('a valid registration settles once; later expiry does not undo the committed receipt', async () => { - const h = await harness(); - const before = await telemetry.read(); - await h.commit(); - assert.deepEqual(h.store.getById(h.message.id).queueCustody.handledByCatIds, ['opus']); - h.taskStore.update(h.task.id, { status: 'done' }); - await h.commit(); - assert.deepEqual(h.store.getById(h.message.id).queueCustody.handledByCatIds, ['opus']); - assert.equal((await telemetry.read()).falseBypass, before.falseBypass); -}); diff --git a/packages/api/test/typed-wait-queue-redis.test.js b/packages/api/test/typed-wait-queue-redis.test.js deleted file mode 100644 index 9b68dba8d2..0000000000 --- a/packages/api/test/typed-wait-queue-redis.test.js +++ /dev/null @@ -1,193 +0,0 @@ -import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; -import { after, describe, test } from 'node:test'; -import { createRedisClient } from '@cat-cafe/shared/utils'; -import { createTypedWaitRegistration } from '../dist/domains/ball-custody/TypedWaitRegistration.js'; -import { RedisMessageStore } from '../dist/domains/cats/services/stores/redis/RedisMessageStore.js'; -import { RedisTaskStore } from '../dist/domains/cats/services/stores/redis/RedisTaskStore.js'; -import { TaskKeys } from '../dist/domains/cats/services/stores/redis-keys/task-keys.js'; -import { - assertRedisIsolationOrThrow, - cleanupClientKeyspace, - redisIsolationSkipReason, -} from './helpers/redis-test-helpers.js'; -import { createTypedWaitCustodyFixture } from './helpers/typed-wait-custody-fixture.js'; -import { createTypedWaitMetricReader } from './helpers/typed-wait-metrics.js'; - -const telemetry = createTypedWaitMetricReader(); -after(() => telemetry.close()); - -describe( - 'typed wait registration and Queue share atomic Redis authority', - { skip: redisIsolationSkipReason(process.env.REDIS_URL) }, - () => { - async function harness(t) { - assertRedisIsolationOrThrow(process.env.REDIS_URL, 'typed-wait-queue'); - const redis = createRedisClient({ url: process.env.REDIS_URL, keyPrefix: `typed-wait-${randomUUID()}:` }); - await redis.ping(); - t.after(async () => { - await cleanupClientKeyspace(redis); - await redis.quit(); - }); - const taskStore = new RedisTaskStore(redis); - const h = await createTypedWaitCustodyFixture({ messageStore: new RedisMessageStore(redis), taskStore }); - return { ...h, redis }; - } - - test('private receipt survives a new store instance; public hydration and replay stay clean', async (t) => { - const h = await harness(t); - const cold = new RedisTaskStore(h.redis); - assert.deepEqual((await cold.getWaitRegistration(h.task.id)).receipt, h.receipt); - assert.equal(JSON.stringify(await cold.get(h.task.id)).includes('typedWaitRegistration'), false); - const before = await telemetry.read(); - await h.commit(); - await h.taskStore.update(h.task.id, { status: 'done' }); - await h.commit(); - const source = await h.store.getById(h.message.id); - assert.equal(source.deliveryStatus, 'delivered'); - assert.deepEqual(source.queueCustody.handledByCatIds, ['opus']); - assert.equal((await telemetry.read()).falseBypass, before.falseBypass); - }); - - test('a losing Task CAS cannot overwrite the winning generation receipt', async (t) => { - const h = await harness(t); - const active = { ...h.active, generation: 2, ownerFence: { kind: 'containing_task', generation: 2 } }; - const nextReceipt = createTypedWaitRegistration({ - task: h.task, - active, - invocationId: 'child-2', - source: h.receipt.source, - }); - assert.equal( - await h.taskStore.replaceAutomationStateIfGeneration(h.task.id, { - expectedGeneration: 0, - automationState: { await: active }, - waitRegistration: nextReceipt, - }), - null, - ); - assert.deepEqual((await h.taskStore.getWaitRegistration(h.task.id)).receipt, h.receipt); - assert.ok( - await h.taskStore.replaceAutomationStateIfGeneration(h.task.id, { - expectedGeneration: 1, - automationState: { await: active }, - waitRegistration: nextReceipt, - }), - ); - const next = await new RedisTaskStore(h.redis).getWaitRegistration(h.task.id); - assert.equal(next.task.automationState.await.generation, 2); - assert.equal(next.receipt.invocationId, 'child-2'); - const before = await telemetry.read(); - await assert.rejects(h.commit, /typed wait/); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal(after['event_wait.rejected_stale_total'], before['event_wait.rejected_stale_total'] + 1); - }); - - test('Lua checks expiry against server time even when the Task hash is unchanged', async (t) => { - const h = await harness(t); - const active = { ...h.active, expiresAt: Date.now() + 100 }; - const receipt = createTypedWaitRegistration({ - task: h.task, - active, - invocationId: 'child-1', - source: h.receipt.source, - }); - - await h.taskStore.replaceAutomationStateIfGeneration(h.task.id, { - expectedGeneration: 1, - automationState: { await: active }, - waitRegistration: receipt, - }); - const evaluate = h.redis.eval.bind(h.redis); - h.redis.eval = async (script, ...args) => { - if (script.includes('local waitGuards =')) - await new Promise((resolve) => setTimeout(resolve, Math.max(0, active.expiresAt - Date.now()) + 10)); - return evaluate(script, ...args); - }; - const before = await telemetry.read(); - await assert.rejects(h.commit, /typed wait/); - assert.deepEqual((await h.store.getById(h.message.id)).queueCustody.handledByCatIds, []); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal(after['event_wait.rejected_stale_total'], before['event_wait.rejected_stale_total'] + 1); - }); - - test('a collector-only update during Queue CAS leaves the same active registration valid', async (t) => { - const h = await harness(t); - const evaluate = h.redis.eval.bind(h.redis); - let injected = false; - h.redis.eval = async (script, ...args) => { - if (!injected && script.includes('local waitGuards =')) { - injected = true; - await h.taskStore.patchAutomationState(h.task.id, { - ci: { headSha: 'head-1', lastNotificationBucket: 'pending' }, - }); - } - return evaluate(script, ...args); - }; - const before = await telemetry.read(); - await h.commit(); - assert.equal(injected, true); - assert.deepEqual((await h.store.getById(h.message.id)).queueCustody.handledByCatIds, ['opus']); - assert.equal((await telemetry.read()).falseBypass, before.falseBypass); - }); - - test('a failed canonical Task read reports one bounded rejection', async (t) => { - const h = await harness(t); - const read = h.redis.hgetall.bind(h.redis); - h.redis.hgetall = async (key) => { - if (key === TaskKeys.detail(h.task.id)) throw new Error('Task connection unavailable'); - return read(key); - }; - const before = await telemetry.read(); - await assert.rejects(h.commit); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal( - after['event_wait.rejected_query_failed_total'], - before['event_wait.rejected_query_failed_total'] + 1, - ); - assert.equal((await h.store.getById(h.message.id)).deliveryStatus, 'queued'); - }); - - for (const race of ['matched', 'expired', 'superseded', 'owner', 'deleted']) { - test(`Lua rejects ${race} between proof read and Queue mutation`, async (t) => { - const h = await harness(t); - const evaluate = h.redis.eval.bind(h.redis); - let injected = false; - h.redis.eval = async (script, ...args) => { - if (!injected && script.includes('local waitGuards =')) { - injected = true; - if (race === 'deleted') await h.taskStore.delete(h.task.id); - else if (race === 'owner') await h.redis.hset(TaskKeys.detail(h.task.id), 'ownerCatId', 'foreign-cat'); - else { - const active = structuredClone(h.active); - if (race === 'expired') active.expiresAt = Date.now() - 1; - if (race === 'superseded') { - active.generation = 2; - active.ownerFence.generation = 2; - } - await h.taskStore.replaceAutomationStateIfGeneration(h.task.id, { - expectedGeneration: 1, - automationState: - race === 'matched' ? { waitOutcome: { generation: 1, reason: 'matched' } } : { await: active }, - }); - } - } - return evaluate(script, ...args); - }; - const before = await telemetry.read(); - await assert.rejects(h.commit, /typed wait/); - assert.equal(injected, true); - const source = await h.store.getById(h.message.id); - assert.equal(source.deliveryStatus, 'queued'); - assert.deepEqual(source.queueCustody.handledByCatIds, []); - const after = await telemetry.read(); - assert.equal(after.falseBypass, before.falseBypass + 1); - assert.equal(after['event_wait.rejected_stale_total'], before['event_wait.rejected_stale_total'] + 1); - assert.match(after.text, /routing_event_wait_reason="authority_changed"/); - }); - } - }, -); diff --git a/packages/api/test/typed-wait-registration-redis.test.js b/packages/api/test/typed-wait-registration-redis.test.js new file mode 100644 index 0000000000..cd40455a3a --- /dev/null +++ b/packages/api/test/typed-wait-registration-redis.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { describe, test } from 'node:test'; +import { createRedisClient } from '@cat-cafe/shared/utils'; +import { resolveTypedWaitContinuation } from '../dist/domains/ball-custody/TypedWaitContinuation.js'; +import { createTypedWaitRegistration } from '../dist/domains/ball-custody/TypedWaitRegistration.js'; +import { RedisMessageStore } from '../dist/domains/cats/services/stores/redis/RedisMessageStore.js'; +import { RedisTaskStore } from '../dist/domains/cats/services/stores/redis/RedisTaskStore.js'; +import { TaskKeys } from '../dist/domains/cats/services/stores/redis-keys/task-keys.js'; +import { + assertRedisIsolationOrThrow, + cleanupClientKeyspace, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; +import { createTypedWaitCustodyFixture } from './helpers/typed-wait-custody-fixture.js'; + +describe( + 'typed wait registration keeps exact private Redis authority', + { skip: redisIsolationSkipReason(process.env.REDIS_URL) }, + () => { + async function harness(t) { + assertRedisIsolationOrThrow(process.env.REDIS_URL, 'typed-wait-registration'); + const redis = createRedisClient({ url: process.env.REDIS_URL, keyPrefix: `typed-wait-${randomUUID()}:` }); + await redis.ping(); + t.after(async () => { + await cleanupClientKeyspace(redis); + await redis.quit(); + }); + const taskStore = new RedisTaskStore(redis); + const h = await createTypedWaitCustodyFixture({ messageStore: new RedisMessageStore(redis), taskStore }); + const identity = { + invocationId: 'child-1', + userId: 'user-1', + catId: 'opus', + threadId: 'thread-wait', + sourceMessageId: h.message.id, + holdTaskId: 'hold-1', + }; + return { ...h, redis, identity }; + } + + test('private receipt survives a new store instance and public hydration stays clean', async (t) => { + const h = await harness(t); + const cold = new RedisTaskStore(h.redis); + assert.deepEqual((await cold.getWaitRegistration(h.task.id)).receipt, h.receipt); + assert.equal(JSON.stringify(await cold.get(h.task.id)).includes('typedWaitRegistration'), false); + assert.equal((await resolveTypedWaitContinuation({ taskStore: cold, ...h.identity })).kind, 'bypass'); + }); + + test('a losing Task CAS cannot overwrite the winning generation receipt', async (t) => { + const h = await harness(t); + const active = { ...h.active, generation: 2, ownerFence: { kind: 'containing_task', generation: 2 } }; + const nextReceipt = createTypedWaitRegistration({ + task: h.task, + active, + invocationId: 'child-2', + source: h.receipt.source, + }); + assert.equal( + await h.taskStore.replaceAutomationStateIfGeneration(h.task.id, { + expectedGeneration: 0, + automationState: { await: active }, + waitRegistration: nextReceipt, + }), + null, + ); + assert.deepEqual((await h.taskStore.getWaitRegistration(h.task.id)).receipt, h.receipt); + }); + + test('collector-only updates retain the same active registration', async (t) => { + const h = await harness(t); + await h.taskStore.patchAutomationState(h.task.id, { + ci: { headSha: 'head-1', lastNotificationBucket: 'pending' }, + }); + assert.equal((await resolveTypedWaitContinuation({ taskStore: h.taskStore, ...h.identity })).kind, 'bypass'); + }); + + test('expiry and generation replacement fail closed on a fresh canonical read', async (t) => { + const h = await harness(t); + assert.equal( + (await resolveTypedWaitContinuation({ taskStore: h.taskStore, ...h.identity, now: h.active.expiresAt + 1 })) + .kind, + 'reject', + ); + const active = { ...h.active, generation: 2, ownerFence: { kind: 'containing_task', generation: 2 } }; + await h.taskStore.replaceAutomationStateIfGeneration(h.task.id, { + expectedGeneration: 1, + automationState: { await: active }, + }); + assert.equal((await resolveTypedWaitContinuation({ taskStore: h.taskStore, ...h.identity })).kind, 'reject'); + }); + + test('a failed canonical Task read reports query_failed', async (t) => { + const h = await harness(t); + const read = h.redis.hgetall.bind(h.redis); + h.redis.hgetall = async (key) => { + if (key === TaskKeys.detail(h.task.id)) throw new Error('Task connection unavailable'); + return read(key); + }; + assert.deepEqual(await resolveTypedWaitContinuation({ taskStore: h.taskStore, ...h.identity }), { + kind: 'reject', + reason: 'query_failed', + }); + }); + }, +); diff --git a/packages/api/test/visibility-cursor-consumer-contracts.test.js b/packages/api/test/visibility-cursor-consumer-contracts.test.js index 2dd9e9e248..3999d8d3d2 100644 --- a/packages/api/test/visibility-cursor-consumer-contracts.test.js +++ b/packages/api/test/visibility-cursor-consumer-contracts.test.js @@ -13,6 +13,7 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -121,14 +122,16 @@ describe('#3444 A4 source-to-sink contracts (requires Redis)', () => { }); async function appendUserMessage(messageStore, threadId, content, timestamp) { - return messageStore.append({ - userId: USER_ID, - catId: null, - content, - mentions: [], - timestamp, - threadId, - }); + return messageStore.append( + canonicalTestMessageInput({ + userId: USER_ID, + catId: null, + content, + mentions: [], + timestamp, + threadId, + }), + ); } function makeCursorStore(messageStore) { diff --git a/packages/api/test/visibility-cursor-unresolved-read-policy.test.js b/packages/api/test/visibility-cursor-unresolved-read-policy.test.js index fa3e27b747..2512cc8be5 100644 --- a/packages/api/test/visibility-cursor-unresolved-read-policy.test.js +++ b/packages/api/test/visibility-cursor-unresolved-read-policy.test.js @@ -11,6 +11,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; import { assertRedisIsolationOrThrow, cleanupClientKeyspace, @@ -71,15 +72,17 @@ describe('#3444 unresolved visibility read-anchor policy', { skip: redisIsolatio }); async function appendMessage({ threadId, catId, content, timestamp, mentionsUser = false }) { - return messageStore.append({ - userId: USER_ID, - catId, - content, - mentions: [], - mentionsUser, - timestamp, - threadId, - }); + return messageStore.append( + canonicalTestMessageInput({ + userId: USER_ID, + catId, + content, + mentions: [], + mentionsUser, + timestamp, + threadId, + }), + ); } const durableCursorNamespaces = [ diff --git a/packages/api/test/wait-continuation-retry-committer-redis.test.js b/packages/api/test/wait-continuation-retry-committer-redis.test.js deleted file mode 100644 index 9be8ed6fba..0000000000 --- a/packages/api/test/wait-continuation-retry-committer-redis.test.js +++ /dev/null @@ -1,371 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, beforeEach, describe, test } from 'node:test'; - -import { makeQueuedMessageCustody as makeCustody } from './helpers/queued-message-custody.js'; -import { - assertRedisIsolationOrThrow, - cleanupPrefixedRedisKeys, - redisIsolationSkipReason, -} from './helpers/redis-test-helpers.js'; - -const REDIS_URL = process.env.REDIS_URL; -const TARGET_CAT_ID = 'codex-sol'; -const USER_ID = 'user-1'; -const THREAD_ID = 'thread-retry-atomic'; -const TASK_ID = 'task-wait-retry-atomic'; - -function waitOutcome(ownerFence, overrides = {}) { - return { - v: 1, - outcomeId: 'wait:pr:zts212653/cat-cafe#1:g4:matched', - generation: 4, - subjectRef: 'pr:zts212653/cat-cafe#1', - ownerFence, - reason: 'matched', - at: 1_000, - delivery: 'delivered', - nextStep: 'continue exact work', - ...overrides, - }; -} - -function waitTask(ownerFence, overrides = {}) { - return { - id: TASK_ID, - kind: 'pr_tracking', - threadId: THREAD_ID, - subjectKey: 'pr:zts212653/cat-cafe#1', - title: 'wait', - ownerCatId: TARGET_CAT_ID, - status: 'done', - why: 'test', - createdBy: TARGET_CAT_ID, - createdAt: 1, - updatedAt: 2, - userId: USER_ID, - automationState: { waitOutcome: waitOutcome(ownerFence) }, - ...overrides, - }; -} - -function failedCustody() { - return makeCustody({ - entryId: 'entry-retry-atomic', - allTargetCats: [TARGET_CAT_ID], - pendingTargetCats: [TARGET_CAT_ID], - failedByCatIds: [TARGET_CAT_ID], - targetAttempts: [ - { - id: `entry-retry-atomic:${TARGET_CAT_ID}:1`, - targetCatId: TARGET_CAT_ID, - sequence: 1, - state: 'failed', - createdAt: 1_000, - updatedAt: 1_100, - terminalReason: 'invocation_failed', - }, - ], - updatedAt: 1_100, - }); -} - -function retryTransition(messageId, current) { - const attempt = { - id: `entry-retry-atomic:${TARGET_CAT_ID}:2`, - targetCatId: TARGET_CAT_ID, - sequence: 2, - state: 'queued', - createdAt: 1_200, - updatedAt: 1_200, - }; - return { - messageId, - current, - next: { - ...current, - revision: current.revision + 1, - failedByCatIds: [], - targetAttempts: [...current.targetAttempts, attempt], - updatedAt: 1_200, - }, - }; -} - -describe('Gate 5 retry authority Redis linearization', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { - let redis; - let messageStore; - let taskStore; - let leaseStore; - let WaitContinuationRetryCommitter; - let MessageKeys; - let TaskKeys; - let serializeTask; - let canonicalizeActionTerminalPredicate; - let connected = false; - - before(async () => { - assertRedisIsolationOrThrow(REDIS_URL, 'Gate 5 retry authority Redis linearization'); - const [ - { createRedisClient }, - messageStoreModule, - taskStoreModule, - messageKeysModule, - taskKeysModule, - taskCodecModule, - leaseStoreModule, - committerModule, - predicateModule, - ] = await Promise.all([ - import('@cat-cafe/shared/utils'), - import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'), - import('../dist/domains/cats/services/stores/redis/RedisTaskStore.js'), - import('../dist/domains/cats/services/stores/redis-keys/message-keys.js'), - import('../dist/domains/cats/services/stores/redis-keys/task-keys.js'), - import('../dist/domains/cats/services/stores/redis/RedisTaskCodec.js'), - import('../dist/domains/ball-custody/RedisActionSuccessorLeaseStore.js'), - import('../dist/domains/ball-custody/WaitContinuationRetryCommitter.js'), - import('../dist/domains/ball-custody/ActionTerminalPredicateCatalog.js'), - ]); - redis = createRedisClient({ url: REDIS_URL }); - try { - await redis.ping(); - connected = true; - messageStore = new messageStoreModule.RedisMessageStore(redis); - taskStore = new taskStoreModule.RedisTaskStore(redis); - leaseStore = new leaseStoreModule.RedisActionSuccessorLeaseStore(redis); - WaitContinuationRetryCommitter = committerModule.WaitContinuationRetryCommitter; - MessageKeys = messageKeysModule.MessageKeys; - TaskKeys = taskKeysModule.TaskKeys; - serializeTask = taskCodecModule.serializeTask; - canonicalizeActionTerminalPredicate = predicateModule.canonicalizeActionTerminalPredicate; - } catch { - await redis.quit().catch(() => {}); - } - }); - - beforeEach(async (t) => { - if (!connected) return t.skip('Redis not connected'); - await cleanupPrefixedRedisKeys(redis, ['msg:*', 'task:*', 'tasks:*', 'action:successor:*']); - }); - - after(async () => { - if (!connected) return; - await cleanupPrefixedRedisKeys(redis, ['msg:*', 'task:*', 'tasks:*', 'action:successor:*']); - await redis.quit(); - }); - - async function seedMessageAndTask(ownerFence) { - const current = failedCustody(); - const message = await messageStore.append({ - userId: USER_ID, - catId: 'system', - content: 'retry this exact wait continuation', - mentions: [TARGET_CAT_ID], - timestamp: 1_100, - threadId: THREAD_ID, - deliveryStatus: 'queued', - source: { - connector: 'github-wait', - label: 'GitHub wait', - icon: 'github', - meta: { - waitContinuationCarrier: { - v: 1, - waitId: TASK_ID, - outcomeId: 'wait:pr:zts212653/cat-cafe#1:g4:matched', - ownerFence, - }, - }, - }, - queueCustody: current, - }); - await redis.hset(TaskKeys.detail(TASK_ID), serializeTask(waitTask(ownerFence))); - return { current, message, transition: retryTransition(message.id, current) }; - } - - async function commit(fixture, beforeAtomicCommit) { - const committer = new WaitContinuationRetryCommitter({ - messageStore, - taskStore, - actionSuccessorLeaseStore: leaseStore, - redis, - beforeAtomicCommit, - }); - return committer.commit({ - authorityMessageId: fixture.message.id, - requestingUserId: USER_ID, - targetCatId: TARGET_CAT_ID, - transitions: [fixture.transition], - }); - } - - async function assertAttemptWasNotMinted(fixture) { - const stored = await messageStore.getById(fixture.message.id); - assert.equal(stored.queueCustody.revision, fixture.current.revision); - assert.deepEqual( - stored.queueCustody.targetAttempts.map(({ id, state }) => ({ id, state })), - [{ id: `entry-retry-atomic:${TARGET_CAT_ID}:1`, state: 'failed' }], - ); - } - - test('atomically verifies the containing-task witness and appends attempt 2', async () => { - const ownerFence = { kind: 'containing_task', generation: 4 }; - const fixture = await seedMessageAndTask(ownerFence); - - assert.deepEqual(await commit(fixture), { outcome: 'committed' }); - const stored = await messageStore.getById(fixture.message.id); - assert.equal(stored.queueCustody.revision, 2); - assert.deepEqual( - stored.queueCustody.targetAttempts.map(({ id, state }) => ({ id, state })), - [ - { id: `entry-retry-atomic:${TARGET_CAT_ID}:1`, state: 'failed' }, - { id: `entry-retry-atomic:${TARGET_CAT_ID}:2`, state: 'queued' }, - ], - ); - }); - - test('preserves authenticated user retry without inventing a Task witness', async () => { - const current = failedCustody(); - const message = await messageStore.append({ - userId: USER_ID, - catId: null, - content: 'retry my authored queued message', - mentions: [TARGET_CAT_ID], - timestamp: 1_100, - threadId: THREAD_ID, - deliveryStatus: 'queued', - queueCustody: current, - }); - const fixture = { current, message, transition: retryTransition(message.id, current) }; - - assert.deepEqual(await commit(fixture), { outcome: 'committed' }); - const stored = await messageStore.getById(message.id); - assert.equal(stored.queueCustody.revision, 2); - assert.equal(stored.queueCustody.targetAttempts.at(-1).state, 'queued'); - }); - - for (const [description, rawSource] of [ - ['legacy connector shape', JSON.stringify({ connector: 'github-wait' })], - ['malformed connector JSON', '{'], - ]) { - test(`rejects a present-but-unparseable ${description} without minting attempt 2`, async () => { - const current = failedCustody(); - const message = await messageStore.append({ - userId: USER_ID, - catId: null, - content: 'legacy persisted connector must not become user-authored retry authority', - mentions: [TARGET_CAT_ID], - timestamp: 1_100, - threadId: THREAD_ID, - deliveryStatus: 'queued', - queueCustody: current, - }); - await redis.hset(MessageKeys.detail(message.id), 'source', rawSource); - const hydrated = await messageStore.getById(message.id); - assert.equal(hydrated.source, undefined); - const fixture = { current, message, transition: retryTransition(message.id, current) }; - - assert.deepEqual(await commit(fixture), { - outcome: 'authority_stale', - reason: 'legacy_unattributed', - }); - assert.equal(hydrated.sourceParseFailure, true); - await assertAttemptWasNotMinted(fixture); - }); - } - - test('rejects a source field that appears after user preflight even when its raw value is empty', async () => { - const current = failedCustody(); - const message = await messageStore.append({ - userId: USER_ID, - catId: null, - content: 'source presence is part of the atomic user-authority witness', - mentions: [TARGET_CAT_ID], - timestamp: 1_100, - threadId: THREAD_ID, - deliveryStatus: 'queued', - queueCustody: current, - }); - const fixture = { current, message, transition: retryTransition(message.id, current) }; - - const result = await commit(fixture, async () => { - await redis.hset(MessageKeys.detail(message.id), 'source', ''); - }); - - assert.deepEqual(result, { outcome: 'authority_stale', reason: 'authority_witness_changed' }); - await assertAttemptWasNotMinted(fixture); - }); - - test('rejects a containing-task replacement immediately before the custody Lua commit', async () => { - const ownerFence = { kind: 'containing_task', generation: 4 }; - const fixture = await seedMessageAndTask(ownerFence); - let revoked = false; - - const result = await commit(fixture, async () => { - revoked = true; - const replacementFence = { kind: 'containing_task', generation: 5 }; - await redis.hset( - TaskKeys.detail(TASK_ID), - serializeTask( - waitTask(replacementFence, { - automationState: { waitOutcome: waitOutcome(replacementFence, { generation: 5 }) }, - }), - ), - ); - }); - - assert.equal(revoked, true); - assert.deepEqual(result, { outcome: 'authority_stale', reason: 'authority_witness_changed' }); - await assertAttemptWasNotMinted(fixture); - }); - - test('rejects action-successor terminalization immediately before the custody Lua commit', async () => { - const terminalPredicate = canonicalizeActionTerminalPredicate({ - actionFamily: 'review', - subjectRef: 'pr:zts212653/cat-cafe#1', - predicate: { kind: 'review_delivered', headSha: 'a'.repeat(40) }, - }); - const claimed = await leaseStore.claim({ - leaseId: 'lease-retry-atomic', - tenantScope: USER_ID, - subjectRef: 'pr:zts212653/cat-cafe#1', - actionFamily: 'review', - successorSlot: 'reviewer', - mode: 'single', - holderCatIds: [TARGET_CAT_ID], - dispatchId: 'dispatch-retry-atomic', - claimOrigin: 'structured_transfer', - holderThreadId: THREAD_ID, - predecessorCatId: 'codex-terra', - predecessorThreadId: 'thread-reviewer', - issuerStandingEvidenceRef: 'message:review-request', - evidenceRefs: ['message:review-request'], - terminalPredicate, - now: 900, - }); - assert.equal(claimed.outcome, 'claimed'); - const ownerFence = { - kind: 'action_successor', - leaseId: claimed.lease.leaseId, - generation: claimed.lease.generation, - }; - const fixture = await seedMessageAndTask(ownerFence); - let terminalized = false; - - const result = await commit(fixture, async () => { - terminalized = true; - const committed = await leaseStore.commitOutcome(claimed.lease.leaseId, { - generation: claimed.lease.generation, - catId: TARGET_CAT_ID, - outcome: 'failed', - evidenceRef: 'invocation:failed-before-custody-cas', - now: 1_150, - }); - assert.equal(committed.outcome, 'recorded'); - }); - - assert.equal(terminalized, true); - assert.deepEqual(result, { outcome: 'authority_stale', reason: 'authority_witness_changed' }); - await assertAttemptWasNotMinted(fixture); - }); -}); diff --git a/packages/api/test/wait-continuation-retry-linearization.test.js b/packages/api/test/wait-continuation-retry-linearization.test.js deleted file mode 100644 index 59c3d52d9f..0000000000 --- a/packages/api/test/wait-continuation-retry-linearization.test.js +++ /dev/null @@ -1,299 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, mock, test } from 'node:test'; - -import { WaitContinuationRetryCommitter } from '../dist/domains/ball-custody/WaitContinuationRetryCommitter.js'; -import { WaitContinuationRetryPreflight } from '../dist/domains/ball-custody/WaitContinuationRetryPreflight.js'; -import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; -import { - createInitialQueuedMessageCustody, - QueuedMessageCustodyCoordinator, -} from '../dist/domains/cats/services/agents/invocation/QueuedMessageCustodyCoordinator.js'; -import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; -import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; - -const TARGET_CAT_ID = 'codex-sol'; - -function waitOutcome(ownerFence, overrides = {}) { - return { - v: 1, - outcomeId: 'wait:pr:zts212653/cat-cafe#1:g4:matched', - generation: 4, - subjectRef: 'pr:zts212653/cat-cafe#1', - ownerFence, - reason: 'matched', - at: 1_000, - delivery: 'delivered', - nextStep: 'continue exact work', - ...overrides, - }; -} - -function waitTask(ownerFence, overrides = {}) { - return { - id: 'task-wait-1', - kind: 'pr_tracking', - threadId: 'thread-1', - subjectKey: 'pr:zts212653/cat-cafe#1', - title: 'wait', - ownerCatId: TARGET_CAT_ID, - status: 'done', - why: 'test', - createdBy: TARGET_CAT_ID, - createdAt: 1, - updatedAt: 2, - userId: 'user-1', - automationState: { waitOutcome: waitOutcome(ownerFence) }, - ...overrides, - }; -} - -function processorDeps(queue, messageStore, queueCustodyCoordinator) { - return { - queue, - messageStore, - queueCustodyCoordinator, - invocationTracker: { - start: mock.fn(() => new AbortController()), - startAll: mock.fn(() => new AbortController()), - complete: mock.fn(), - completeAll: mock.fn(), - has: mock.fn(() => false), - }, - invocationRecordStore: { - create: mock.fn(async () => ({ outcome: 'created', invocationId: 'inv-stub' })), - update: mock.fn(async () => {}), - }, - router: { - routeExecution: mock.fn(async function* () { - yield { type: 'done', catId: TARGET_CAT_ID, timestamp: Date.now() }; - }), - ackCollectedCursors: mock.fn(async () => {}), - }, - socketManager: { - broadcastAgentMessage: mock.fn(), - broadcastToRoom: mock.fn(), - emitToUser: mock.fn(), - }, - log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, - }; -} - -async function failedWaitFixture({ ownerFence, leaseResult }) { - const queue = new InvocationQueue(); - const messageStore = new MessageStore(); - const queued = queue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - content: 'retry the exact wait continuation', - source: 'agent', - ownerAuthProvenance: 'strict', - targetCats: [TARGET_CAT_ID], - intent: 'execute', - callerCatId: 'system', - }); - assert.equal(queued.outcome, 'enqueued'); - assert.ok(queued.entry); - const entry = queued.entry; - const message = messageStore.append({ - threadId: entry.threadId, - userId: entry.userId, - catId: 'system', - content: entry.content, - mentions: entry.targetCats, - timestamp: entry.createdAt, - deliveryStatus: 'queued', - source: { - connector: 'github-wait', - meta: { - waitContinuationCarrier: { - v: 1, - waitId: 'task-wait-1', - outcomeId: 'wait:pr:zts212653/cat-cafe#1:g4:matched', - ownerFence, - }, - }, - }, - queueCustody: createInitialQueuedMessageCustody(entry), - }); - queue.backfillMessageId(entry.threadId, entry.userId, entry.id, message.id); - - const coordinator = new QueuedMessageCustodyCoordinator({ messageStore }); - queue.markQueuedSeen(entry.threadId, entry.userId, entry.id, TARGET_CAT_ID, 'inv-failed'); - queue.markQueuedFailedForCatAcrossUsers( - entry.threadId, - TARGET_CAT_ID, - 'inv-failed', - new Set([entry.id]), - 'invocation_failed', - ); - await coordinator.persistEntry(queue.getEntrySnapshot(entry.threadId, entry.userId, entry.id)); - - let currentTask = waitTask(ownerFence); - let currentLeaseResult = leaseResult; - const taskStore = { get: () => currentTask }; - const actionSuccessorLeaseStore = { - preflightOutput: () => currentLeaseResult, - }; - const preflight = new WaitContinuationRetryPreflight({ - taskStore, - actionSuccessorLeaseStore, - }); - const revalidateAuthority = async () => { - const currentMessage = messageStore.getById(message.id); - assert.ok(currentMessage); - return preflight.preflight({ - message: currentMessage, - requestingUserId: entry.userId, - targetCatId: TARGET_CAT_ID, - }); - }; - const committer = new WaitContinuationRetryCommitter({ messageStore, taskStore, actionSuccessorLeaseStore }); - let beforeCommit; - const commitAuthority = async (transitions) => { - beforeCommit?.(); - return committer.commit({ - authorityMessageId: message.id, - requestingUserId: entry.userId, - targetCatId: TARGET_CAT_ID, - transitions, - }); - }; - const failedAttempt = messageStore.getById(message.id).queueCustody.targetAttempts[0]; - assert.equal(failedAttempt.state, 'failed'); - - return { - entry, - failedAttempt, - message, - messageStore, - processor: new QueueProcessor(processorDeps(queue, messageStore, coordinator)), - queue, - commitAuthority, - revalidateAuthority, - replaceTask: (next) => { - currentTask = next; - }, - replaceLeaseResult: (next) => { - currentLeaseResult = next; - }, - setBeforeCommit: (hook) => { - beforeCommit = hook; - }, - }; -} - -function assertRetryWasNotMinted(fixture) { - const queueEntry = fixture.queue.getEntrySnapshot(fixture.entry.threadId, fixture.entry.userId, fixture.entry.id); - assert.deepEqual(queueEntry.queuedFailedByCatIds, [TARGET_CAT_ID]); - const custody = fixture.messageStore.getById(fixture.message.id).queueCustody; - assert.deepEqual( - custody.targetAttempts.map(({ id, state }) => ({ id, state })), - [{ id: fixture.failedAttempt.id, state: 'failed' }], - ); -} - -describe('Gate 5 retry authority mutation linearization', () => { - test('containing-task replacement after route preflight cannot reopen Queue or append an attempt', async () => { - const ownerFence = { kind: 'containing_task', generation: 4 }; - const fixture = await failedWaitFixture({ ownerFence, leaseResult: { ok: true, reason: 'active' } }); - assert.deepEqual(await fixture.revalidateAuthority(), { ok: true, kind: 'wait_containing_task' }); - - fixture.replaceTask( - waitTask( - { kind: 'containing_task', generation: 5 }, - { - automationState: { waitOutcome: waitOutcome({ kind: 'containing_task', generation: 5 }, { generation: 5 }) }, - }, - ), - ); - const result = await fixture.processor.retryFailedTarget( - fixture.entry.threadId, - fixture.entry.userId, - fixture.entry.id, - TARGET_CAT_ID, - fixture.failedAttempt.id, - fixture.commitAuthority, - ); - - assert.deepEqual(result, { outcome: 'authority_stale', reason: 'outcome_mismatch' }); - assertRetryWasNotMinted(fixture); - }); - - test('action-successor terminalization after route preflight cannot reopen Queue or append an attempt', async () => { - const ownerFence = { kind: 'action_successor', leaseId: 'lease-1', generation: 7 }; - const fixture = await failedWaitFixture({ ownerFence, leaseResult: { ok: true, reason: 'active' } }); - assert.deepEqual(await fixture.revalidateAuthority(), { ok: true, kind: 'wait_action_successor' }); - - fixture.replaceLeaseResult({ ok: false, reason: 'holder_terminal' }); - const result = await fixture.processor.retryFailedTarget( - fixture.entry.threadId, - fixture.entry.userId, - fixture.entry.id, - TARGET_CAT_ID, - fixture.failedAttempt.id, - fixture.commitAuthority, - ); - - assert.deepEqual(result, { outcome: 'authority_stale', reason: 'holder_terminal' }); - assertRetryWasNotMinted(fixture); - }); - - test('containing-task replacement immediately before authority commit cannot mint the custody attempt', async () => { - const ownerFence = { kind: 'containing_task', generation: 4 }; - const fixture = await failedWaitFixture({ ownerFence, leaseResult: { ok: true, reason: 'active' } }); - let revoked = false; - fixture.setBeforeCommit(() => { - if (!revoked) { - revoked = true; - fixture.replaceTask( - waitTask( - { kind: 'containing_task', generation: 5 }, - { - automationState: { - waitOutcome: waitOutcome({ kind: 'containing_task', generation: 5 }, { generation: 5 }), - }, - }, - ), - ); - } - }); - - const result = await fixture.processor.retryFailedTarget( - fixture.entry.threadId, - fixture.entry.userId, - fixture.entry.id, - TARGET_CAT_ID, - fixture.failedAttempt.id, - fixture.commitAuthority, - ); - - assert.equal(revoked, true, 'the deterministic race must revoke immediately before the custody CAS'); - assert.deepEqual(result, { outcome: 'authority_stale', reason: 'outcome_mismatch' }); - assertRetryWasNotMinted(fixture); - }); - - test('action-successor terminalization immediately before authority commit cannot mint the custody attempt', async () => { - const ownerFence = { kind: 'action_successor', leaseId: 'lease-1', generation: 7 }; - const fixture = await failedWaitFixture({ ownerFence, leaseResult: { ok: true, reason: 'active' } }); - let revoked = false; - fixture.setBeforeCommit(() => { - if (!revoked) { - revoked = true; - fixture.replaceLeaseResult({ ok: false, reason: 'holder_terminal' }); - } - }); - - const result = await fixture.processor.retryFailedTarget( - fixture.entry.threadId, - fixture.entry.userId, - fixture.entry.id, - TARGET_CAT_ID, - fixture.failedAttempt.id, - fixture.commitAuthority, - ); - - assert.equal(revoked, true, 'the deterministic race must revoke immediately before the custody CAS'); - assert.deepEqual(result, { outcome: 'authority_stale', reason: 'holder_terminal' }); - assertRetryWasNotMinted(fixture); - }); -}); diff --git a/packages/api/test/wait-continuation-retry-preflight.test.js b/packages/api/test/wait-continuation-retry-preflight.test.js deleted file mode 100644 index 735650d6e2..0000000000 --- a/packages/api/test/wait-continuation-retry-preflight.test.js +++ /dev/null @@ -1,176 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { WaitContinuationRetryPreflight } from '../dist/domains/ball-custody/WaitContinuationRetryPreflight.js'; - -const outcome = (ownerFence, overrides = {}) => ({ - v: 1, - outcomeId: 'wait:pr:zts212653/cat-cafe#1:g4:matched', - generation: 4, - subjectRef: 'pr:zts212653/cat-cafe#1', - ownerFence, - reason: 'matched', - at: 1_000, - delivery: 'delivered', - nextStep: 'continue exact work', - ...overrides, -}); - -const task = (waitOutcome, overrides = {}) => ({ - id: 'task-wait-1', - kind: 'pr_tracking', - threadId: 'thread-1', - subjectKey: 'pr:zts212653/cat-cafe#1', - title: 'wait', - ownerCatId: 'codex-sol', - status: 'done', - why: 'test', - createdBy: 'codex-sol', - createdAt: 1, - updatedAt: 2, - userId: 'user-1', - automationState: { waitOutcome }, - ...overrides, -}); - -const waitMessage = (ownerFence, overrides = {}) => ({ - catId: 'system', - threadId: 'thread-1', - userId: 'user-1', - source: { - connector: 'github-wait', - meta: { - waitContinuationCarrier: { - v: 1, - waitId: 'task-wait-1', - outcomeId: 'wait:pr:zts212653/cat-cafe#1:g4:matched', - ownerFence, - }, - }, - }, - ...overrides, -}); - -const createPreflight = ({ storedTask, leaseResult } = {}) => { - const leaseCalls = []; - return { - leaseCalls, - service: new WaitContinuationRetryPreflight({ - taskStore: { get: async () => storedTask ?? null }, - actionSuccessorLeaseStore: { - preflightOutput: async (...args) => { - leaseCalls.push(args); - return leaseResult ?? { ok: true, reason: 'active' }; - }, - }, - }), - }; -}; - -const request = (message, overrides = {}) => ({ - message, - requestingUserId: 'user-1', - targetCatId: 'codex-sol', - ...overrides, -}); - -describe('Gate 5 wait continuation retry preflight', () => { - test('accepts an authenticated ordinary user message without a synthetic action fence', async () => { - const { service } = createPreflight(); - assert.deepEqual(await service.preflight(request({ catId: null, threadId: 'thread-1', userId: 'user-1' })), { - ok: true, - kind: 'user', - }); - }); - - test('accepts only the exact containing-task outcome, generation, owner, thread, and user', async () => { - const fence = { kind: 'containing_task', generation: 4 }; - const exactTask = task(outcome(fence)); - const { service } = createPreflight({ storedTask: exactTask }); - assert.deepEqual(await service.preflight(request(waitMessage(fence))), { - ok: true, - kind: 'wait_containing_task', - }); - - for (const [label, mutatedTask, expectedReason] of [ - ['missing task', null, 'task_missing'], - ['foreign user', task(outcome(fence), { userId: 'user-2' }), 'task_identity_mismatch'], - ['foreign thread', task(outcome(fence), { threadId: 'thread-2' }), 'task_identity_mismatch'], - ['foreign owner', task(outcome(fence), { ownerCatId: 'opus' }), 'owner_mismatch'], - ['different outcome', task(outcome(fence, { outcomeId: 'other-outcome' })), 'outcome_mismatch'], - [ - 'different generation', - task(outcome({ kind: 'containing_task', generation: 5 }, { generation: 5 })), - 'outcome_mismatch', - ], - ]) { - const candidate = createPreflight({ storedTask: mutatedTask }).service; - assert.deepEqual( - await candidate.preflight(request(waitMessage(fence))), - { ok: false, reason: expectedReason }, - label, - ); - } - }); - - test('requires an active exact action-successor generation assigned to the target', async () => { - const fence = { kind: 'action_successor', leaseId: 'lease-1', generation: 7 }; - const exactTask = task(outcome(fence, { generation: 4 })); - const { service, leaseCalls } = createPreflight({ storedTask: exactTask }); - assert.deepEqual(await service.preflight(request(waitMessage(fence))), { - ok: true, - kind: 'wait_action_successor', - }); - assert.deepEqual(leaseCalls, [['lease-1', 7, 'codex-sol']]); - }); - - test('maps every action-successor denial without mutating or downgrading to generic execution', async () => { - const fence = { kind: 'action_successor', leaseId: 'lease-1', generation: 7 }; - const exactTask = task(outcome(fence)); - for (const reason of [ - 'lease_missing', - 'stale_generation', - 'lease_not_active', - 'holder_not_assigned', - 'holder_terminal', - 'subject_terminal', - ]) { - const { service } = createPreflight({ storedTask: exactTask, leaseResult: { ok: false, reason } }); - assert.deepEqual(await service.preflight(request(waitMessage(fence))), { ok: false, reason }); - } - - const { service } = createPreflight({ - storedTask: exactTask, - leaseResult: { ok: true, reason: 'verified_success' }, - }); - assert.deepEqual(await service.preflight(request(waitMessage(fence))), { ok: false, reason: 'holder_terminal' }); - }); - - test('fails closed for malformed github-wait and unattributed agent or connector messages', async () => { - const { service } = createPreflight(); - assert.deepEqual( - await service.preflight( - request({ - ...waitMessage({ kind: 'containing_task', generation: 4 }), - source: { connector: 'github-wait', meta: { waitContinuationCarrier: { v: 1 } } }, - }), - ), - { ok: false, reason: 'invalid_wait_carrier' }, - ); - assert.deepEqual(await service.preflight(request({ catId: 'opus', threadId: 'thread-1', userId: 'user-1' })), { - ok: false, - reason: 'legacy_unattributed', - }); - assert.deepEqual( - await service.preflight( - request({ catId: null, threadId: 'thread-1', userId: 'system' }, { requestingUserId: 'system' }), - ), - { ok: false, reason: 'legacy_unattributed' }, - ); - assert.deepEqual( - await service.preflight( - request({ catId: 'system', threadId: 'thread-1', userId: 'user-1', source: { connector: 'github' } }), - ), - { ok: false, reason: 'legacy_unattributed' }, - ); - }); -}); diff --git a/packages/api/test/wake-cat-impl.test.js b/packages/api/test/wake-cat-impl.test.js index 5dd48bfd3d..5227049ab0 100644 --- a/packages/api/test/wake-cat-impl.test.js +++ b/packages/api/test/wake-cat-impl.test.js @@ -20,13 +20,13 @@ function createMockDeps() { }, }, invocationQueue: { - enqueue(input) { + async enqueueDurable(input) { enqueuedEntries.push(input); return enqueueResult; }, }, queueProcessor: { - async tryAutoExecute(threadId) { + async requestDrain(threadId) { autoExecuteCalls.push(threadId); }, }, @@ -55,8 +55,9 @@ describe('createWakeCatFn', () => { const entry = enqueuedEntries[0]; assert.equal(entry.threadId, 'thread-game-1'); assert.equal(entry.userId, 'user-operator'); + assert.equal(entry.kind, 'private_input'); assert.equal(entry.content, 'You are wolf.'); - assert.equal(entry.source, 'agent'); + assert.deepEqual(entry.from, { kind: 'system', service: 'game-orchestrator' }); assert.deepEqual(entry.targetCats, ['opus']); assert.equal(entry.intent, 'execute'); assert.equal(entry.autoExecute, true); diff --git a/packages/api/test/web-digest-template.test.js b/packages/api/test/web-digest-template.test.js index 8e089d0f8d..6364216507 100644 --- a/packages/api/test/web-digest-template.test.js +++ b/packages/api/test/web-digest-template.test.js @@ -86,7 +86,7 @@ describe('webDigestTemplate', () => { assert.equal(deliverMock.mock.calls.length, 1); const delivered = deliverMock.mock.calls[0].arguments[0]; assert.equal(delivered.threadId, 'th-2'); - assert.equal(delivered.userId, 'scheduler'); + assert.equal(delivered.userId, 'default-user'); assert.ok(delivered.content.includes('browser-automation')); assert.ok(delivered.content.includes('https://x.com/user')); assert.ok(delivered.content.includes('AI')); diff --git a/packages/api/test/web-outbound-delivery.test.js b/packages/api/test/web-outbound-delivery.test.js deleted file mode 100644 index f3a4cefdda..0000000000 --- a/packages/api/test/web-outbound-delivery.test.js +++ /dev/null @@ -1,560 +0,0 @@ -import assert from 'node:assert/strict'; -import { beforeEach, describe, it } from 'node:test'; -import { cleanupStreamingOnFailure, deliverOutboundFromWeb } from '../dist/routes/messages.js'; - -function noopLog() { - const noop = () => {}; - return { info: noop, warn: noop, error: noop, debug: noop, trace: noop, fatal: noop, child: () => noopLog() }; -} - -function makeOpts(overrides = {}) { - return { - registry: {}, - messageStore: {}, - socketManager: { broadcastAgentMessage() {}, broadcastToRoom() {} }, - router: {}, - ...overrides, - }; -} - -describe('deliverOutboundFromWeb (F088 ISSUE-15)', () => { - let deliverCalls; - let streamCalls; - let mockOutboundHook; - let mockStreamingHook; - - beforeEach(() => { - deliverCalls = []; - streamCalls = { start: [], chunk: [], end: [], cleanup: [], catching: [], blocked: [] }; - - mockOutboundHook = { - async deliver(threadId, content, catId, richBlocks, threadMeta) { - deliverCalls.push({ threadId, content, catId, richBlocks, threadMeta }); - }, - }; - - mockStreamingHook = { - async onStreamStart(threadId, catId, invocationId) { - streamCalls.start.push({ threadId, catId, invocationId }); - }, - async onStreamChunk(threadId, text, invocationId) { - streamCalls.chunk.push({ threadId, text, invocationId }); - }, - async onStreamEnd(threadId, text, invocationId) { - streamCalls.end.push({ threadId, text, invocationId }); - }, - async cleanupPlaceholders(threadId, invocationId) { - streamCalls.cleanup.push({ threadId, invocationId }); - }, - async onClosureCatchingUp(threadId, catId, invocationId) { - streamCalls.catching.push({ threadId, catId, invocationId }); - }, - async onClosureBlocked(threadId, catId, reason, invocationId, recoveryUrl) { - streamCalls.blocked.push({ threadId, catId, reason, invocationId, recoveryUrl }); - }, - }; - }); - - it('delivers single-turn text to outbound hook', async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook }); - const turns = [{ catId: 'opus', textParts: ['Hello ', 'world'] }]; - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['Hello ', 'world'], turns, ctx, undefined, opts, noopLog()); - - assert.equal(deliverCalls.length, 1); - assert.equal(deliverCalls[0].threadId, 't-1'); - assert.equal(deliverCalls[0].content, 'Hello world'); - assert.equal(deliverCalls[0].catId, 'opus'); - }); - - it('delivers multi-turn text per-cat', async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook }); - const turns = [ - { catId: 'opus', textParts: ['First cat'] }, - { catId: 'codex', textParts: ['Second cat'] }, - ]; - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb( - 't-1', - 'opus', - 'inv-1', - ['First cat', 'Second cat'], - turns, - ctx, - undefined, - opts, - noopLog(), - ); - - assert.equal(deliverCalls.length, 2); - assert.equal(deliverCalls[0].content, 'First cat'); - assert.equal(deliverCalls[0].catId, 'opus'); - assert.equal(deliverCalls[1].content, 'Second cat'); - assert.equal(deliverCalls[1].catId, 'codex'); - }); - - it('F254 Phase E never delivers a superseded draft to connector hooks', async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook, streamingHook: mockStreamingHook }); - const turns = [{ catId: 'opus', textParts: ['known-stale answer'] }]; - const ctx = { - failed: false, - errors: [], - outputCommitDecisions: { - opus: { - kind: 'superseded_positive_stale', - closureId: 'closure-1', - requiredFrontierMessageId: 'msg-2', - }, - }, - }; - - await deliverOutboundFromWeb( - 't-1', - 'opus', - 'inv-1', - ['known-stale answer'], - turns, - ctx, - undefined, - opts, - noopLog(), - ); - - assert.equal(deliverCalls.length, 0); - assert.deepEqual(streamCalls.end, []); - assert.deepEqual(streamCalls.catching, [{ threadId: 't-1', catId: 'opus', invocationId: 'inv-1' }]); - assert.equal(streamCalls.cleanup.length, 0, 'catching-up placeholder must survive a superseded attempt'); - }); - - it('ADR-042 delivers a published-with-unseen answer and closes its stream normally', async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook, streamingHook: mockStreamingHook }); - const ctx = { - failed: false, - errors: [], - outputCommitDecisions: { - opus: { - kind: 'published_with_unseen', - messageId: 'msg-published', - lineageId: 'msg-published', - offeredSupplementId: 'f254-supplement:msg-published:1', - requiredFrontierMessageId: 'msg-newer', - }, - }, - }; - - await deliverOutboundFromWeb( - 't-1', - 'opus', - 'inv-1', - ['published answer'], - [{ catId: 'opus', textParts: ['published answer'] }], - ctx, - undefined, - opts, - noopLog(), - ); - - assert.deepEqual( - deliverCalls.map(({ content, catId }) => ({ content, catId })), - [{ content: 'published answer', catId: 'opus' }], - ); - assert.deepEqual(streamCalls.end, [{ threadId: 't-1', text: 'published answer', invocationId: 'inv-1' }]); - assert.equal(streamCalls.cleanup.length, 1); - assert.deepEqual(streamCalls.catching, []); - }); - - it('F254 Phase E projects a blocked output as terminal connector state', async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook, streamingHook: mockStreamingHook }); - const ctx = { - failed: false, - errors: [], - outputCommitDecisions: { - opus: { kind: 'blocked_known_closure', closureId: 'closure-1', reason: 'commit_recheck_exhausted' }, - }, - }; - - await deliverOutboundFromWeb( - 't-1', - 'opus', - 'inv-1', - ['blocked draft'], - [{ catId: 'opus', textParts: ['blocked draft'] }], - ctx, - undefined, - opts, - noopLog(), - ); - - assert.equal(deliverCalls.length, 0); - assert.deepEqual(streamCalls.blocked, [ - { - threadId: 't-1', - catId: 'opus', - reason: 'commit_recheck_exhausted', - invocationId: 'inv-1', - recoveryUrl: undefined, - }, - ]); - }); - - for (const scenario of [ - { - name: 'catching-up', - decision: { - kind: 'superseded_positive_stale', - closureId: 'closure-codex', - requiredFrontierMessageId: 'msg-newer', - }, - projectedCalls: 'catching', - }, - { - name: 'blocked', - decision: { - kind: 'blocked_known_closure', - closureId: 'closure-codex', - reason: 'attempt_budget_exhausted', - }, - projectedCalls: 'blocked', - }, - ]) { - it(`F254 Phase E projects a non-primary ${scenario.name} output under its owner cat`, async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook, streamingHook: mockStreamingHook }); - const ctx = { - failed: false, - errors: [], - outputCommitDecisions: { codex: scenario.decision }, - }; - - await deliverOutboundFromWeb( - 't-1', - 'opus', - 'inv-multi', - ['withheld codex draft'], - [{ catId: 'codex', textParts: ['withheld codex draft'] }], - ctx, - undefined, - opts, - noopLog(), - ); - - assert.equal(streamCalls[scenario.projectedCalls].length, 1); - assert.equal(streamCalls[scenario.projectedCalls][0].catId, 'codex'); - }); - } - - it('uses turn-based aggregate for stream end when a later turn was replace-mode rewritten', async () => { - const opts = makeOpts({ - outboundHook: mockOutboundHook, - streamingHook: mockStreamingHook, - }); - const turns = [ - { catId: 'opus', textParts: ['First cat. '] }, - { catId: 'antigravity', textParts: ['Second cat rewritten.'] }, - ]; - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb( - 't-1', - 'opus', - 'inv-1', - ['Second cat rewritten.'], - turns, - ctx, - undefined, - opts, - noopLog(), - ); - - assert.equal(streamCalls.end.length, 1); - assert.equal(streamCalls.end[0].text, 'First cat. Second cat rewritten.'); - }); - - it('does not call deliver when outboundHook is absent', async () => { - const opts = makeOpts(); - const turns = [{ catId: 'opus', textParts: ['test'] }]; - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['test'], turns, ctx, undefined, opts, noopLog()); - assert.equal(deliverCalls.length, 0); - }); - - it('does not call deliver when no content collected', async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook }); - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb('t-1', 'opus', 'inv-1', [], [], ctx, undefined, opts, noopLog()); - assert.equal(deliverCalls.length, 0); - }); - - it('includes richBlocks in delivery', async () => { - const opts = makeOpts({ outboundHook: mockOutboundHook }); - const blocks = [{ id: 'rb1', kind: 'card' }]; - const turns = [{ catId: 'opus', textParts: ['msg'], richBlocks: blocks }]; - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['msg'], turns, ctx, undefined, opts, noopLog()); - - assert.equal(deliverCalls.length, 1); - assert.deepEqual(deliverCalls[0].richBlocks, blocks); - }); - - it('calls streaming lifecycle: start → end → cleanup', async () => { - const opts = makeOpts({ - outboundHook: mockOutboundHook, - streamingHook: mockStreamingHook, - }); - const turns = [{ catId: 'opus', textParts: ['hi'] }]; - const ctx = { failed: false, errors: [] }; - const startPromise = mockStreamingHook.onStreamStart('t-1', 'opus', 'inv-1'); - - await deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['hi'], turns, ctx, startPromise, opts, noopLog()); - - assert.equal(streamCalls.end.length, 1); - assert.equal(streamCalls.end[0].text, 'hi'); - assert.equal(streamCalls.cleanup.length, 1); - assert.equal(streamCalls.cleanup[0].threadId, 't-1'); - }); - - it('cleans up placeholders even when no outbound hook (stream-only)', async () => { - const opts = makeOpts({ streamingHook: mockStreamingHook }); - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb( - 't-1', - 'opus', - 'inv-1', - ['text'], - [{ catId: 'opus', textParts: ['text'] }], - ctx, - undefined, - opts, - noopLog(), - ); - - assert.equal(streamCalls.end.length, 1); - assert.equal(streamCalls.cleanup.length, 1); - }); - - it('does not throw when outbound deliver fails', async () => { - const failHook = { - async deliver() { - throw new Error('network error'); - }, - }; - const opts = makeOpts({ outboundHook: failHook }); - const turns = [{ catId: 'opus', textParts: ['test'] }]; - const ctx = { failed: false, errors: [] }; - - await assert.doesNotReject( - deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['test'], turns, ctx, undefined, opts, noopLog()), - ); - }); - - it('does not throw when streaming hook fails', async () => { - const failStreamHook = { - async onStreamStart() { - throw new Error('boom'); - }, - async onStreamChunk() { - throw new Error('boom'); - }, - async onStreamEnd() { - throw new Error('boom'); - }, - async cleanupPlaceholders() { - throw new Error('boom'); - }, - }; - const opts = makeOpts({ - outboundHook: mockOutboundHook, - streamingHook: failStreamHook, - }); - const turns = [{ catId: 'opus', textParts: ['msg'] }]; - const ctx = { failed: false, errors: [] }; - const failedStart = failStreamHook.onStreamStart('t-1', 'opus', 'inv-1').catch(() => {}); - - await assert.doesNotReject( - deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['msg'], turns, ctx, failedStart, opts, noopLog()), - ); - }); - - it('resolves threadMeta from threadStore when available', async () => { - const mockThreadStore = { - get(id) { - return Promise.resolve({ id, title: 'Test Thread' }); - }, - }; - const opts = makeOpts({ - outboundHook: mockOutboundHook, - threadStore: mockThreadStore, - }); - const turns = [{ catId: 'opus', textParts: ['hello'] }]; - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['hello'], turns, ctx, undefined, opts, noopLog()); - - assert.equal(deliverCalls.length, 1); - assert.ok(deliverCalls[0].threadMeta); - assert.equal(deliverCalls[0].threadMeta.threadTitle, 'Test Thread'); - assert.match(deliverCalls[0].threadMeta.deepLinkUrl, /\/thread\/t-1$/); - assert.ok(!deliverCalls[0].threadMeta.deepLinkUrl.includes('/threads/')); - }); -}); - -describe('cleanupStreamingOnFailure (P1 regression)', () => { - it('calls onStreamEnd + cleanupPlaceholders on failure path', async () => { - const calls = { end: [], cleanup: [] }; - const hook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd(threadId, text, invocationId) { - calls.end.push({ threadId, text, invocationId }); - }, - async cleanupPlaceholders(threadId, invocationId) { - calls.cleanup.push({ threadId, invocationId }); - }, - }; - const opts = makeOpts({ streamingHook: hook }); - - await cleanupStreamingOnFailure('t-1', 'inv-1', undefined, opts, noopLog()); - - assert.equal(calls.end.length, 1); - assert.equal(calls.end[0].text, ''); - assert.equal(calls.cleanup.length, 1); - assert.equal(calls.cleanup[0].threadId, 't-1'); - }); - - it('waits for streamStartPromise before ending', async () => { - let startResolved = false; - const calls = { end: [] }; - const hook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd(threadId, text, invocationId) { - calls.end.push({ startResolved }); - }, - async cleanupPlaceholders() {}, - }; - const opts = makeOpts({ streamingHook: hook }); - const startPromise = new Promise((resolve) => { - setTimeout(() => { - startResolved = true; - resolve(); - }, 10); - }); - - await cleanupStreamingOnFailure('t-1', 'inv-1', startPromise, opts, noopLog()); - - assert.equal(calls.end.length, 1); - assert.equal(calls.end[0].startResolved, true); - }); - - it('is a no-op when streamingHook is absent', async () => { - const opts = makeOpts(); - await assert.doesNotReject(cleanupStreamingOnFailure('t-1', 'inv-1', undefined, opts, noopLog())); - }); - - it('does not throw when streaming hooks fail', async () => { - const hook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd() { - throw new Error('boom'); - }, - async cleanupPlaceholders() { - throw new Error('boom'); - }, - }; - const opts = makeOpts({ streamingHook: hook }); - - await assert.doesNotReject(cleanupStreamingOnFailure('t-1', 'inv-1', undefined, opts, noopLog())); - }); -}); - -describe('deliverOutboundFromWeb fire-and-forget safety (P2 regression)', () => { - it('handles internal errors gracefully when called fire-and-forget', async () => { - const failHook = { - async deliver() { - throw new Error('connector down'); - }, - }; - const opts = makeOpts({ outboundHook: failHook }); - const turns = [{ catId: 'opus', textParts: ['msg'] }]; - const ctx = { failed: false, errors: [] }; - - const promise = deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['msg'], turns, ctx, undefined, opts, noopLog()); - await assert.doesNotReject(promise); - }); - - it('completes without awaiting slow thread lookup', async () => { - let delivered = false; - const hook = { - async deliver() { - delivered = true; - }, - }; - const slowThreadStore = { - get() { - return new Promise((resolve) => setTimeout(() => resolve({ id: 't-1', title: 'slow' }), 50)); - }, - }; - const opts = makeOpts({ outboundHook: hook, threadStore: slowThreadStore }); - const turns = [{ catId: 'opus', textParts: ['msg'] }]; - const ctx = { failed: false, errors: [] }; - - await deliverOutboundFromWeb('t-1', 'opus', 'inv-1', ['msg'], turns, ctx, undefined, opts, noopLog()); - assert.equal(delivered, true); - }); -}); - -describe('cleanupStreamingOnFailure timeout alignment (P1-B regression)', () => { - it('waits up to 5s for slow streamStartPromise before cleaning up', async () => { - const calls = { end: [] }; - const hook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd(threadId, text, invocationId) { - calls.end.push({ threadId, text, invocationId }); - }, - async cleanupPlaceholders() {}, - }; - const opts = makeOpts({ streamingHook: hook }); - - let resolveStart; - const startPromise = new Promise((resolve) => { - resolveStart = resolve; - }); - - const cleanupPromise = cleanupStreamingOnFailure('t-1', 'inv-1', startPromise, opts, noopLog()); - - // Resolve after 50ms (well within 5s timeout) — should be awaited - setTimeout(() => resolveStart(), 50); - await cleanupPromise; - - assert.equal(calls.end.length, 1, 'onStreamEnd should be called after startPromise resolves'); - }); - - it('times out and still cleans up if streamStartPromise never resolves', { timeout: 10_000 }, async () => { - const calls = { end: [], cleanup: [] }; - const hook = { - async onStreamStart() {}, - async onStreamChunk() {}, - async onStreamEnd(threadId, text, invocationId) { - calls.end.push({ threadId, text, invocationId }); - }, - async cleanupPlaceholders(threadId, invocationId) { - calls.cleanup.push({ threadId, invocationId }); - }, - }; - const opts = makeOpts({ streamingHook: hook }); - - // A promise that never resolves — cleanup should still proceed after timeout - const neverResolves = new Promise(() => {}); - await cleanupStreamingOnFailure('t-1', 'inv-1', neverResolves, opts, noopLog()); - - assert.equal(calls.end.length, 1, 'onStreamEnd should be called after timeout'); - assert.equal(calls.cleanup.length, 1, 'cleanupPlaceholders should be called after timeout'); - }); -}); diff --git a/packages/api/test/whisper-visibility.test.js b/packages/api/test/whisper-visibility.test.js index 7c8931186d..7223374b8f 100644 --- a/packages/api/test/whisper-visibility.test.js +++ b/packages/api/test/whisper-visibility.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, test } from 'node:test'; import './helpers/setup-cat-registry.js'; +import { canonicalTestMessageInput } from './helpers/message-from-fixtures.js'; describe('canViewMessage', () => { let canViewMessage; @@ -60,60 +61,70 @@ describe('MessageStore whisper', () => { }); test('append stores visibility and whisperTo', () => { - const msg = store.append({ - userId: 'user1', - catId: null, - content: 'secret message', - mentions: ['opus'], - timestamp: Date.now(), - threadId: 'thread1', - visibility: 'whisper', - whisperTo: ['opus'], - }); + const msg = store.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'secret message', + mentions: ['opus'], + timestamp: Date.now(), + threadId: 'thread1', + visibility: 'whisper', + whisperTo: ['opus'], + }), + ); assert.equal(msg.visibility, 'whisper'); assert.deepEqual(msg.whisperTo, ['opus']); }); test('revealWhispers sets revealedAt on all whispers in thread', () => { - store.append({ - userId: 'user1', - catId: null, - content: 'public msg', - mentions: [], - timestamp: 1000, - threadId: 'thread1', - }); - store.append({ - userId: 'user1', - catId: null, - content: 'whisper 1', - mentions: ['opus'], - timestamp: 2000, - threadId: 'thread1', - visibility: 'whisper', - whisperTo: ['opus'], - }); - store.append({ - userId: 'user1', - catId: null, - content: 'whisper 2', - mentions: ['codex'], - timestamp: 3000, - threadId: 'thread1', - visibility: 'whisper', - whisperTo: ['codex'], - }); - store.append({ - userId: 'user1', - catId: null, - content: 'other thread whisper', - mentions: ['opus'], - timestamp: 4000, - threadId: 'thread2', - visibility: 'whisper', - whisperTo: ['opus'], - }); + store.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'public msg', + mentions: [], + timestamp: 1000, + threadId: 'thread1', + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'whisper 1', + mentions: ['opus'], + timestamp: 2000, + threadId: 'thread1', + visibility: 'whisper', + whisperTo: ['opus'], + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'whisper 2', + mentions: ['codex'], + timestamp: 3000, + threadId: 'thread1', + visibility: 'whisper', + whisperTo: ['codex'], + }), + ); + store.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'other thread whisper', + mentions: ['opus'], + timestamp: 4000, + threadId: 'thread2', + visibility: 'whisper', + whisperTo: ['opus'], + }), + ); const count = store.revealWhispers('thread1', 'user1'); assert.equal(count, 2); @@ -134,16 +145,18 @@ describe('MessageStore whisper', () => { }); test('revealWhispers is idempotent', () => { - store.append({ - userId: 'user1', - catId: null, - content: 'whisper', - mentions: ['opus'], - timestamp: 1000, - threadId: 'thread1', - visibility: 'whisper', - whisperTo: ['opus'], - }); + store.append( + canonicalTestMessageInput({ + userId: 'user1', + catId: null, + content: 'whisper', + mentions: ['opus'], + timestamp: 1000, + threadId: 'thread1', + visibility: 'whisper', + whisperTo: ['opus'], + }), + ); assert.equal(store.revealWhispers('thread1', 'user1'), 1); assert.equal(store.revealWhispers('thread1', 'user1'), 0); // already revealed diff --git a/packages/api/test/worklist-registry-f121.test.js b/packages/api/test/worklist-registry-f121.test.js deleted file mode 100644 index ff952459e3..0000000000 --- a/packages/api/test/worklist-registry-f121.test.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * F121: WorklistRegistry a2aTriggerMessageId tests - * Split from worklist-registry.test.js to stay under 350-line cap. - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -describe('WorklistRegistry: a2aTriggerMessageId (F121)', () => { - test('a2aTriggerMessageId tracks trigger message through pushToWorklist', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-trigger-msg'; - const entry = registerWorklist(threadId, ['opus'], 10); - - try { - // 1. First push: opus @mentions codex with msg-A - const push1 = pushToWorklist(threadId, ['codex'], 'opus', undefined, 'msg-A'); - assert.deepEqual(push1.added, ['codex']); - assert.equal(entry.a2aTriggerMessageId.get('codex'), 'msg-A'); - assert.equal(entry.a2aFrom.get('codex'), 'opus'); - - // 2. Advance to codex executing, then codex @mentions sonnet with msg-B - entry.executedIndex = 1; - const push2 = pushToWorklist(threadId, ['sonnet'], 'codex', undefined, 'msg-B'); - assert.deepEqual(push2.added, ['sonnet']); - assert.equal(entry.a2aTriggerMessageId.get('sonnet'), 'msg-B'); - - // 3. Duplicate re-enqueue: codex @mentions sonnet AGAIN with msg-C - // sonnet is already pending (non-original) → should update to latest - const push3 = pushToWorklist(threadId, ['sonnet'], 'codex', undefined, 'msg-C'); - assert.deepEqual(push3.added, []); - assert.equal( - entry.a2aTriggerMessageId.get('sonnet'), - 'msg-C', - 'Duplicate re-enqueue must update triggerMessageId to latest', - ); - assert.equal(entry.a2aFrom.get('sonnet'), 'codex'); - - // 4. Re-mention A2A-added pending target with new message - entry.executedIndex = 0; - pushToWorklist(threadId, ['codex'], 'opus', undefined, 'msg-D'); - // codex was A2A-added (not original) → should update - assert.equal( - entry.a2aTriggerMessageId.get('codex'), - 'msg-D', - 'A2A-added pending target should update triggerMessageId on re-mention', - ); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('response-text path: direct a2aTriggerMessageId.set on worklist entry', async () => { - // This mirrors what route-serial.ts does for response-text @mentions: - // it directly sets a2aTriggerMessageId on the entry (not via pushToWorklist). - const { registerWorklist, unregisterWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-response-text-trigger'; - const entry = registerWorklist(threadId, ['opus', 'codex'], 10); - - try { - // Simulate: opus runs, its stored message has id 'opus-msg-1', - // and its response text contains @codex. - // route-serial directly sets the trigger message on the entry. - const storedMsgId = 'opus-msg-1'; - entry.a2aTriggerMessageId.set('codex', storedMsgId); - entry.a2aFrom.set('codex', 'opus'); - - assert.equal(entry.a2aTriggerMessageId.get('codex'), 'opus-msg-1'); - assert.equal(entry.a2aFrom.get('codex'), 'opus'); - - // Simulate: opus also @mentions sonnet (new cat, pushed to worklist) - entry.a2aTriggerMessageId.set('sonnet', storedMsgId); - entry.a2aFrom.set('sonnet', 'opus'); - assert.equal(entry.a2aTriggerMessageId.get('sonnet'), 'opus-msg-1'); - - // Original target (opus) should NOT have a trigger message - assert.equal(entry.a2aTriggerMessageId.get('opus'), undefined); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('original pending target does NOT get triggerMessageId overwritten', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-trigger-msg-original'; - // Both opus and codex are original targets - const entry = registerWorklist(threadId, ['opus', 'codex'], 10); - - try { - // opus @mentions codex (codex is original pending) → should NOT set triggerMessageId - const push = pushToWorklist(threadId, ['codex'], 'opus', undefined, 'msg-X'); - assert.deepEqual(push.added, []); - assert.equal( - entry.a2aTriggerMessageId.get('codex'), - undefined, - 'Original pending target must NOT get triggerMessageId from re-mention', - ); - assert.equal(entry.a2aFrom.get('codex'), undefined, 'Original pending target keeps no a2aFrom'); - } finally { - unregisterWorklist(threadId, entry); - } - }); -}); diff --git a/packages/api/test/worklist-registry-f122.test.js b/packages/api/test/worklist-registry-f122.test.js deleted file mode 100644 index c3c822b7e0..0000000000 --- a/packages/api/test/worklist-registry-f122.test.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * F122: pushToWorklist returns structured PushResult (AC-A2) - * Split from worklist-registry.test.js to stay within 350-line file cap. - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -describe('WorklistRegistry: PushResult structured reason (F122)', () => { - test('not_found reason when no worklist registered', async () => { - const { pushToWorklist } = await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); - const result = pushToWorklist('nonexistent-thread-f122', ['opus']); - assert.deepEqual(result.added, []); - assert.equal(result.reason, 'not_found'); - }); - - test('depth_limit reason when maxDepth exceeded', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const threadId = 'test-f122-depth'; - const entry = registerWorklist(threadId, ['opus'], 0); // maxDepth=0 - try { - const result = pushToWorklist(threadId, ['codex']); - assert.deepEqual(result.added, []); - assert.equal(result.reason, 'depth_limit'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('caller_mismatch reason when caller is not current cat', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const threadId = 'test-f122-caller'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const result = pushToWorklist(threadId, ['codex'], 'gemini'); // gemini != opus - assert.deepEqual(result.added, []); - assert.equal(result.reason, 'caller_mismatch'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('caller_admission_closed reason after the route seals the current turn', async () => { - const { registerWorklist, setWorklistCallerAdmissionOpen, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const threadId = 'test-f122-caller-admission-closed'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - setWorklistCallerAdmissionOpen(entry, false); - const result = pushToWorklist(threadId, ['codex'], 'opus'); - assert.deepEqual(result.added, []); - assert.equal(result.reason, 'caller_admission_closed'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('all_duplicate reason when all cats already pending', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const threadId = 'test-f122-dup'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const result = pushToWorklist(threadId, ['opus']); // already in list - assert.deepEqual(result.added, []); - assert.equal(result.reason, 'all_duplicate'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('success has no reason', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - const threadId = 'test-f122-success'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const result = pushToWorklist(threadId, ['codex']); - assert.deepEqual(result.added, ['codex']); - assert.equal(result.reason, undefined); - } finally { - unregisterWorklist(threadId, entry); - } - }); -}); diff --git a/packages/api/test/worklist-registry-streak.test.js b/packages/api/test/worklist-registry-streak.test.js deleted file mode 100644 index b6bd360c55..0000000000 --- a/packages/api/test/worklist-registry-streak.test.js +++ /dev/null @@ -1,415 +0,0 @@ -/** - * F167 L1: Ping-pong streak tracking on WorklistRegistry. - * - * streak = 连续 same-pair(不区分方向)在 pushToWorklist 上的 push 次数。 - * streak >= 2 → warnPingPong - * streak >= 4 → blockPingPong(不加入 list) - * 不同 pair 的 push / 第三只猫插入 / resetStreak → 重置为 {new pair, count=1} 或空 - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -async function loadRegistry() { - return await import('../dist/domains/cats/services/agents/routing/WorklistRegistry.js'); -} - -describe('F167 L1: WorklistRegistry ping-pong streak', () => { - test('single push with caller = streak starts at 1, no warn', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-1push'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const result = pushToWorklist(threadId, ['codex'], 'opus'); - assert.deepEqual(result.added, ['codex']); - assert.ok(!result.warnPingPong, 'first push must not warn'); - assert.ok(!result.blockPingPong, 'first push must not block'); - assert.ok(entry.streakPair, 'streakPair must be populated after first push'); - assert.equal(entry.streakPair.count, 1); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('A→B then B→A (same pair reversed): streak=2 → warnPingPong', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-warn'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - pushToWorklist(threadId, ['codex'], 'opus'); // push 1: opus→codex, streak=1 - entry.executedIndex = 1; // now codex is current - const result = pushToWorklist(threadId, ['opus'], 'codex'); // push 2: codex→opus, streak=2 - assert.deepEqual(result.added, ['opus']); - assert.ok(result.warnPingPong, 'streak=2 must trigger warnPingPong'); - assert.ok(!result.blockPingPong, 'streak=2 must NOT block yet'); - assert.equal(entry.streakPair.count, 2); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('A↔B × 4 rounds: streak=4 → blockPingPong + added=[]', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-block'; - const entry = registerWorklist(threadId, ['opus'], 20); - try { - // Round 1: opus→codex - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - // Round 2: codex→opus (warn) - pushToWorklist(threadId, ['opus'], 'codex'); - entry.executedIndex = 2; - // Round 3: opus→codex (still warn) - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 3; - // Round 4: codex→opus → BLOCK - const result = pushToWorklist(threadId, ['opus'], 'codex'); - assert.deepEqual(result.added, [], 'streak=4 must not enqueue'); - assert.ok(result.blockPingPong, 'streak=4 must set blockPingPong'); - assert.equal(result.reason, 'pingpong_terminated'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('different pair resets streak (A→B then A→C)', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-diff-pair'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - pushToWorklist(threadId, ['codex'], 'opus'); // {opus,codex}=1 - const result = pushToWorklist(threadId, ['gemini'], 'opus'); // {opus,gemini}=1 (reset) - assert.deepEqual(result.added, ['gemini']); - assert.ok(!result.warnPingPong, 'new pair must reset to 1 (no warn)'); - assert.equal(entry.streakPair.count, 1); - assert.ok( - (entry.streakPair.from === 'opus' && entry.streakPair.to === 'gemini') || - (entry.streakPair.from === 'gemini' && entry.streakPair.to === 'opus'), - 'streakPair must be the latest pair {opus,gemini}', - ); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('third-cat injection resets streak (A↔B once, then C→A)', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-third-cat'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - pushToWorklist(threadId, ['codex'], 'opus'); // {opus,codex}=1 - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); // {opus,codex}=2, warn - // Now simulate a third cat (gemini) becoming the current cat via executedIndex advance - entry.list.push('gemini'); - entry.executedIndex = 3; // gemini now current — but we're skipping to show insertion - // Actually: third cat enters via being caller — gemini→opus is a different pair - const result = pushToWorklist(threadId, ['opus'], 'gemini'); - assert.ok(!result.warnPingPong, 'third-cat caller must reset streak'); - assert.equal(entry.streakPair.count, 1); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('resetStreak API zeros out streakPair (for user-message hook)', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist, resetStreak } = await loadRegistry(); - const threadId = 'test-streak-reset-api'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - pushToWorklist(threadId, ['codex'], 'opus'); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex'); - assert.equal(entry.streakPair.count, 2); // warn level - resetStreak(threadId); - assert.ok(!entry.streakPair || entry.streakPair.count === 0, 'resetStreak must clear streakPair'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('multi-target push (cats.length > 1) does not increment streak', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-multi-target'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - pushToWorklist(threadId, ['codex'], 'opus'); // streak=1 - entry.executedIndex = 1; - // codex→[opus, gemini] — fan-out, not ping-pong - const result = pushToWorklist(threadId, ['opus', 'gemini'], 'codex'); - assert.ok(!result.warnPingPong, 'multi-target push must not count as streak'); - // streakPair may reset or stay — key invariant: no warn from fan-out - assert.ok(!entry.streakPair || entry.streakPair.count <= 1, 'fan-out must not accumulate streak'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('push without callerCatId does not update streak (initial user→cat routing)', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-no-caller'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const result = pushToWorklist(threadId, ['codex']); // no caller = not A2A - assert.deepEqual(result.added, ['codex']); - assert.ok(!entry.streakPair, 'push without caller must not initialize streakPair'); - } finally { - unregisterWorklist(threadId, entry); - } - }); -}); - -/** - * F167 Phase D: isSubstantiveTool — tool_call 作为"干活"的客观信号。 - * - * 黑名单:cat_cafe_post_message / cat_cafe_multi_mention / cat_cafe_hold_ball - * 这三个是路由/持球本身(传球/持球工具),不算工作证据。 - * 其他所有 tool(文件 I/O、MCP task、search、git 等)都算实质工作。 - * - * 兼容 provider 前缀(mcp__cat-cafe__*)— substring 匹配。 - */ -describe('F167 Phase D: isSubstantiveTool helper', () => { - test('routing tools (post_message / multi_mention / hold_ball) are NOT substantive', async () => { - const { isSubstantiveTool } = await loadRegistry(); - assert.equal(isSubstantiveTool('cat_cafe_post_message'), false); - assert.equal(isSubstantiveTool('cat_cafe_multi_mention'), false); - assert.equal(isSubstantiveTool('cat_cafe_hold_ball'), false); - }); - - test('provider-prefixed routing tools are also NOT substantive', async () => { - const { isSubstantiveTool } = await loadRegistry(); - assert.equal(isSubstantiveTool('mcp__cat-cafe__cat_cafe_post_message'), false); - assert.equal(isSubstantiveTool('mcp__cat-cafe__cat_cafe_multi_mention'), false); - assert.equal(isSubstantiveTool('mcp__cat-cafe__cat_cafe_hold_ball'), false); - }); - - test('file/search/task tools are substantive (evidence of real work)', async () => { - const { isSubstantiveTool } = await loadRegistry(); - assert.equal(isSubstantiveTool('Read'), true); - assert.equal(isSubstantiveTool('Edit'), true); - assert.equal(isSubstantiveTool('Write'), true); - assert.equal(isSubstantiveTool('Grep'), true); - assert.equal(isSubstantiveTool('Bash'), true); - assert.equal(isSubstantiveTool('cat_cafe_update_task'), true); - assert.equal(isSubstantiveTool('cat_cafe_search_evidence'), true); - assert.equal(isSubstantiveTool('cat_cafe_create_task'), true); - }); - - test('empty string returns false (defensive)', async () => { - const { isSubstantiveTool } = await loadRegistry(); - assert.equal(isSubstantiveTool(''), false); - }); -}); - -/** - * F167 Phase D: updateStreakOnPush with callerActivity. - * - * streak 累加条件从"同 pair 连续次数"升级为"同 pair + 猫没干实质活 + 内容短"。 - * 客观坐标系: - * - hadSubstantiveToolCall=true → 干活(豁免) - * - outputLength > OUTPUT_LEN_T(200) → 讨论/设计(豁免) - * - 两者都否 → 纯语言惯性(streak++) - * - * 不传 activity 视为向后兼容——按旧逻辑累加(pushToWorklist 在 Task 3 之前还是不传)。 - */ -describe('F167 Phase D: updateStreakOnPush with callerActivity', () => { - test('substantive tool call every round → streak NOT accumulated (4 rounds, no block)', async () => { - const { registerWorklist, unregisterWorklist, updateStreakOnPush } = await loadRegistry(); - const threadId = 'test-streak-phaseD-substantive'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const activity = { hadSubstantiveToolCall: true, outputLength: 50 }; - updateStreakOnPush(entry, 'opus', 'codex', activity); - updateStreakOnPush(entry, 'codex', 'opus', activity); - updateStreakOnPush(entry, 'opus', 'codex', activity); - const r4 = updateStreakOnPush(entry, 'codex', 'opus', activity); - assert.equal(r4.blockPingPong, false, 'substantive work every round must not trip the breaker'); - assert.equal(r4.count, 1, 'streak stays at 1 when every round is substantive'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('no tool + short text → streak accumulates and blocks at 4 (pure language inertia)', async () => { - const { registerWorklist, unregisterWorklist, updateStreakOnPush } = await loadRegistry(); - const threadId = 'test-streak-phaseD-lazy'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const activity = { hadSubstantiveToolCall: false, outputLength: 30 }; - updateStreakOnPush(entry, 'opus', 'codex', activity); // 1 - updateStreakOnPush(entry, 'codex', 'opus', activity); // 2 warn - updateStreakOnPush(entry, 'opus', 'codex', activity); // 3 warn - const r4 = updateStreakOnPush(entry, 'codex', 'opus', activity); // 4 block - assert.equal(r4.blockPingPong, true); - assert.equal(r4.count, 4); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('no tool + long text (>200) → streak NOT accumulated (architecture discussion)', async () => { - const { registerWorklist, unregisterWorklist, updateStreakOnPush } = await loadRegistry(); - const threadId = 'test-streak-phaseD-longtext'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const activity = { hadSubstantiveToolCall: false, outputLength: 500 }; - updateStreakOnPush(entry, 'opus', 'codex', activity); - updateStreakOnPush(entry, 'codex', 'opus', activity); - updateStreakOnPush(entry, 'opus', 'codex', activity); - const r4 = updateStreakOnPush(entry, 'codex', 'opus', activity); - assert.equal(r4.blockPingPong, false, 'long-text discussion must not trip the breaker'); - assert.equal(r4.count, 1, 'long text is real discussion — no streak accumulation'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('P1 (gpt52 review): prior inertia streak is BROKEN by a substantive round', async () => { - // Scenario: 3 rounds of pure-inertia push (streak=3, warn but not block), - // then 1 round of substantive work (tool_use / long text), then another - // short push → must NOT block. Real work must reset the inertia counter, - // not merely skip incrementing it (otherwise 3+1 substantive+1 short = block). - const { registerWorklist, unregisterWorklist, updateStreakOnPush } = await loadRegistry(); - const threadId = 'test-streak-phaseD-break-inertia'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const inertia = { hadSubstantiveToolCall: false, outputLength: 30 }; - const substantive = { hadSubstantiveToolCall: true, outputLength: 30 }; - - updateStreakOnPush(entry, 'opus', 'codex', inertia); // 1 - updateStreakOnPush(entry, 'codex', 'opus', inertia); // 2 warn - updateStreakOnPush(entry, 'opus', 'codex', inertia); // 3 warn - assert.equal(entry.streakPair.count, 3, 'precondition: streak=3 from inertia'); - - updateStreakOnPush(entry, 'codex', 'opus', substantive); // should RESET, not just skip - assert.equal(entry.streakPair.count, 1, 'substantive round must reset inertia streak to 1'); - - const r5 = updateStreakOnPush(entry, 'opus', 'codex', inertia); // next short - assert.equal(r5.blockPingPong, false, 'after substantive reset, one more inertia push must NOT block'); - assert.equal(r5.count, 2, 'streak restart from new run: 1 (substantive) → 2 (next inertia)'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('P1 (cloud Codex review): substantive streak-reset requires actual enqueue — dedup skip must NOT reset', async () => { - // Reward-hack vector cloud Codex flagged: a long-content callback that - // targets a cat ALREADY pending in the worklist would still reset a - // near-blocked streak to 1 via isSubstantiveActivity, even though nothing - // was actually enqueued. Fix: streak updates only fire on actual push. - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-phaseD-reset-requires-enqueue'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - // Preload streak=3 (pure inertia). Advance executedIndex after each push - // so caller_mismatch guard passes on the next iteration (current cat = caller). - const inertia = { hadSubstantiveToolCall: false, outputLength: 30 }; - pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, inertia); - entry.executedIndex = 1; // current = 'codex' - pushToWorklist(threadId, ['opus'], 'codex', undefined, undefined, inertia); - entry.executedIndex = 2; // current = 'opus' - pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, inertia); - entry.executedIndex = 3; // current = 'codex' (matches next caller) - assert.equal(entry.streakPair.count, 3, 'precondition: streak=3'); - assert.equal(entry.list[entry.executedIndex], 'codex', 'precondition: current cat is codex'); - - // Inject duplicate 'opus' into pending tail so dedup will skip. - entry.list.push('opus'); // list=[opus, codex, opus, codex, opus]; pending tail=[codex, opus] - const beforeCount = entry.streakPair.count; - - // Substantive-looking callback from codex → opus. Passes caller check; - // should hit dedup (opus already in pending); streak MUST NOT reset. - const substantive = { hadSubstantiveToolCall: false, outputLength: 500 }; - const result = pushToWorklist(threadId, ['opus'], 'codex', undefined, undefined, substantive); - - assert.equal(result.added.length, 0, 'dedup must skip — nothing added'); - assert.equal( - entry.streakPair.count, - beforeCount, - 'streak must NOT reset when nothing was actually enqueued (cloud Codex P1)', - ); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('P1 (cloud Codex review): streak-increment also requires actual enqueue — depth skip must NOT accumulate', async () => { - // Symmetric: inertia push that hits depth limit must not count either. - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-streak-phaseD-incr-requires-enqueue'; - const entry = registerWorklist(threadId, ['opus'], 2); // maxDepth=2 - try { - const inertia = { hadSubstantiveToolCall: false, outputLength: 30 }; - pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, inertia); - entry.executedIndex = 1; // current = codex - pushToWorklist(threadId, ['opus'], 'codex', undefined, undefined, inertia); - entry.executedIndex = 2; // current = opus - // a2aCount=2 now at maxDepth — next push would hit depth limit. - assert.equal(entry.a2aCount, 2, 'precondition: at maxDepth'); - const beforeCount = entry.streakPair.count; - - // opus → gemini push should be depth-skipped (different pair anyway; use gemini to rule out dedup). - // Intentionally same pair opus↔codex to stress streak ++ vs actual enqueue separation: - const result = pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, inertia); - assert.equal(result.added.length, 0, 'depth limit must skip — nothing added'); - assert.equal(entry.streakPair.count, beforeCount, 'streak must NOT tick when depth guard skipped the enqueue'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('no activity arg (backward compat) → legacy accumulation behavior', async () => { - const { registerWorklist, unregisterWorklist, updateStreakOnPush } = await loadRegistry(); - const threadId = 'test-streak-phaseD-legacy'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - updateStreakOnPush(entry, 'opus', 'codex'); - updateStreakOnPush(entry, 'codex', 'opus'); - updateStreakOnPush(entry, 'opus', 'codex'); - const r4 = updateStreakOnPush(entry, 'codex', 'opus'); - assert.equal(r4.blockPingPong, true, 'omitting activity must keep legacy accumulation behavior'); - assert.equal(r4.count, 4); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('pushToWorklist passes callerActivity through to streak (substantive → no block)', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-pushToWorklist-activity-substantive'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const activity = { hadSubstantiveToolCall: true, outputLength: 50 }; - pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, activity); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex', undefined, undefined, activity); - entry.executedIndex = 2; - pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, activity); - entry.executedIndex = 3; - const r = pushToWorklist(threadId, ['opus'], 'codex', undefined, undefined, activity); - assert.ok(!r.blockPingPong, 'substantive activity via pushToWorklist must not trip breaker after 4 rounds'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('pushToWorklist: inertia activity (short text + no tool) → block at round 4', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await loadRegistry(); - const threadId = 'test-pushToWorklist-activity-inertia'; - const entry = registerWorklist(threadId, ['opus'], 10); - try { - const activity = { hadSubstantiveToolCall: false, outputLength: 30 }; - pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, activity); - entry.executedIndex = 1; - pushToWorklist(threadId, ['opus'], 'codex', undefined, undefined, activity); - entry.executedIndex = 2; - pushToWorklist(threadId, ['codex'], 'opus', undefined, undefined, activity); - entry.executedIndex = 3; - const r = pushToWorklist(threadId, ['opus'], 'codex', undefined, undefined, activity); - assert.ok(r.blockPingPong, 'pure inertia via pushToWorklist must still trip breaker'); - assert.equal(r.reason, 'pingpong_terminated'); - } finally { - unregisterWorklist(threadId, entry); - } - }); -}); diff --git a/packages/api/test/worklist-registry.test.js b/packages/api/test/worklist-registry.test.js deleted file mode 100644 index 3023451be1..0000000000 --- a/packages/api/test/worklist-registry.test.js +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Unit tests for WorklistRegistry (F27) - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -describe('WorklistRegistry', () => { - let _registryModule; - - // Import fresh each time to avoid cross-test contamination - // (registry is a module-level Map) - test('register + push + unregister lifecycle', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist, hasWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-lifecycle'; - const worklist = ['opus']; - - // Before register - assert.equal(hasWorklist(threadId), false); - assert.deepEqual(pushToWorklist(threadId, ['codex']).added, []); - - // Register - const entry = registerWorklist(threadId, worklist, 10); - assert.equal(hasWorklist(threadId), true); - assert.equal(entry.a2aCount, 0); - assert.equal(entry.maxDepth, 10); - assert.strictEqual(entry.list, worklist); - - // Push unique - const pushed = pushToWorklist(threadId, ['codex']); - assert.deepEqual(pushed.added, ['codex']); - assert.deepEqual(worklist, ['opus', 'codex']); - assert.equal(entry.a2aCount, 1); - - // Push duplicate — no-op - const pushDup = pushToWorklist(threadId, ['codex']); - assert.deepEqual(pushDup.added, []); - assert.deepEqual(worklist, ['opus', 'codex']); - assert.equal(entry.a2aCount, 1); - - // Push multiple - const pushMulti = pushToWorklist(threadId, ['gemini', 'codex']); - assert.deepEqual(pushMulti.added, ['gemini']); // codex deduplicated - assert.deepEqual(worklist, ['opus', 'codex', 'gemini']); - assert.equal(entry.a2aCount, 2); - - // Unregister with owner check - unregisterWorklist(threadId, entry); - assert.equal(hasWorklist(threadId), false); - assert.deepEqual(pushToWorklist(threadId, ['opus']).added, []); - }); - - test('push respects maxDepth', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-depth'; - const worklist = ['opus']; - registerWorklist(threadId, worklist, 2); - - try { - // Push 1st (a2aCount: 0 → 1) — ok - assert.deepEqual(pushToWorklist(threadId, ['codex']).added, ['codex']); - // Push 2nd (a2aCount: 1 → 2) — ok - assert.deepEqual(pushToWorklist(threadId, ['gemini']).added, ['gemini']); - // Push 3rd (a2aCount: 2 >= maxDepth: 2) — blocked - assert.deepEqual(pushToWorklist(threadId, ['opus']).added, []); - } finally { - unregisterWorklist(threadId); - } - }); - - test('R1 P1-1: preempt race — old unregister does not delete new worklist', async () => { - const { registerWorklist, unregisterWorklist, hasWorklist, pushToWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-preempt'; - - // Old invocation registers its worklist - const oldEntry = registerWorklist(threadId, ['opus'], 10); - assert.equal(hasWorklist(threadId), true); - - // New invocation preempts: registers a new worklist for the same thread - const newEntry = registerWorklist(threadId, ['codex'], 10); - assert.equal(hasWorklist(threadId), true); - assert.notStrictEqual(oldEntry, newEntry); - - // Old invocation's finally block tries to unregister with stale owner - unregisterWorklist(threadId, oldEntry); - - // New invocation's worklist must still be alive - assert.equal(hasWorklist(threadId), true, 'new worklist must survive old unregister'); - const pushed = pushToWorklist(threadId, ['gemini']); - assert.deepEqual(pushed.added, ['gemini'], 'push to new worklist must still work'); - - // Cleanup: new owner unregisters - unregisterWorklist(threadId, newEntry); - assert.equal(hasWorklist(threadId), false); - }); - - test('cloud Codex P1: stale callback caller rejected by callerCatId guard', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-caller-guard'; - - // New invocation registers worklist: opus executes first, then codex - const entry = registerWorklist(threadId, ['opus', 'codex'], 10); - - try { - // executedIndex = 0 → current cat is opus - // A callback from opus (current cat) should be allowed - assert.deepEqual(pushToWorklist(threadId, ['gemini'], 'opus').added, ['gemini']); - assert.equal(entry.a2aCount, 1); - - // A callback from codex (not currently executing) should be rejected - // This simulates a stale callback from a preempted invocation whose catId is codex - assert.deepEqual(pushToWorklist(threadId, ['opus'], 'codex').added, []); - assert.equal(entry.a2aCount, 1, 'stale caller must not increase a2aCount'); - - // Advance executedIndex to 1 → now codex is current - entry.executedIndex = 1; - - // Now codex's callback should be allowed - assert.deepEqual(pushToWorklist(threadId, ['opus'], 'codex').added, ['opus']); - assert.equal(entry.a2aCount, 2); - - // But opus callback should now be rejected (it's no longer current) - assert.deepEqual(pushToWorklist(threadId, ['gemini'], 'opus').added, []); - assert.equal(entry.a2aCount, 2, 'past caller must not increase a2aCount'); - - // Without callerCatId (legacy path from routeSerial text detection), always allowed. - // Use a cat not already in pending to avoid dedup: worklist is now - // ['opus','codex','gemini','opus'] with executedIndex=1, pending=['codex','gemini','opus']. - // Push 'codex' would be deduped, so we push a cat that's not in pending. - // Actually at this point pending already includes codex/gemini/opus. - // Register a fresh worklist to test legacy path cleanly. - unregisterWorklist(threadId, entry); - const fresh = registerWorklist(threadId, ['opus'], 10); - assert.deepEqual(pushToWorklist(threadId, ['codex']).added, ['codex']); - assert.equal(fresh.a2aCount, 1, 'legacy path without callerCatId must still work'); - unregisterWorklist(threadId, fresh); - return; // Skip the finally block cleanup since we already cleaned up - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('P1: do not overwrite reply target for pending original cats', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'test-original-target-reply'; - const entry = registerWorklist(threadId, ['opus', 'codex'], 10); - - try { - // codex is an original pending target; mention should NOT set A2A sender mapping - assert.deepEqual(pushToWorklist(threadId, ['codex'], 'opus').added, []); - assert.equal(getWorklist(threadId).a2aFrom.get('codex'), undefined); - - // A2A-added pending targets may still refresh to latest sender before execution - assert.deepEqual(pushToWorklist(threadId, ['gemini'], 'opus').added, ['gemini']); - assert.equal(getWorklist(threadId).a2aFrom.get('gemini'), 'opus'); - - entry.executedIndex = 1; // now codex is current, gemini still pending - assert.deepEqual(pushToWorklist(threadId, ['gemini'], 'codex').added, []); - assert.equal(getWorklist(threadId).a2aFrom.get('gemini'), 'codex'); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - // F121 a2aTriggerMessageId tests → worklist-registry-f121.test.js (file size cap) - - test('multiple threads are independent', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const wl1 = ['opus']; - const wl2 = ['codex']; - registerWorklist('t1', wl1, 10); - registerWorklist('t2', wl2, 10); - - try { - assert.deepEqual(pushToWorklist('t1', ['codex']).added, ['codex']); - assert.deepEqual(pushToWorklist('t2', ['opus']).added, ['opus']); - - assert.deepEqual(wl1, ['opus', 'codex']); - assert.deepEqual(wl2, ['codex', 'opus']); - assert.equal(getWorklist('t1').a2aCount, 1); - assert.equal(getWorklist('t2').a2aCount, 1); - } finally { - unregisterWorklist('t1'); - unregisterWorklist('t2'); - } - }); -}); - -// F122 PushResult tests moved to worklist-registry-f122.test.js (file size cap) - -// --- F108: parentInvocationId-based isolation (AC-A6) --- - -describe('WorklistRegistry: parentInvocationId isolation (F108)', () => { - test('two concurrent invocations in same thread have independent worklists (AC-A6)', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist, hasWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'thread-concurrent'; - - // Invocation 1: opus running, keyed by parentInvocationId 'inv-1' - const wl1 = ['opus']; - const entry1 = registerWorklist(threadId, wl1, 10, 'inv-1'); - - // Invocation 2: codex running, keyed by parentInvocationId 'inv-2' - const wl2 = ['codex']; - const entry2 = registerWorklist(threadId, wl2, 10, 'inv-2'); - - try { - // Both worklists coexist - assert.equal(hasWorklist(threadId), true, 'thread-level check: any worklist active'); - - // Push to inv-1 worklist only - const pushed1 = pushToWorklist(threadId, ['gemini'], undefined, 'inv-1'); - assert.deepEqual(pushed1.added, ['gemini'], 'push to inv-1 worklist'); - assert.deepEqual(wl1, ['opus', 'gemini']); - assert.deepEqual(wl2, ['codex'], 'inv-2 worklist untouched'); - - // Push to inv-2 worklist only - const pushed2 = pushToWorklist(threadId, ['opus'], undefined, 'inv-2'); - assert.deepEqual(pushed2.added, ['opus'], 'push to inv-2 worklist'); - assert.deepEqual(wl2, ['codex', 'opus']); - assert.deepEqual(wl1, ['opus', 'gemini'], 'inv-1 worklist untouched'); - - // Unregister inv-1, inv-2 still alive - unregisterWorklist(threadId, entry1, 'inv-1'); - assert.equal(hasWorklist(threadId), true, 'inv-2 still active'); - - // Unregister inv-2, thread worklist gone - unregisterWorklist(threadId, entry2, 'inv-2'); - assert.equal(hasWorklist(threadId), false, 'no worklists left'); - } finally { - // Safety cleanup - unregisterWorklist(threadId, entry1, 'inv-1'); - unregisterWorklist(threadId, entry2, 'inv-2'); - } - }); - - test('pushToWorklist without parentInvocationId falls back to threadId key (backward compat)', async () => { - const { registerWorklist, unregisterWorklist, pushToWorklist, hasWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'thread-legacy'; - const wl = ['opus']; - - // Register without parentInvocationId (legacy path) - const entry = registerWorklist(threadId, wl, 10); - - try { - assert.equal(hasWorklist(threadId), true); - - // Push without parentInvocationId (legacy path) - const pushed = pushToWorklist(threadId, ['codex']); - assert.deepEqual(pushed.added, ['codex']); - assert.deepEqual(wl, ['opus', 'codex']); - } finally { - unregisterWorklist(threadId, entry); - } - }); - - test('getWorklist with parentInvocationId returns specific invocation worklist', async () => { - const { registerWorklist, unregisterWorklist, getWorklist } = await import( - '../dist/domains/cats/services/agents/routing/WorklistRegistry.js' - ); - - const threadId = 'thread-get-specific'; - const wl1 = ['opus']; - const wl2 = ['codex']; - const entry1 = registerWorklist(threadId, wl1, 10, 'inv-a'); - const entry2 = registerWorklist(threadId, wl2, 5, 'inv-b'); - - try { - const fetched1 = getWorklist(threadId, 'inv-a'); - assert.equal(fetched1.maxDepth, 10); - assert.strictEqual(fetched1.list, wl1); - - const fetched2 = getWorklist(threadId, 'inv-b'); - assert.equal(fetched2.maxDepth, 5); - assert.strictEqual(fetched2.list, wl2); - } finally { - unregisterWorklist(threadId, entry1, 'inv-a'); - unregisterWorklist(threadId, entry2, 'inv-b'); - } - }); -}); diff --git a/packages/mcp-server/governance/cutovers/f167-message-lifecycle-completion-sunset.json b/packages/mcp-server/governance/cutovers/f167-message-lifecycle-completion-sunset.json new file mode 100644 index 0000000000..6b2b52e1ab --- /dev/null +++ b/packages/mcp-server/governance/cutovers/f167-message-lifecycle-completion-sunset.json @@ -0,0 +1,54 @@ +{ + "resource": { + "kind": "family", + "resourceFamily": "task-workflow" + }, + "retiredNames": ["cat_cafe_complete_a2a_dispatch", "cat_cafe_complete_managed_hold"], + "canonicalNames": [], + "expectedProfiles": {}, + "rollbackRevision": "47f716cac016eca3211e4f0754ae18607f4ada45", + "layers": { + "mcp-definition": { + "status": "covered", + "consumers": ["packages/mcp-server/src/tools/callback-tools.ts", "packages/mcp-server/src/tools/index.ts"] + }, + "runtime-catalog-profile": { + "status": "covered", + "consumers": [ + "packages/mcp-server/src/canonical-server-tools.ts", + "packages/mcp-server/governance/mcp-surface-baseline.json" + ] + }, + "prompt-l0": { + "status": "not-applicable", + "rationale": "Ordinary A2A and managed-hold completion now follow the persisted Message/Queue lifecycle; no completion instruction is injected into a model prompt.", + "absenceEvidence": ["cat_cafe_complete_a2a_dispatch", "cat_cafe_complete_managed_hold"] + }, + "skill-convention": { + "status": "not-applicable", + "rationale": "The retired manual completion operations are no longer agent decisions or skill steps; hold_ball remains only for registering a persistent continuation.", + "absenceEvidence": ["cat_cafe_complete_a2a_dispatch", "cat_cafe_complete_managed_hold"] + }, + "fixture-test": { + "status": "covered", + "consumers": [ + "packages/mcp-server/test/tool-registration.test.js", + "packages/api/test/turn-custody-projection.test.js", + "packages/api/test/managed-command-wake-recovery-sweep.test.js" + ] + }, + "eval": { + "status": "not-applicable", + "rationale": "The cutover removes a duplicate manual terminal declaration; the canonical Message/Queue lifecycle tests are the behavioral proof.", + "absenceEvidence": ["cat_cafe_complete_a2a_dispatch", "cat_cafe_complete_managed_hold"] + }, + "observability": { + "status": "covered", + "consumers": [ + "packages/api/src/domains/cats/services/stores/visibility.ts", + "packages/api/src/domains/cats/services/context/ContextAssembler.ts", + "packages/api/src/domains/ball-custody/managed-command-wake-task-projection.ts" + ] + } + } +} diff --git a/packages/mcp-server/governance/mcp-surface-baseline.json b/packages/mcp-server/governance/mcp-surface-baseline.json index c566ca1b79..b4114db4b6 100644 --- a/packages/mcp-server/governance/mcp-surface-baseline.json +++ b/packages/mcp-server/governance/mcp-surface-baseline.json @@ -44,7 +44,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleAckMentions", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_act_artifact_review", @@ -1128,7 +1128,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleAdmitEntrustedWork", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_advance_evolution_program_change", @@ -1139,9 +1139,9 @@ ], "activeState": "canonical", "description": "Advance the governed Change & Learn lane of a canonical Evolution Program with one operation-only action. Use propose only for an actionable intervention and from an authenticated invocation with an exact source message; use sync to import canonical Approval/dispatch/mutation/outcome progress; use decide only after a merged-and-loaded fresh outcome and an explicit value-owner/operator disposition. NOT for: sending an Approval, owner identity, target/version, Task/lease, mutation receipt or outcome — F246/F266/F313 and the asset owner resolve and own all of those. Output: appended/duplicate/conflict/waiting/blocked plus the ref-only Program projection. Rejected, withdrawn, superseded and drifted attempts require a fresh propose operation and remain side-effect-ineligible. GOTCHA: agent-key callers may only sync. Proposal and metabolism decisions fail closed without an invocation-bound owner source or a direct owner session.", - "descriptionDigest": "sha256:2829761e89e8a14e6ef3dfad6d869208163d46aa954e385ed19b100074f8a73b", - "descriptionCharacters": 931, - "descriptionTokensCl100kBase": 176, + "descriptionDigest": "sha256:3608f24149dee57687c9ade8ffc59961a535743b74d49f1a657583b5a559f5b6", + "descriptionCharacters": 936, + "descriptionTokensCl100kBase": 175, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -1247,7 +1247,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/capability-evolution-change-tools.js#handleAdvanceEvolutionProgramChange", - "protectedImplementationDigest": "sha256:bda533334bc6faba0dd08b92501ebf059d7d39a54431398d6b66a09d5aa106bc" + "protectedImplementationDigest": "sha256:8b64299e073e6d04a4b296f0b16dfb20c821703eb2a52bd0ced3f9253d1a1f73" }, { "name": "cat_cafe_amend_person_interaction", @@ -1464,7 +1464,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleAmendPersonInteraction", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_audio_capture_start", @@ -1631,7 +1631,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioCaptureStart", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_audio_capture_status", @@ -1666,7 +1666,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioCaptureStatus", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_audio_capture_stop", @@ -1701,7 +1701,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioCaptureStop", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_audio_enroll_speakers", @@ -1775,7 +1775,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioEnrollSpeakers", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_audio_list_sources", @@ -1810,7 +1810,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioListSources", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_audio_read_transcript", @@ -1875,7 +1875,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioReadTranscript", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_audio_set_advisory_mode", @@ -1922,7 +1922,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioSetAdvisoryMode", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_audio_set_talking_points", @@ -1968,7 +1968,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/audio-tools.js#handleAudioSetTalkingPoints", - "protectedImplementationDigest": "sha256:a418d03b3930cf5a5776bd6ac5743a07fb921506392e1e40bc1b75a9c873a683" + "protectedImplementationDigest": "sha256:991f1169d6ad912964696eeb9d820f181c8927444c040be2cf9e3ef299527cbb" }, { "name": "cat_cafe_backfill_events", @@ -2009,7 +2009,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/event-memory-tools.js#handleBackfillEvents", - "protectedImplementationDigest": "sha256:43c2fde0f5b5b9417b04abc6f744ce8982899e0e555b081939dab686a06dab8f" + "protectedImplementationDigest": "sha256:a909f42974441ba495f39106f7594d62a7435e920911baf151cd351e25d504ac" }, { "name": "cat_cafe_begin_evolution_preparation_work", @@ -2169,7 +2169,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handleBindRequestReviewConsumption", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_bootcamp_env_check", @@ -2213,7 +2213,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleBootcampEnvCheck", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_capture_paw_feel", @@ -2553,7 +2553,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCloseEntrustedWork", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_collective_current_context", @@ -2751,7 +2751,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCommunityAwaitExternal", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_community_guardian_signoff", @@ -2862,7 +2862,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCommunityGuardianSignoff", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_community_request_guardian", @@ -2923,99 +2923,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCommunityRequestGuardian", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" - }, - { - "name": "cat_cafe_complete_a2a_dispatch", - "serverFamily": "collab", - "resourceFamily": "task-workflow", - "actions": [ - "complete" - ], - "activeState": "canonical", - "description": "Terminally dispose the exact ordinary A2A dispatch bound to this invocation. Use when: the current turn was triggered by a same-thread or queued agent handoff and its requested work is actually handled/completed. NOT for: user turns, managed holds, unfinished work, re-hold, event wait, transfer, or unrelated task completion. Output: terminalizes the exact F167 dispatch ball; the server derives and fences threadId, holderCatId, fromCatId, invocationId, and sourceMessageId. GOTCHA: command exit, tests, merge truth, another coordination terminal, or ACK never substitute for this producer, and the caller cannot select or close another subject. If replaced, the error names the latest verified same-thread successor event and any source message or coordination.", - "descriptionDigest": "sha256:3a9ab181d1825e64111cf5f52215a0fff143f49e79d04ee6718aaf03271e333d", - "descriptionCharacters": 764, - "descriptionTokensCl100kBase": 157, - "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "disposition": { - "description": "handled = exact A2A request consumed; completed = requested A2A work completed.", - "enum": [ - "handled", - "completed" - ], - "type": "string" - } - }, - "required": [ - "disposition" - ], - "type": "object" - }, - "inputSchemaDigest": "sha256:a84e36e2bf38784293dcee3ff9e8c6bd53b5e9fdaefecab7fd73ad532ca4f13b", - "annotations": { - "destructiveHint": false, - "openWorldHint": false, - "readOnlyHint": false - }, - "runtimeProfiles": [ - "full" - ], - "schemaDelivery": { - "evidenceRef": "file:packages/mcp-server/src/tools/callback-tools.ts", - "policy": "host-default" - }, - "implementationRef": "module:./tools/callback-tools.js#handleCompleteA2ADispatch", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" - }, - { - "name": "cat_cafe_complete_managed_hold", - "serverFamily": "collab", - "resourceFamily": "task-workflow", - "actions": [ - "complete" - ], - "activeState": "canonical", - "description": "Terminally dispose the exact managed hold wake bound to this invocation. Use when: the current turn was triggered by a managed hold wake, or a full-body read adopted one into this same invocation, and its work is actually handled/completed. NOT for: ordinary holds, unfinished work, re-hold, a structured event wait, transfer, or unrelated task completion. Output: marks the exact F264 target receipt handled and terminalizes the original F167 hold ball; the server derives and fences threadId, holderCatId, invocationId, sourceMessageId, and taskId. GOTCHA: full read, command exit, tests, merge truth, or ACK never substitute for this producer, and the caller cannot select or close another subject. Full-read managedHoldDisposition guidance reports a unique pending source, ambiguity, or no obligation; never guess through ambiguity.", - "descriptionDigest": "sha256:a28763c258b774e609e3ed1bc4f58e4ac6e15cf82a6ddd305fccbc637315931d", - "descriptionCharacters": 836, - "descriptionTokensCl100kBase": 171, - "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "disposition": { - "description": "handled = wake consumed; completed = wake work completed. Both terminalize the exact original hold.", - "enum": [ - "handled", - "completed" - ], - "type": "string" - } - }, - "required": [ - "disposition" - ], - "type": "object" - }, - "inputSchemaDigest": "sha256:9e603b3c5e22f3454f510bfc6053aa9d39b0078c0c06369fed4778ce42e9d63b", - "annotations": { - "destructiveHint": false, - "openWorldHint": false, - "readOnlyHint": false - }, - "runtimeProfiles": [ - "full" - ], - "schemaDelivery": { - "evidenceRef": "file:packages/mcp-server/src/tools/callback-tools.ts", - "policy": "host-default" - }, - "implementationRef": "module:./tools/callback-tools.js#handleCompleteManagedHold", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_constitute_evolution_program", @@ -3303,7 +3211,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCorrectPersonClaim", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_create_rich_block", @@ -3314,9 +3222,9 @@ ], "activeState": "migration-candidate", "description": "Create a rich block (card, diff, checklist, file, media_gallery, audio, interactive, or html_widget) attached to the current message. Use card for status/decisions, diff for code changes, checklist for inline todos, file for existing documents/audio/video, media_gallery for images, audio for voice, interactive for user selection/confirmation, html_widget for custom inline HTML. Use this when the user wants to see, hear, or interact with the result directly in Clowder AI Chat (for example: show me, draw an HTML demo, or send a screenshot); the user does not need to say \"rich text\". Use html_widget for a self-contained HTML/demo, media_gallery for existing screenshots/images, and browser-preview for a complex localhost or multi-page app. NOT for discussing or editing HTML without a visible-delivery request. Use this for long structured replies/reports with lists, tables, code blocks, diffs, status fields, or multi-step checklists; F192 rich-messaging wakeup treats plain long Markdown with these signals and no rich block as a miss. NOT for: persistent task tracking across sessions (use create_task for 🧶 毛线球). NOT for: document generation/export (use generate_document). Output: block rendered inline in the current message. GOTCHA: The block JSON must use \"kind\" (NOT \"type\") and include \"v\": 1 and a unique \"id\". GOTCHA: Call get_rich_block_rules first if you haven't loaded the full schema yet in this session. GOTCHA: checklist kind is ephemeral inline content — for persistent cross-session work items, use create_task (毛线球) instead. If callback auth fails, falls back to cc_rich text encoding automatically.", - "descriptionDigest": "sha256:a7062af36494a826d7e1e275fbf42427d360986e2dd6b438bb7a04789c54424c", - "descriptionCharacters": 1626, - "descriptionTokensCl100kBase": 356, + "descriptionDigest": "sha256:f576b04cb0153d3113b0676f64410fc07f92f79364463f0e1073c4e0cd08e19d", + "descriptionCharacters": 1628, + "descriptionTokensCl100kBase": 358, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -3351,7 +3259,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCreateRichBlock", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_create_task", @@ -3362,9 +3270,9 @@ ], "activeState": "migration-candidate", "description": "Create a new 🧶 毛线球 (yarn ball) task in the current thread. Use when: user says \"建个毛线球\", \"记一下任务\", \"track this\", or you identify persistent work items across sessions — e.g. \"fix login timeout\", \"update API docs\", \"review F160 spec\". NOT for: temporary execution steps (use PlanBoard/TodoWrite), NOT for inline checklists in a message (use create_rich_block with kind:\"checklist\"). Output: task appears in the thread 🧶 毛线球 panel, persists across sessions, visible to all cats and co-creator. GOTCHA: 毛线球 ≠ checklist rich block. 毛线球 lives in the task panel and survives session boundaries; checklist is ephemeral inline content in one message. TIP: Include a \"why\" to give context to whoever picks up the task. F193-E1 DISPATCH GATE: If your task references a feature (F-number) outside your current scope, provide dispatchGate with status \"dispatched\" (you already cross_posted to the owning thread) or \"not_dispatched\" (with reason). If you omit dispatchGate and external F-IDs are detected, the task is created but a warning is returned reminding you to dispatch.", - "descriptionDigest": "sha256:107d21b3a0fd0b8623c893360e1cb29c842ace3fded307e2a3f7e261e9df5ab4", - "descriptionCharacters": 1058, - "descriptionTokensCl100kBase": 277, + "descriptionDigest": "sha256:2e6cff04a59b9bc8ece085f469d133a367bdfda4add33191d9f0fc8141628f8c", + "descriptionCharacters": 1065, + "descriptionTokensCl100kBase": 273, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -3445,7 +3353,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCreateTask", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_cross_post_message", @@ -3456,9 +3364,9 @@ ], "activeState": "canonical", "description": "Post a message to a specific thread by threadId (cross-thread notification). Use when you need to notify a different thread about something relevant. NOT for: posting to your own current thread (use post_message instead). Output: message appears in the target thread as a new message visible to all participants. ROUTING: You MUST include routing credentials to wake the target cat — either set `targetCats` array with the recipient catId(s), OR put a line-start `@handle` in content. Messages without routing (no targetCats, no line-start @) will be REJECTED (F193 AC-A4). GOTCHA: Requires threadId — use feat_index/list_threads plus thread truth to verify the exact owning thread; never guess a nearby thread. PAW-FEEL: The original [爪感差: ...] message is already collected. Cross-post only a marker-free sourceMessageId reference to a verified owner; if none exists, use cat_cafe_propose_thread (F128). New responsibility uses effectClass=assign_work plus proposedAction for Approval Hub review. GOTCHA: For Claim/Release coordination, pass coordination.phase=active on Claim/work hops and terminal on Release. A terminal id or bound subject that conflicts with the incoming coordination fails with HTTP 409 before message persistence or wake; start genuinely new work with phase=active. For a local review result, route one ordinary @author cross-post with localReviewVerdict + exact reviewedHeadSha + reviewSubjectRef + acceptedSourceRef + acceptedRevision + clientMessageId. This durable fact needs no action lease, coordination generation, replacement, or issuer route; prose is never parsed. The server carries a stable id across active hops; a direct courtesy ACK after terminal is recorded without waking another cat. If terminal reveals genuinely new work, start a new coordination with phase=active instead of ACKing the closed chain. GOTCHA: For a direct named external action, pass action + explicit clientMessageId + targetCats. For operator-gated new responsibility, pass proposedAction with effectClass=assign_work instead; action and assign_work remain mutually exclusive. Output: direct action admission posts and queues one fenced carrier; assign_work publishes one pending approval card and posts nothing to the target until approval atomically acquires the fence. Existing standing reuses the same CAS via claimOrigin=\"existing_standing\" + groundingEvidenceRef. returnToPredecessor sends a rejected single generation to the server-persisted predecessor; parallel mode records only the rejecting holder terminal. Structured action metadata currently requires invocation-token auth; agent-key callers fail closed with the non-retryable action_agent_key_unsupported status and never send an unfenced fallback. F247: gpt-pro agent-key returns carry only the exact replyTo from the runtime delta; authorization stays in server custody. Fallback must use replace with the active leaseId/generation after server-recorded terminal evidence. Use mode=parallel + parallelIntent only for deliberate independent review, #ideate, or explicit operator fan-out. TIP: The sub-thread \"## 主 Thread\" header includes exact routing credentials (threadId + targetCats/handle) — copy them directly.", - "descriptionDigest": "sha256:9a32247ad1e2a3b61d10fec5fef0dcf2188f5aecd358f205bdb2e0098d597024", - "descriptionCharacters": 3186, - "descriptionTokensCl100kBase": 649, + "descriptionDigest": "sha256:8ca412c4c26bd8b743430b94492f31926568d292593112c73ffbf58548e169de", + "descriptionCharacters": 3196, + "descriptionTokensCl100kBase": 647, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -3475,10 +3383,6 @@ "minLength": 1, "type": "string" }, - "acknowledgeHeld": { - "description": "F254 Freshness Gate escape hatch. Force-send despite unseen messages in the target thread.", - "type": "boolean" - }, "action": { "additionalProperties": false, "description": "Optional durable subject/action/slot successor identity for this cross-thread dispatch. Requires explicit clientMessageId and targetCats. mode=single allows exactly one target; mode=parallel requires at least two targets plus parallelIntent. A duplicate active action returns safe_wait. Use claimOrigin=existing_standing plus groundingEvidenceRef for a verified self-claim. New claims require terminalPredicate typed parameters; server catalog owns completion semantics and exact revision freshness. Use returnToPredecessor for rejected custody: single mode returns the generation to the persisted predecessor; parallel mode records only the rejecting holder terminal and does not enqueue a whole-lease return. Direct executable action custody is closed to implement + implementer + task_done. Local cat review uses an ordinary durable handoff with localReviewVerdict + reviewedHeadSha + accepted-source fields; external review enters through an approved proposedAction and records its verdict through the external-review contract. review/reviewer, merge/pr_merged, and every other reserved pair are unavailable on direct carriers. subjectRef must use pr:/# (for example pr:zts212653/cat-cafe#2943) or subject::. GitHub URL forms and SHA suffixes such as github:owner/repo#2943@abc123 are invalid.", @@ -3821,7 +3725,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:7bd2843ca7eef2f724d0d4b4d1a172fb6530162cdb192630a56a41736814b6b4", + "inputSchemaDigest": "sha256:ecf9a0b23b3b200e779590e9296456bef3ec95f1dc329d9cf157353c03094bc1", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -3838,7 +3742,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleCrossPostMessage", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_defer_person_memory_delta", @@ -4018,7 +3922,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleDeferPersonMemoryDelta", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_dismiss_request_review_consumption", @@ -4072,7 +3976,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handleDismissRequestReviewConsumption", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_dismiss_skill_consumption", @@ -4125,7 +4029,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handleDismissSkillConsumption", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_dispose_deferred_person_memory", @@ -4205,7 +4109,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleDisposeDeferredPersonMemory", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_drill_memory_cue", @@ -4255,7 +4159,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleDrillMemoryCue", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_drill_person_memory", @@ -4351,7 +4255,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleDrillPersonMemory", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_edit_office_document", @@ -4540,7 +4444,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleFeatIndex", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_finance_query", @@ -4680,7 +4584,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleForgetDeferredPersonMemory", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_forget_person", @@ -4730,7 +4634,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleForgetPerson", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_forget_person_memory_proposal", @@ -4780,7 +4684,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleForgetPersonMemoryProposal", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_generate_document", @@ -4840,7 +4744,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGenerateDocument", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_available_guides", @@ -4875,7 +4779,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetAvailableGuides", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_evolution_program", @@ -4958,7 +4862,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetHoldStatus", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_message", @@ -5023,7 +4927,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetMessage", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_pending_mentions", @@ -5070,7 +4974,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetPendingMentions", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_person_memory_proposal_status", @@ -5121,7 +5025,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetPersonMemoryProposalStatus", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_rich_block_rules", @@ -5156,7 +5060,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/rich-block-rules-tool.js#handleGetRichBlockRules", - "protectedImplementationDigest": "sha256:22c51a550dc7dfa24058da4de22da2a261a17f4f2e512f18440b0fac631dfa3b" + "protectedImplementationDigest": "sha256:309f969de85d4a72557fcf45851ff5d7daa5130924c1d6c186680d7afd5c3651" }, { "name": "cat_cafe_get_thread_cats", @@ -5204,7 +5108,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetThreadCats", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_thread_context", @@ -5214,10 +5118,10 @@ "read" ], "activeState": "canonical", - "description": "Read messages from one thread, with token-lean anchor previews by default and optional full bodies, ranked keywords, or a bounded window around messageId. Use when: browsing the current conversation, reading a different known threadId, finding relevant messages inside that thread, opening context around a known messageId, or a freshness notice asks you to catch up. NOT for: finding features, decisions, plans, lessons, or unknown threads across project knowledge; use search_evidence or list_threads first. Output: a bounded aggregate envelope with threadId, ordered messages, hasMore, and nextCursor when continuation is required; anchor mode includes drillDown pointers, while responseMode=\"full\" returns complete bodies per ordinary item and includes same-target queued bodies. GOTCHA: keyword ranking is best-effort over a bounded recent scan; scanCapped=true means older history may contain additional matches. A single item larger than the full-page budget falls back to an honest anchor; drill an oversized workflow SOP with cat_cafe_get_workflow_sop using the returned threadId. Anchor mode does not consume queued bodies or close freshness responsibility; for a freshness catch, use responseMode=\"full\" with no catId/keyword/messageId filters and follow nextCursor until hasMore=false. Pass threadId only to read a different thread; omit it for the current thread.", - "descriptionDigest": "sha256:90ec53854591290ac797db7f4c203b06dd57595e48c609d2c8d989195aa1fd16", - "descriptionCharacters": 1376, - "descriptionTokensCl100kBase": 266, + "description": "Read messages from one thread, with token-lean anchor previews by default and optional full bodies, ranked keywords, or a bounded window around messageId. Use when: browsing the current conversation, reading a different known threadId, finding relevant messages inside that thread, opening context around a known messageId, or explicitly catching up. NOT for: finding features, decisions, plans, lessons, or unknown threads across project knowledge; use search_evidence or list_threads first. Output: a bounded aggregate envelope with threadId, ordered messages, contextScope, hasMore, and nextCursor when continuation is required; anchor mode includes drillDown pointers, while responseMode=\"full\" returns complete bodies per ordinary item and includes same-target queued bodies. contextScope=\"unread_delta\" contains only currently unread relevant messages; in that scope hasMore=false closes the unread selection, not all thread history. Use anchor browsing or a messageId window to inspect older history. When available, situation reports exact lifecycle-backed active runs (target, source messages, response, invocation); situation.complete=false means runtime evidence could not be fully joined, so do not infer execution from recent speech. GOTCHA: keyword ranking is best-effort over a bounded recent scan; scanCapped=true means older history may contain additional matches. A single item larger than the full-page budget falls back to an honest anchor; drill an oversized workflow SOP with cat_cafe_get_workflow_sop using the returned threadId. Anchor mode does not consume queued bodies; for a complete catch-up of current unread work, use responseMode=\"full\" with no catId/keyword/messageId filters and follow nextCursor until hasMore=false. Pass threadId only to read a different thread; omit it for the current thread.", + "descriptionDigest": "sha256:83a9ba26ab335e887fe3334a58c079963adc867207b481cd2a8a129892f3cd92", + "descriptionCharacters": 1830, + "descriptionTokensCl100kBase": 347, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -5268,7 +5172,7 @@ "type": "string" }, "responseMode": { - "description": "Response projection mode. \"anchor\" (DEFAULT — omit for normal browsing): token-lean previews with drillDown pointers to full content. \"full\": returns complete message bodies inside a bounded aggregate page; use nextCursor when hasMore=true. A persisted message larger than the page is returned as an honest anchor with a precise drill pointer; a transient queued body without a persisted message anchor remains unseen and says to retry after persistence. An oversized workflow SOP is likewise returned as an honest anchor that points to cat_cafe_get_workflow_sop instead of overflowing the aggregate envelope. Use \"full\" whenever a freshness catch asks you to consume the contiguous unread set: current-thread reads prefer the unread delta, and only complete bodies advance queued-read evidence. GOTCHA: anchor previews are not a freshness closure and cannot prove queued messages were handled.", + "description": "Response projection mode. \"anchor\" (DEFAULT — omit for normal browsing): token-lean previews with drillDown pointers to full content. \"full\": returns complete message bodies inside a bounded aggregate page; use nextCursor when hasMore=true. A persisted message larger than the page is returned as an honest anchor with a precise drill pointer; a transient queued body without a persisted message anchor remains unseen and says to retry after persistence. An oversized workflow SOP is likewise returned as an honest anchor that points to cat_cafe_get_workflow_sop instead of overflowing the aggregate envelope. Use \"full\" for a complete catch-up of the current unread selection: current-thread reads prefer the unread delta, and only complete bodies advance queued-read evidence. The response contextScope names that selection: contextScope=\"unread_delta\" contains only currently unread relevant messages, so hasMore=false closes that unread selection rather than proving that all thread history was returned. Use anchor browsing or a messageId window to inspect older history. GOTCHA: anchor previews do not consume queued bodies and cannot prove queued messages were handled.", "enum": [ "anchor", "full" @@ -5283,7 +5187,7 @@ }, "type": "object" }, - "inputSchemaDigest": "sha256:58b58cd005e376c8ca562789ce5ab780c57ea96f82486f2139c5ff1c84939b3c", + "inputSchemaDigest": "sha256:0010189be770b1eb103ee775ca639ab6a3a53c3ff25516a86136a842c91f1151", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -5300,7 +5204,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetThreadContext", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_thread_metadata", @@ -5334,7 +5238,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetThreadMetadata", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_get_workflow_sop", @@ -5382,7 +5286,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGetWorkflowSop", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_graph_resolve", @@ -5447,7 +5351,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/graph-tools.js#handleGraphResolve", - "protectedImplementationDigest": "sha256:c0ddc2a4b51ea18ad8f521992e59db336930e6a8913b748b4d82043819ff02aa" + "protectedImplementationDigest": "sha256:2b18d34d76bdd4b107274ab2a9774c1aa103ed84106ccbfaa04dc130a6e87200" }, { "name": "cat_cafe_guide_control", @@ -5495,7 +5399,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleGuideControl", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_hold_ball", @@ -5505,10 +5409,10 @@ "wait" ], "activeState": "migration-candidate", - "description": "Declare a bounded ball hold: keep the ball while waiting for a short, predictable condition, then get auto-re-invoked with your context. Use when: ball is clearly yours + nobody else can advance + short predictable wait (e.g. CI running, build compiling, PR checks pending) + you know exactly what to do next. NOT for: waiting for co-creator/user OR another cat to reply / answer / decide → @co-creator or @ that cat (their next message re-invokes you; a hold timer just stacks a redundant 2nd trigger). This is the #1 misuse — hold_ball is NOT a way to \"stay alive\" until a person replies; need review/approval → @ reviewer or @co-creator; need another cat to act → @ that cat; \"let me think\" / \"I'll hold for now\" → hesitation not hold, pick 接/退/升; review/analysis done → MUST @ author, conclusion ≠ endpoint; status updates → use post_message. Output: system schedules a one-shot wake-up after wakeAfterMs; you get re-invoked with reason + nextStep as trigger context. GOTCHA: max 3 holds per (thread, cat) within a sliding ~1h window (anchored to last successful hold) — 4th call returns 429 with retryAt/retryAfterMs, you MUST pass (@ another cat or @co-creator). GOTCHA: the counter is process-local best-effort (in-memory on the API node); API restart or multi-instance deploys may reset it, so do not treat the 429 as a hard security boundary — treat it as a self-discipline guardrail. GOTCHA: hold is an EXCEPTION state, not a default exit. Most turns should end with @ someone, not hold. GOTCHA (F167 Phase M): only hold for harness-INVISIBLE waits — external conditions nothing will call you back about (cloud review verdict, remote CI, external webhook). Background work the harness already tracks (a background Bash command, a spawned task) AUTO-RE-INVOKES you on completion; holding for that just stacks a redundant wake on top. Ask \"will something call me back already?\" — if yes, do NOT hold. A co-creator or another cat sending a message into this thread IS such a callback (it re-invokes you), so \"waiting for co-creator to answer\" must be @co-creator, never a hold. GOTCHA: SINGLE-SLOT per (thread, cat) — calling hold_ball again while a previous hold is pending REPLACES the prior wake (prior taskId cancelled). This is intentional (KD-23): hold = \"持一个球\" exception, not a queue. If you need to track multiple waiting conditions, merge them into one nextStep (e.g. \"等 CI + @co-creator 确认\" 合并成一句). Sliding-window counter ticks on each successful hold; rejected 429 calls do not advance the window. NEW (F167 Phase Q): event-backed retirement only works when waitSourceRef.expectedSignal is one of these exact structured keys: assignment, review_posted, ci_complete, comment_posted, managed_command_complete, user_message. Free-text values like \"CI pass\" remain valid narrative context but will NOT retire a timer by event, by design. NEW (F167 Phase P): wakeWhen — instead of a timed delay, specify a shell command to run. The server spawns it, captures output, and wakes you when it completes (or times out). Use for: pnpm gate, pnpm test, build commands — anything you would run_in_background and poll. wakeWhen is for LOCAL COMMANDS ONLY — it does not turn hold_ball into a universal \"smart wait\": waiting on a person is still @co-creator / @ that cat, waiting on a cloud event (PR / CI / issue) is still register_pr_tracking / register_issue_tracking. wakeWhen and wakeAfterMs are MUTUALLY EXCLUSIVE — provide exactly one.", - "descriptionDigest": "sha256:35f750d385393885361e717500b2838e570601e724419a2fa5a55abe1e654825", - "descriptionCharacters": 3393, - "descriptionTokensCl100kBase": 837, + "description": "Declare a bounded ball hold: keep the ball while waiting for a short, predictable condition, then get auto-re-invoked with your context. Use when: ball is clearly yours + nobody else can advance + short predictable wait (e.g. CI running, build compiling, PR checks pending) + you know exactly what to do next. NOT for: waiting for co-creator/user OR another cat to reply / answer / decide → @co-creator or @ that cat (their next message re-invokes you; a hold timer just stacks a redundant 2nd trigger). This is the #1 misuse — hold_ball is NOT a way to \"stay alive\" until a person replies; need review/approval → @ reviewer or @co-creator; need another cat to act → @ that cat; \"let me think\" / \"I'll hold for now\" → hesitation not hold, pick 接/退/升; review/analysis done → MUST @ author, conclusion ≠ endpoint; status updates → use post_message. Output: system schedules a one-shot wake-up after wakeAfterMs; you get re-invoked with reason + nextStep as trigger context. GOTCHA: max 3 holds per (thread, cat) within a rolling ~1h window — 4th call returns 429, you MUST pass (@ another cat or @co-creator). GOTCHA: the counter is process-local best-effort (in-memory on the API node); API restart or multi-instance deploys may reset it, so do not treat the 429 as a hard security boundary — treat it as a self-discipline guardrail. GOTCHA: hold is an EXCEPTION state, not a default exit. Most turns should end with @ someone, not hold. GOTCHA (F167 Phase M): only hold for harness-INVISIBLE waits — external conditions nothing will call you back about (cloud review verdict, remote CI, external webhook). Background work the harness already tracks (a background Bash command, a spawned task) AUTO-RE-INVOKES you on completion; holding for that just stacks a redundant wake on top. Ask \"will something call me back already?\" — if yes, do NOT hold. A co-creator or another cat sending a message into this thread IS such a callback (it re-invokes you), so \"waiting for co-creator to answer\" must be @co-creator, never a hold. GOTCHA: SINGLE-SLOT per (thread, cat) — calling hold_ball again while a previous hold is pending REPLACES the prior wake (prior taskId cancelled). This is intentional (KD-23): hold = \"持一个球\" exception, not a queue. If you need to track multiple waiting conditions, merge them into one nextStep (e.g. \"等 CI + @co-creator 确认\" 合并成一句). Rolling-window counter still ticks per call. NEW (F167 Phase Q): event-backed retirement only works when waitSourceRef.expectedSignal is one of these exact structured keys: assignment, review_posted, ci_complete, comment_posted, managed_command_complete, user_message. Free-text values like \"CI pass\" remain valid narrative context but will NOT retire a timer by event, by design. NEW (F167 Phase P): wakeWhen — instead of a timed delay, specify a shell command to run. The server spawns it, captures output, and wakes you when it completes (or times out). Use for: pnpm gate, pnpm test, build commands — anything you would run_in_background and poll. wakeWhen is for LOCAL COMMANDS ONLY — it does not turn hold_ball into a universal \"smart wait\": waiting on a person is still @co-creator / @ that cat, waiting on a cloud event (PR / CI / issue) is still register_pr_tracking / register_issue_tracking. wakeWhen and wakeAfterMs are MUTUALLY EXCLUSIVE — provide exactly one.", + "descriptionDigest": "sha256:81c8bb9760a979aa70d8b1abec9fb82bfcb2a8a3394b0d332c3f5b09b9a77868", + "descriptionCharacters": 3328, + "descriptionTokensCl100kBase": 804, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -5607,7 +5511,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:4ec2a08a7659258082971b489e1d226111bb1c5b486c2695beb1f1ce406b72ab", + "inputSchemaDigest": "sha256:9d7254d913858e953daa2f9fee246ef1497ecfe4d9d3d313c2945c3789678a6b", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -5621,7 +5525,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleHoldBall", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_home_state_self", @@ -5656,7 +5560,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/home-state-tools.js#handleHomeStateSelf", - "protectedImplementationDigest": "sha256:02c240b52fe2794afa164062446bb4f7da03c4b1e44b8a82af0c4c3d149bbcfc" + "protectedImplementationDigest": "sha256:1da4ff4a37d1daf1937039003aad0f8816ade2f22e6204a3f09869e6441fc854" }, { "name": "cat_cafe_inspect_office_document", @@ -5854,7 +5758,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/library-lifecycle-tools.js#handleLibraryArchive", - "protectedImplementationDigest": "sha256:212fbeacfee154c631e21d6e2ef16c26dc067ae805a24c7d9fc4435e05c13232" + "protectedImplementationDigest": "sha256:a6fa315030c5b8ac7f3c861597348bc806a45237a9ff99c6c527710f4da0a0d1" }, { "name": "cat_cafe_library_create", @@ -5935,7 +5839,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/library-lifecycle-tools.js#handleLibraryCreate", - "protectedImplementationDigest": "sha256:212fbeacfee154c631e21d6e2ef16c26dc067ae805a24c7d9fc4435e05c13232" + "protectedImplementationDigest": "sha256:a6fa315030c5b8ac7f3c861597348bc806a45237a9ff99c6c527710f4da0a0d1" }, { "name": "cat_cafe_library_dry_run", @@ -5985,7 +5889,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/library-lifecycle-tools.js#handleLibraryDryRun", - "protectedImplementationDigest": "sha256:212fbeacfee154c631e21d6e2ef16c26dc067ae805a24c7d9fc4435e05c13232" + "protectedImplementationDigest": "sha256:a6fa315030c5b8ac7f3c861597348bc806a45237a9ff99c6c527710f4da0a0d1" }, { "name": "cat_cafe_library_list", @@ -6033,7 +5937,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/library-lifecycle-tools.js#handleLibraryList", - "protectedImplementationDigest": "sha256:212fbeacfee154c631e21d6e2ef16c26dc067ae805a24c7d9fc4435e05c13232" + "protectedImplementationDigest": "sha256:a6fa315030c5b8ac7f3c861597348bc806a45237a9ff99c6c527710f4da0a0d1" }, { "name": "cat_cafe_library_rebuild", @@ -6079,7 +5983,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/library-lifecycle-tools.js#handleLibraryRebuild", - "protectedImplementationDigest": "sha256:212fbeacfee154c631e21d6e2ef16c26dc067ae805a24c7d9fc4435e05c13232" + "protectedImplementationDigest": "sha256:a6fa315030c5b8ac7f3c861597348bc806a45237a9ff99c6c527710f4da0a0d1" }, { "name": "cat_cafe_library_verify", @@ -6140,7 +6044,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/library-lifecycle-tools.js#handleLibraryVerify", - "protectedImplementationDigest": "sha256:212fbeacfee154c631e21d6e2ef16c26dc067ae805a24c7d9fc4435e05c13232" + "protectedImplementationDigest": "sha256:a6fa315030c5b8ac7f3c861597348bc806a45237a9ff99c6c527710f4da0a0d1" }, { "name": "cat_cafe_link_evolution_program_observation", @@ -6551,7 +6455,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/event-memory-tools.js#handleListEvents", - "protectedImplementationDigest": "sha256:43c2fde0f5b5b9417b04abc6f744ce8982899e0e555b081939dab686a06dab8f" + "protectedImplementationDigest": "sha256:a909f42974441ba495f39106f7594d62a7435e920911baf151cd351e25d504ac" }, { "name": "cat_cafe_list_external_runtime_sessions", @@ -6562,9 +6466,9 @@ ], "activeState": "migration-candidate", "description": "List orphan, dispatched, or IDE-direct external runtime sessions by runtime, cat, and recent activity. Use when an external runtime session looks lost, detached from the current thread, or needs cross-runtime drilldown. Use before reading digest/events when there is no normal Clowder AI thread yet.", - "descriptionDigest": "sha256:7fdb228b6de24bc07dcc6b6b88af7f00f506598bc61ab94e4d656190fbd78523", - "descriptionCharacters": 297, - "descriptionTokensCl100kBase": 57, + "descriptionDigest": "sha256:c5b75be45b29fc2fabcfb612feaa27294a7c806eee10280f9a768036a243fbb6", + "descriptionCharacters": 299, + "descriptionTokensCl100kBase": 59, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -6604,7 +6508,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/external-runtime-session-tools.js#handleListExternalRuntimeSessions", - "protectedImplementationDigest": "sha256:9aa0f2492e14becb363af8749547360d22d7b472d51054bbf17fcfd3739a66b4" + "protectedImplementationDigest": "sha256:086d5839e8f37864a5da2aa6156fac40599e74550b4afe05a1010fbd3dc28776" }, { "name": "cat_cafe_list_labels", @@ -6652,7 +6556,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleListLabels", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_list_paw_feel_inbox", @@ -6822,7 +6726,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/recent-tools.js#handleListRecent", - "protectedImplementationDigest": "sha256:74d41f7949ca53e78f1e511094b088b16460d26ca1d765aa204a864b90f01c33" + "protectedImplementationDigest": "sha256:133b517ab466bf9501ccaf183ddc622c154cbec5f4f48be9159a10533d51faeb" }, { "name": "cat_cafe_list_schedule_templates", @@ -6863,7 +6767,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/schedule-tools.js#handleListScheduleTemplates", - "protectedImplementationDigest": "sha256:a12977739952606f97e28299a5484b843f71d9746f6e17ec9151ae3273d2f2f2" + "protectedImplementationDigest": "sha256:fcc50f0e0f64be61ffd71b2d3b2a33a9ca547cc4a1acb0a1569b20acdc9ab5fd" }, { "name": "cat_cafe_list_session_chain", @@ -6920,7 +6824,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/session-chain-tools.js#handleListSessionChain", - "protectedImplementationDigest": "sha256:c03fb32907023398da1057edbac8ed65e8b4671951bf0199b3b5c34e850ea5b5" + "protectedImplementationDigest": "sha256:4370e2dc51956dd35fe6b56a4d8200d50fa4bd892f4ec4b7cc83fe85b28ac43e" }, { "name": "cat_cafe_list_tasks", @@ -6994,7 +6898,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleListTasks", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_list_threads", @@ -7055,7 +6959,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleListThreads", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_mark_generalizable", @@ -7104,7 +7008,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/distillation-tools.js#handleMarkGeneralizable", - "protectedImplementationDigest": "sha256:96c90031ffce7e26307b789bf7294c6c4f00b7650d705f46cc038dc9a96d5c03" + "protectedImplementationDigest": "sha256:8a8ca7cb6f2047c409ff6ebd91f4409e8bea0182e93ddbeaa731949b0523118b" }, { "name": "cat_cafe_multi_mention", @@ -7115,9 +7019,9 @@ ], "activeState": "canonical", "description": "Fan out to and collect responses from up to 3 cats, with explicit parallel action leases when the reviews are intentionally independent. Use when: deliberate independent multi-cat review/ideation needs aggregation. NOT for: a new same-thread single successor (use post_message with action.mode=\"single\") or simple cross-thread notification (use cross_post_message). Output: returns request/status immediately; admitted responses are aggregated and routed to callbackTo, while conflicts return safe_wait without creating request/timer/queue work. REQUIRES: searchEvidenceRefs (list what you searched first) OR overrideReason (why you're skipping search). GOTCHA: legacy action.mode=\"single\" callers remain accepted during migration and still require one target + idempotencyKey; new single-successor calls should move to post_message. GOTCHA: callbackTo is usually your own catId. For the same subjectRef+actionFamily+successorSlot, accept safe_wait. Fallback must use replace with the active leaseId/generation after server-recorded failed/canceled/unavailable evidence. Only deliberate independent review, #ideate, or explicit operator fan-out may use mode=parallel with parallelIntent.", - "descriptionDigest": "sha256:6188f77a761b21d7e8fe7dc3fe7ecb6c9e63d1c1a57d7dc6e2e82e3d747bab3e", - "descriptionCharacters": 1182, - "descriptionTokensCl100kBase": 236, + "descriptionDigest": "sha256:c5ca64f33cae925bff6890efae7df60b2a3cb3f73f9fd38a28f68f96667d759c", + "descriptionCharacters": 1187, + "descriptionTokensCl100kBase": 235, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -7309,7 +7213,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:0427b01baed294ea7b5ebde8c43e513ec74d730731edb391080ce4801c34ffe8", + "inputSchemaDigest": "sha256:37fac4aa032d3cdea7b4e7647d86a21bacd2f2a45c2f33e7ad35f83f3f9f83f9", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -7323,7 +7227,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleMultiMention", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_nominate_for_global", @@ -7380,7 +7284,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/distillation-tools.js#handleNominateForGlobal", - "protectedImplementationDigest": "sha256:96c90031ffce7e26307b789bf7294c6c4f00b7650d705f46cc038dc9a96d5c03" + "protectedImplementationDigest": "sha256:8a8ca7cb6f2047c409ff6ebd91f4409e8bea0182e93ddbeaa731949b0523118b" }, { "name": "cat_cafe_offer_custody", @@ -7440,7 +7344,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleOfferCustody", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_open_evolution_round", @@ -7582,7 +7486,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:4d1f723f22c279626311ccf34ab69eb441cd04188772e46c6cb9bdb59aa8105b", + "inputSchemaDigest": "sha256:32155590d5a757678f54b228d6a317162c3dfeb780a9d16d41f1d88c2bdf054f", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -7596,7 +7500,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handleOpenWithWorkspaceNavigator", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_post_message", @@ -7626,10 +7530,6 @@ "minLength": 1, "type": "string" }, - "acknowledgeHeld": { - "description": "F254 Freshness Gate escape hatch. Set to true to force-send your message even when the thread has unseen messages. Use only after you have reviewed the held envelope previews and decided your message is still appropriate.", - "type": "boolean" - }, "action": { "additionalProperties": false, "description": "Optional same-thread structured successor identity. New dispatches require mode=single; a parallel holder may use returnToPredecessor with one predecessor target to record only its rejected-ownership terminal. Requires explicit clientMessageId and exactly one targetCats entry. Use claimOrigin=existing_standing plus groundingEvidenceRef to claim verified standing through the same custody CAS. New claims require terminalPredicate typed parameters; server catalog owns completion semantics, and carrier exit/text never counts as action success. A mismatched current holder returns with returnToPredecessor={leaseId, expectedGeneration, groundingEvidenceRef}; targetCats must name the persisted predecessor. Use multi_mention for deliberate parallel review/ideation. Direct executable action custody is closed to implement + implementer + task_done. Local cat review uses an ordinary durable handoff with localReviewVerdict + reviewedHeadSha + accepted-source fields; external review enters through an approved proposedAction and records its verdict through the external-review contract. review/reviewer, merge/pr_merged, and every other reserved pair are unavailable on direct carriers. subjectRef must use pr:/# (for example pr:zts212653/cat-cafe#2943) or subject::. GitHub URL forms and SHA suffixes such as github:owner/repo#2943@abc123 are invalid.", @@ -7845,7 +7745,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:21e42ecca5cd259bdc9c1f4c622f066911831cbda5f625ae11d52672b11baf85", + "inputSchemaDigest": "sha256:0670f3c08ec93df773d2de6cc7362ec29bc58da67cef3c90256e7434115bd99a", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -7862,7 +7762,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handlePostMessage", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_prepare_artifact_review", @@ -8056,7 +7956,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handlePrepareRequestReviewConsumption", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_prepare_skill_consumption", @@ -8099,7 +7999,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handlePrepareSkillConsumption", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_preview_cat_life_settings", @@ -8249,9 +8149,9 @@ ], "activeState": "canonical", "description": "Open a localhost app in the Hub Browser Preview panel. Use after starting or discovering a dev server, or when the user asks to see frontend changes. Result: the Hub Browser panel auto-opens the localhost target through the preview gateway. Output: the accepted request plus deliveryStatus=applied|queued|blocked|unconfirmed. GOTCHA: allowed:true only means admission; only deliveryStatus=applied proves a connected Hub client applied the preview — never report \"已打开\" on unconfirmed/queued/blocked. For operator or visual acceptance, pass visiblePageAdmission; applied then requires an exact iframe origin/port, embedded client revision, and DOM proof. threadId is required for agent-key auth and may be omitted for invocation auth. GOTCHA: validate the target dev server first; shared persistent MCP callers pass agentKeyCatId; do not handwrite curl to /api/preview/auto-open.", - "descriptionDigest": "sha256:4ae3acdb6fb6b74e3acfb890d13882c503e414668a5726428643ae6c77dec4af", - "descriptionCharacters": 872, - "descriptionTokensCl100kBase": 184, + "descriptionDigest": "sha256:5334534836c2f58390940c43ff923b9c0a2fc913617481ca8fb2f55cb7bd1e70", + "descriptionCharacters": 877, + "descriptionTokensCl100kBase": 183, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -8354,7 +8254,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:95f00687519f5f73d260ee08a4a610c072fa93cc11a95a7811196931f5e281a5", + "inputSchemaDigest": "sha256:6c219b5995ec6f8c64476da1fb787e58c808c177001323aa8a1add34066e3526", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -8369,7 +8269,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/hub-action-tools.js#handlePreviewOpen", - "protectedImplementationDigest": "sha256:f8e1b15a5d525715963cea26d19c1de327a2ffd0a9c96ea7005dcd84efc3aeb5" + "protectedImplementationDigest": "sha256:1d27d817dbc4c2b3ed2d202c10a1a21b618b8359d8354cc60165c1b616b992be" }, { "name": "cat_cafe_preview_scheduled_task", @@ -8435,7 +8335,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/schedule-tools.js#handlePreviewScheduledTask", - "protectedImplementationDigest": "sha256:a12977739952606f97e28299a5484b843f71d9746f6e17ec9151ae3273d2f2f2" + "protectedImplementationDigest": "sha256:fcc50f0e0f64be61ffd71b2d3b2a33a9ca547cc4a1acb0a1569b20acdc9ab5fd" }, { "name": "cat_cafe_propose_entity", @@ -8446,9 +8346,9 @@ ], "activeState": "migration-candidate", "description": "Propose a workspace entity for the registry. Returns a proposalId — the entity is only registered after the operator approves in the Approval Hub. Use when: (1) responding to a registration nudge with pre-filled params; (2) the operator explicitly requests entity registration; or (3) the current turn verified a durable workspace name/handle/alias mapping and provenance identifies its source. NOT for an unverified or bare proper name, bulk proper-noun extraction, or owner-private facts, relationships, or interactions (use cat_cafe_propose_person_memory). Do not infer identity from spelling or silently write either lane. GOTCHA: correcting a pending/private F276 card is not an Entity mutation; use cat_cafe_propose_person_memory with replacesProposalId, or withdraw the F276 proposal. stance includes negative values: \"rejected\" marks terms the registry should explicitly NOT match, \"critique_target\" marks entities under critique (e.g. architectural patterns being evaluated). visibilityScope: only \"workspace\" accepted (KD-7 gate — private scope deferred until resolver supports visibility filtering).", - "descriptionDigest": "sha256:2fa5009da3bfbd8a670e4fdd2c1566f2fbc22d008b70746cfd45135e2953058f", - "descriptionCharacters": 1100, - "descriptionTokensCl100kBase": 222, + "descriptionDigest": "sha256:a23ccb2edfafc25a8bf1f983a1acc22f6c407acd43318e0f684911b965718a8c", + "descriptionCharacters": 1110, + "descriptionTokensCl100kBase": 220, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -8565,7 +8465,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:ffd1905da8b07e632c173e6f6a8daf7fa6665b4b68846a0afbe6038763a74d03", + "inputSchemaDigest": "sha256:bc3197ffb2f0018407fb6a365d9eca2e698c47070f6271bde55765bfdbaa8aa6", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -8579,7 +8479,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleProposeEntity", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_propose_eval_repair", @@ -8630,7 +8530,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/eval-lifecycle-tools.js#handleProposeEvalRepair", - "protectedImplementationDigest": "sha256:39fdc46f99f7c273bcf0994af2289d02722e26231823aacb5b029289373a6ee0" + "protectedImplementationDigest": "sha256:4a6defc14988550031dfd4d7cc2f9d6c7b650410de5a438ddd5b02c24cf63a67" }, { "name": "cat_cafe_propose_person_memory", @@ -9540,7 +9440,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleProposePersonMemory", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_propose_profile_update", @@ -9551,9 +9451,9 @@ ], "activeState": "canonical", "description": "Propose an update to a profile layer — the \"养熟循环\" digest entry point (F231 KD-12/KD-18). Supports two layers: primer (default, per-persona relationship primer) and corpus (owner-wide shared facts visible to all personas). Output: returns a proposalId, NOT a written file; the content is only written after operator approval (reject/expire = nothing changes). Use when: the content is a durable fact about the authenticated person or this persona-operator relationship — personal context, preferred address, or a relationship-specific communication boundary. Use targetLayer:corpus for owner-wide personal facts independent of persona relationship (birthday, background, preferences that all cats should know). NOT for reusable judgments about what makes output, design, expression, architecture, or systems good (use cat_cafe_propose_taste), repeated operational/tool/process rules (use code-as-harness), or one-off context. A correction, praise, or Magic Word does not choose the lane; semantic content does. afterContent is the COMPLETE new content (whole-file replacement, not a diff) — include everything you want kept. Use cat_cafe_read_profile first when preserving existing content matters. GOTCHA: models sharing one persona also share the primer, so afterContent must preserve relevant family continuity. signalKind records provenance: cat-declared vs cvo-instructed.", - "descriptionDigest": "sha256:6a0b8783662f2a88e92d31c95161b9865b589f944b6b3e6f9dbb860999ab0999", - "descriptionCharacters": 1366, - "descriptionTokensCl100kBase": 285, + "descriptionDigest": "sha256:d0954c892767d93ee9d22697e7d56a01f5342e37eb28d3198035b19866cd47b9", + "descriptionCharacters": 1376, + "descriptionTokensCl100kBase": 283, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -9605,7 +9505,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:ae6a5c4908f837d8c5f20dfd92f015c984e242bc38f0e5d4511ff7b8526b56cb", + "inputSchemaDigest": "sha256:4ae6d0dd717e72a3bb604e6064d529d4bee0ea831960870b53fef6ce9ba0c20c", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -9619,7 +9519,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleProposeProfileUpdate", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_propose_session_handoff", @@ -9696,7 +9596,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleProposeSessionHandoff", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_propose_taste", @@ -9707,9 +9607,9 @@ ], "activeState": "migration-candidate", "description": "Propose a taste vignette capturing a operator preference/aesthetic signal (F221 Taste Capture Loop). Output: returns a proposalId; the vignette is only written after operator approval in the Approval Hub. Use when: the content is a reusable judgment about what makes output, design, expression, architecture, or system behavior good or bad — including positive and negative taste signals. NOT for durable personal facts or relationship-specific context (use cat_cafe_propose_profile_update), repeated operational/tool/process rules (use code-as-harness), or transient reactions. A correction or praise does not choose the lane; semantic content does. The 7 taste dimensions cover relationship-stance, cognitive-honesty, architecture-aesthetics, visual-quality, authentic-expression, system-philosophy, and creative-craft. GOTCHA: relationship-stance stores a reusable stance such as partner-not-tool, not a personal fact about the current operator. Preserve original language in quote — taste signals lose meaning when paraphrased.", - "descriptionDigest": "sha256:ddb1b07e280b76843fa4764f3922f5132617c524c2eb779040142eb0110b8735", - "descriptionCharacters": 1016, - "descriptionTokensCl100kBase": 204, + "descriptionDigest": "sha256:54b522191e98f038bc0b8e3e362b4ab94234b7ed401b554635162e42f3cd03cb", + "descriptionCharacters": 1031, + "descriptionTokensCl100kBase": 201, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -9779,7 +9679,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:b4756cfd8b880ec8f3577cc6ce536deaa5f8809b1c587737ba51a8d89af1561e", + "inputSchemaDigest": "sha256:0c8047c1f81f539a1bbbaa113432900f7516ef4a1be886f0ab3a3bdc01ce8901", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -9793,7 +9693,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleProposeTaste", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_propose_thread", @@ -9881,7 +9781,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:7a62ac68966a47c3d375fec3a1f6139219ad4cf0d2346958cbd55bca7d12f7cc", + "inputSchemaDigest": "sha256:cd3b75fade87a8926b94f92016935d5283790c49e50ad0e135eae035368d790a", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -9895,7 +9795,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleProposeThread", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_publish_verdict", @@ -11048,7 +10948,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:d162569546c80e42939374fc44bbeea4facc73e6ca7647647997fdc125ca4140", + "inputSchemaDigest": "sha256:190c0a4625f9004815197d7a25fb99e34fcd3fb827ebf5679384297d8ad09183", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -11064,7 +10964,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/external-runtime-session-tools.js#handleReadExternalRuntimeSession", - "protectedImplementationDigest": "sha256:9aa0f2492e14becb363af8749547360d22d7b472d51054bbf17fcfd3739a66b4" + "protectedImplementationDigest": "sha256:086d5839e8f37864a5da2aa6156fac40599e74550b4afe05a1010fbd3dc28776" }, { "name": "cat_cafe_read_file_slice", @@ -11169,7 +11069,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/session-chain-tools.js#handleReadInvocationDetail", - "protectedImplementationDigest": "sha256:c03fb32907023398da1057edbac8ed65e8b4671951bf0199b3b5c34e850ea5b5" + "protectedImplementationDigest": "sha256:4370e2dc51956dd35fe6b56a4d8200d50fa4bd892f4ec4b7cc83fe85b28ac43e" }, { "name": "cat_cafe_read_meeting_artifact", @@ -11327,7 +11227,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleReadProfile", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_read_session_digest", @@ -11374,7 +11274,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/session-chain-tools.js#handleReadSessionDigest", - "protectedImplementationDigest": "sha256:c03fb32907023398da1057edbac8ed65e8b4671951bf0199b3b5c34e850ea5b5" + "protectedImplementationDigest": "sha256:4370e2dc51956dd35fe6b56a4d8200d50fa4bd892f4ec4b7cc83fe85b28ac43e" }, { "name": "cat_cafe_read_session_events", @@ -11439,7 +11339,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/session-chain-tools.js#handleReadSessionEvents", - "protectedImplementationDigest": "sha256:c03fb32907023398da1057edbac8ed65e8b4671951bf0199b3b5c34e850ea5b5" + "protectedImplementationDigest": "sha256:4370e2dc51956dd35fe6b56a4d8200d50fa4bd892f4ec4b7cc83fe85b28ac43e" }, { "name": "cat_cafe_realtime_companion_start", @@ -11450,9 +11350,9 @@ ], "activeState": "canonical", "description": "Start the narrow experimental Codex Realtime companion for the current cat and exact thread. Use when: running in Alpha after the user explicitly asks for watch-video commentary or meeting companionship and F195 audio capture is already active on that same thread. NOT for: non-Alpha deployments, starting a microphone, arbitrary realtime prompts, raw host control, or background always-on listening. Output: an active text-only companion session; transcript remains owned by F195 and replies use the existing Clowder AI message store.", - "descriptionDigest": "sha256:7f51f9c51d067913a24ec208e465e5f2a595de8f0a8f348ebc2ac4ab15465b6c", - "descriptionCharacters": 533, - "descriptionTokensCl100kBase": 100, + "descriptionDigest": "sha256:bc1ec4a0741560ba6c604834dec72dbcf6c9dfbcfe98396026b2eb0717b3a2ea", + "descriptionCharacters": 535, + "descriptionTokensCl100kBase": 102, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -11477,7 +11377,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:bbf43f35fc318c5a3a388d0b5c40740c99f06ae49b714be998b42c9f94174449", + "inputSchemaDigest": "sha256:66299aa867941506bf502f00ea13969609fcb19199b6c1d49a671efba908ddfe", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -11492,7 +11392,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/realtime-companion-tools.js#handleRealtimeCompanionStart", - "protectedImplementationDigest": "sha256:96bf55f906012eb5f89d17ba8df2aec3bdd5ee142c422a01d76706be851f25b0" + "protectedImplementationDigest": "sha256:c509e18e3588af8bea2c5bbb1a917a57df4838849e4bf601de04a951bf02e0fd" }, { "name": "cat_cafe_realtime_companion_status", @@ -11503,9 +11403,9 @@ ], "activeState": "canonical", "description": "Read whether the current cat has an active experimental realtime companion on an exact Clowder AI thread. Use when: running in Alpha to check readiness after start, confirm shutdown, or diagnose a missing companion reply. NOT for: non-Alpha availability claims, reading transcript content, or starting/inspecting F195 audio capture. Output: read-only active/inactive state plus the bound consumer and native session coordinates when active.", - "descriptionDigest": "sha256:1e91396f2e21544af8239c271779e3859b4e9e76a08c84159351af15d08c0bae", - "descriptionCharacters": 438, - "descriptionTokensCl100kBase": 82, + "descriptionDigest": "sha256:de1513294d8d2dd1cf1abb37bd2266943fa2375c7377183c70a5fcac5f282f1a", + "descriptionCharacters": 440, + "descriptionTokensCl100kBase": 84, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -11521,7 +11421,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:8f26ee0266f9e0955379bbacc2a86948e24fc464b84dedf49cdab6548de58503", + "inputSchemaDigest": "sha256:ab43ba51145d1c8057f1b1ed1b914c8d631f99a1b4e3012eb1e68e540a8504d9", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -11536,7 +11436,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/realtime-companion-tools.js#handleRealtimeCompanionStatus", - "protectedImplementationDigest": "sha256:96bf55f906012eb5f89d17ba8df2aec3bdd5ee142c422a01d76706be851f25b0" + "protectedImplementationDigest": "sha256:c509e18e3588af8bea2c5bbb1a917a57df4838849e4bf601de04a951bf02e0fd" }, { "name": "cat_cafe_realtime_companion_stop", @@ -11565,7 +11465,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:8f26ee0266f9e0955379bbacc2a86948e24fc464b84dedf49cdab6548de58503", + "inputSchemaDigest": "sha256:ab43ba51145d1c8057f1b1ed1b914c8d631f99a1b4e3012eb1e68e540a8504d9", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -11580,7 +11480,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/realtime-companion-tools.js#handleRealtimeCompanionStop", - "protectedImplementationDigest": "sha256:96bf55f906012eb5f89d17ba8df2aec3bdd5ee142c422a01d76706be851f25b0" + "protectedImplementationDigest": "sha256:c509e18e3588af8bea2c5bbb1a917a57df4838849e4bf601de04a951bf02e0fd" }, { "name": "cat_cafe_recall_person_relationship", @@ -11630,7 +11530,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRecallPersonRelationship", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_record_eval_lifecycle", @@ -11641,9 +11541,9 @@ ], "activeState": "migration-candidate", "description": "Write one authenticated fact to an actionable eval finding stable-case lifecycle. Use when: the assigned owner records an action plan, verified main/live commit, or requests re-evaluation; the pinned eval cat records pass/fail. NOT for: acknowledging without a durable task/F167 lease, caller-authored actor/SLA, suppressing on behalf of operator, fabricating runtime activation, or creating a second task for a repeated verdict cycle. Output: appended/duplicate/conflict plus the replayed stable-case projection with task, lease, main, live, and re-eval state. GOTCHA: main and live are separate server-verified facts; use the same commit and current case sequence.", - "descriptionDigest": "sha256:45c7fb4301a896e27376321121eecba38482d25841be74b8cb6460ffedaf9df2", - "descriptionCharacters": 661, - "descriptionTokensCl100kBase": 137, + "descriptionDigest": "sha256:a8b914c7856c4d2780607a62d5cf9e8c1ac29bb81ffaa208bb988f8cbfcb8766", + "descriptionCharacters": 666, + "descriptionTokensCl100kBase": 136, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -11848,7 +11748,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/eval-lifecycle-tools.js#handleRecordEvalLifecycle", - "protectedImplementationDigest": "sha256:39fdc46f99f7c273bcf0994af2289d02722e26231823aacb5b029289373a6ee0" + "protectedImplementationDigest": "sha256:4a6defc14988550031dfd4d7cc2f9d6c7b650410de5a438ddd5b02c24cf63a67" }, { "name": "cat_cafe_record_evolution_evaluation", @@ -12106,9 +12006,9 @@ ], "activeState": "migration-candidate", "description": "Atomically record an external PR review verdict together with its delivery custody for the current HEAD. Use when: you were woken to re-review a configured maintainer-owned external PR and have reached a verdict in this turn. NOT for: ordinary PR tracking, cloud-review progress, self-authored PRs, merging/closing a PR, or a verdict that is not ready to be externally delivered. Output: accepts the typed submission exactly once and returns canonical delivered/pending_delivery, or pending_verification while the existing F168 collector catches up; a later collector tick settles that same submission without a retry call. It never merges or closes GitHub objects. GOTCHA: there is no naked-verdict branch—choose delivered with a same-PR GitHub review/comment URL, or pending_delivery with a concrete reason. GOTCHA: pending_verification is accepted custody, not an instruction to submit again; persistent failed CI/cloud evidence, stale HEAD, wrong principal, and stale leases remain fail-closed. GOTCHA: set userNudgeRequired=true only for an explicit operator reminder, never from inferred chat tone. GOTCHA: reviewedHeadSha, assigned reviewer identity, repo policy, terminal state, and optional action lease are verified server-side using invocation credentials; stale or agent-key-only calls fail closed.", - "descriptionDigest": "sha256:d02bae0d62fa12941e65f11768669db9cacf0fbc504473b8e47d8c37d952526f", - "descriptionCharacters": 1305, - "descriptionTokensCl100kBase": 255, + "descriptionDigest": "sha256:ca37ddfc43224c2f1d2be85c2211f5f2c16d5cd863cc484e93a675f6b7a72723", + "descriptionCharacters": 1310, + "descriptionTokensCl100kBase": 254, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -12224,7 +12124,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:64b12020d7e304095ffc2f30bb79cdd062b7771052e260652710ca017fe84cd8", + "inputSchemaDigest": "sha256:a10b56a198913df1c88bbfa5b89aa81cd5a7c4665be4d205ac551d8d4cedccb7", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -12238,7 +12138,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/external-review-verdict-tool.js#handleExternalReviewVerdict", - "protectedImplementationDigest": "sha256:3614d97e7c6c6ad350e8234f5aad9cd6bad083aba7776793cd0d63739a317965" + "protectedImplementationDigest": "sha256:96ac118ceacd99bdf5c7b3bd2d30198b1cbb7e989e6afbb9afb3ac042738f724" }, { "name": "cat_cafe_record_memory_cue_outcome", @@ -12297,7 +12197,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRecordMemoryCueOutcome", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_record_proactive_memory_abstention", @@ -12371,7 +12271,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRecordProactiveMemoryAbstention", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_record_request_review_consumption", @@ -12422,7 +12322,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handleRecordRequestReviewConsumption", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_record_request_review_owner_fact", @@ -12755,7 +12655,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/skill-consumption-tools.js#handleRecordRequestReviewOwnerFact", - "protectedImplementationDigest": "sha256:b25f4ebffc8964ae7587065d771d0f75d18eb6d6bfe8f00262b7bf64df0902f4" + "protectedImplementationDigest": "sha256:9c97e39dbb663eefc214c86f1b2be7cbcbc11bbf0096ea4c956f1c223492dfd5" }, { "name": "cat_cafe_recover_external_review_verdict", @@ -12819,7 +12719,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/external-review-verdict-tool.js#handleRecoverExternalReviewVerdict", - "protectedImplementationDigest": "sha256:3614d97e7c6c6ad350e8234f5aad9cd6bad083aba7776793cd0d63739a317965" + "protectedImplementationDigest": "sha256:96ac118ceacd99bdf5c7b3bd2d30198b1cbb7e989e6afbb9afb3ac042738f724" }, { "name": "cat_cafe_redact_person_memory_item", @@ -12893,7 +12793,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRedactPersonMemoryItem", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_register_external_runtime_session", @@ -12904,9 +12804,9 @@ ], "activeState": "migration-candidate", "description": "Register an Antigravity IDE-direct runtime session using persistent agent-key auth. Use when an IDE-direct conversation needs Clowder AI session-chain evidence without invocation callback credentials. Shared Antigravity MCP GOTCHA: pass agentKeyCatId so the right sidecar key is selected.", - "descriptionDigest": "sha256:77e26add7cf9343473daf0110af2413679fcc61aa1be20f27f068b434f97d087", - "descriptionCharacters": 286, - "descriptionTokensCl100kBase": 54, + "descriptionDigest": "sha256:fa07d9ea6f338095a3f6c89f163b6a894b0e4cdcb77d2f36f97ad2ca24309be1", + "descriptionCharacters": 288, + "descriptionTokensCl100kBase": 56, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -13016,7 +12916,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/external-runtime-session-tools.js#handleRegisterExternalRuntimeSession", - "protectedImplementationDigest": "sha256:9aa0f2492e14becb363af8749547360d22d7b472d51054bbf17fcfd3739a66b4" + "protectedImplementationDigest": "sha256:086d5839e8f37864a5da2aa6156fac40599e74550b4afe05a1010fbd3dc28776" }, { "name": "cat_cafe_register_issue_tracking", @@ -13115,7 +13015,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRegisterIssueTracking", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_register_pr_tracking", @@ -13280,7 +13180,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRegisterPrTracking", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_register_scheduled_task", @@ -13291,9 +13191,9 @@ ], "activeState": "canonical", "description": "Submit a new scheduled task from a template for operator approval. Use after preview_scheduled_task when the user confirms the draft. NOT for direct activation or unsupported ad-hoc task definitions. Output: one anchored Approval Hub proposal; the task is not persisted or run until the operator approves. Supports cron, interval, and once triggers. GOTCHA: trigger and params are JSON strings; shared persistent MCP callers pass agentKeyCatId and an owned deliveryThreadId.", - "descriptionDigest": "sha256:64b13ab32efd878db1aebbd088629dfdc727810f769042aa16ce24eb2516ce92", - "descriptionCharacters": 464, - "descriptionTokensCl100kBase": 93, + "descriptionDigest": "sha256:e96c9a1acb756f2140eda3323aca3f769f455e136d194c18b8499ffd90c3657e", + "descriptionCharacters": 474, + "descriptionTokensCl100kBase": 91, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -13358,7 +13258,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/schedule-tools.js#handleRegisterScheduledTask", - "protectedImplementationDigest": "sha256:a12977739952606f97e28299a5484b843f71d9746f6e17ec9151ae3273d2f2f2" + "protectedImplementationDigest": "sha256:fcc50f0e0f64be61ffd71b2d3b2a33a9ca547cc4a1acb0a1569b20acdc9ab5fd" }, { "name": "cat_cafe_remove_scheduled_task", @@ -13369,9 +13269,9 @@ ], "activeState": "migration-candidate", "description": "Request permanent removal of a user-created dynamic scheduled task by task ID. Use when the user asks a cat to permanently delete a scheduled task. NOT for pause/resume or builtin system tasks. Output: one anchored Approval Hub proposal; the task remains active until the operator approves. GOTCHA: persistent agent-key callers must pass agentKeyCatId and an owned sourceThreadId.", - "descriptionDigest": "sha256:00dc61302e94c85926eb549825002ba5f4ced832c922bb8daf3a7a20b05219e7", - "descriptionCharacters": 375, - "descriptionTokensCl100kBase": 75, + "descriptionDigest": "sha256:9afa46e265a634d0de472cb652da5aa0673ec591de256108821f14d4402b2f52", + "descriptionCharacters": 380, + "descriptionTokensCl100kBase": 74, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -13412,7 +13312,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/schedule-tools.js#handleRemoveScheduledTask", - "protectedImplementationDigest": "sha256:a12977739952606f97e28299a5484b843f71d9746f6e17ec9151ae3273d2f2f2" + "protectedImplementationDigest": "sha256:fcc50f0e0f64be61ffd71b2d3b2a33a9ca547cc4a1acb0a1569b20acdc9ab5fd" }, { "name": "cat_cafe_request_user_input", @@ -13648,9 +13548,9 @@ ], "activeState": "migration-candidate", "description": "Retain a durable memory item through Clowder AI callback endpoint. Use when you discover an important insight, decision, or lesson that should persist across sessions. Examples: architectural decisions made during discussion, gotchas discovered while debugging, cross-cat agreements. NOT for transient notes — only for knowledge worth remembering long-term. TIP: Add descriptive tags (e.g. [\"redis\", \"pitfall\"]) so future search_evidence queries can find it.", - "descriptionDigest": "sha256:c4a61025c2421768915265b3a681c9205a09451cb96aa330e933fe7ff950b290", - "descriptionCharacters": 456, - "descriptionTokensCl100kBase": 88, + "descriptionDigest": "sha256:29759a1327409bd02b74c57f2f329462d439b127777548d7e12c5b8e1e18b0b2", + "descriptionCharacters": 458, + "descriptionTokensCl100kBase": 90, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -13695,7 +13595,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-memory-tools.js#handleCallbackRetainMemory", - "protectedImplementationDigest": "sha256:d4228bd7d443ef9bd681408546c952b6537e66407c87ce5d79cbb99923532baf" + "protectedImplementationDigest": "sha256:379acb6bc579758296b6aeb468f22fc631285f7df77b0acc184d181aeca0305b" }, { "name": "cat_cafe_retire_person_claim", @@ -13758,7 +13658,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRetirePersonClaim", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_retry_custody_admission", @@ -13892,7 +13792,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleRetryCustodyAdmission", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_review_distillation", @@ -13951,7 +13851,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/distillation-tools.js#handleReviewDistillation", - "protectedImplementationDigest": "sha256:96c90031ffce7e26307b789bf7294c6c4f00b7650d705f46cc038dc9a96d5c03" + "protectedImplementationDigest": "sha256:8a8ca7cb6f2047c409ff6ebd91f4409e8bea0182e93ddbeaa731949b0523118b" }, { "name": "cat_cafe_run_perspective", @@ -13999,7 +13899,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/perspective-tools.js#handleRunPerspective", - "protectedImplementationDigest": "sha256:23b5e84e505be765c73bb0cd71f759f74032f26d7c60fdf4671e41ccf058e63c" + "protectedImplementationDigest": "sha256:030a1a5c933ef1a7fc16b8629153967e6811833fab319517e282a0ef36b77a95" }, { "name": "cat_cafe_search_evidence", @@ -14135,7 +14035,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/evidence-tools.js#handleSearchEvidence", - "protectedImplementationDigest": "sha256:5f88977d9508f737d30903c1c105d99c2c29989f93c4ab68e5f5814618dc3643" + "protectedImplementationDigest": "sha256:60e8a63159c9cd1a4c9457868b4dbe283adc9fae1b2b9d938dfd4fd11aa2f678" }, { "name": "cat_cafe_set_read_mode", @@ -14146,9 +14046,9 @@ ], "activeState": "migration-candidate", "description": "Set session-level mode for cc native Read/Grep/Glob output (F236 Phase C). \"anchor\" mode: PostToolUse hook replaces tool output with a locator (file path + total lines + drill pointer) — saves context tokens. \"full\" mode: pass-through, original output unchanged (default). Bounded Read (offset/limit present) ALWAYS passes through regardless of mode — this is your escape hatch to drill into specific file sections after anchor. Workflow: set_read_mode(\"anchor\") → Read/Grep gives locator → Read(file_path=..., offset=X, limit=Y) for the real slice. GOTCHA: Mode is per-invocation (scoped to the current Clowder AI session, cleaned up on session end). Does NOT persist across sessions. GOTCHA: Requires Clowder AI managed session (CAT_CAFE_INVOCATION_ID).", - "descriptionDigest": "sha256:2d00bde3e79ad5cae2af45d60b44e651f082d74103a7b592f051a5988fb4aaf0", - "descriptionCharacters": 751, - "descriptionTokensCl100kBase": 170, + "descriptionDigest": "sha256:479ce456c725b42568458ecbe2ca96ee4d89525dace5965a2978c51d4efb7b15", + "descriptionCharacters": 755, + "descriptionTokensCl100kBase": 174, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -14182,7 +14082,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleSetReadMode", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_set_thread_metadata", @@ -14355,7 +14255,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleSetThreadMetadata", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_settle_present_loop", @@ -15057,7 +14957,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleStartGuide", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_start_vote", @@ -15135,7 +15035,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleStartVote", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_submit_evolution_preparation", @@ -16286,7 +16186,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/game-action-tools.js#handleSubmitGameAction", - "protectedImplementationDigest": "sha256:1982fdc709ff5293be0efc7b4f28236900f0e9636065876647c2b30b77a7a6a1" + "protectedImplementationDigest": "sha256:3ae84ab985e2855ef0a15137fb3ef600b778b026236cbe1d58a7a0a6f1cb4ae4" }, { "name": "cat_cafe_teleport", @@ -16331,7 +16231,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:42a542a4a49e87341295ffdd2dd8e4ce95365ff565bf27196ae50f29ab7cd155", + "inputSchemaDigest": "sha256:0b727df4d88a605a4dd4e7d75da8587d7a9ed1745d5f2a9212b932011f82ceb3", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -16346,7 +16246,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/event-memory-tools.js#handleTeleport", - "protectedImplementationDigest": "sha256:43c2fde0f5b5b9417b04abc6f744ce8982899e0e555b081939dab686a06dab8f" + "protectedImplementationDigest": "sha256:a909f42974441ba495f39106f7594d62a7435e920911baf151cd351e25d504ac" }, { "name": "cat_cafe_triage_paw_feel", @@ -16722,7 +16622,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleUnregisterTracking", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_update_bootcamp_state", @@ -16850,7 +16750,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleUpdateBootcampState", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_update_entrusted_work", @@ -16962,7 +16862,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleUpdateEntrustedWork", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_update_evolution_program", @@ -17309,7 +17209,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleUpdateGuideState", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_update_task", @@ -17397,7 +17297,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleUpdateTask", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_update_workflow", @@ -17547,7 +17447,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleUpdateWorkflow", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_validate_community_route", @@ -17558,9 +17458,9 @@ ], "activeState": "migration-candidate", "description": "Accept or reject an F168 auto-routed community issue as the assigned cat. Use when: narrator/triage has assigned the current cat a real case with routeAcceptance=pending and the cat has independently verified the thread and custody. NOT for: triage, assigning another cat, operator owner decisions, changing a non-pending case, or bypassing external-author custody. Output: returns the canonical CommunityIssueItem after the route state transition; reject clears assignment and returns the case to pending-decision. GOTCHA: callback identity is supplied inside the MCP bridge—the API verifies that the caller is the assigned cat. Never copy callback credentials into shell commands or tool arguments.", - "descriptionDigest": "sha256:7d84eb641c652b6c6e3c6c0795e412e9c677628370dbb012ea9974760413b3a9", - "descriptionCharacters": 695, - "descriptionTokensCl100kBase": 135, + "descriptionDigest": "sha256:b432692a4b8a26df2f922aad31a893c336282298fab9cc57864585b8f6bdb5ca", + "descriptionCharacters": 700, + "descriptionTokensCl100kBase": 134, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -17606,7 +17506,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/community-route-acceptance-tool.js#handleCommunityRouteAcceptance", - "protectedImplementationDigest": "sha256:ee9fadee7b2a1147e0685adfd0b81430d4311ec4f589e2dc2985073ef1bbc8bf" + "protectedImplementationDigest": "sha256:e4c78a2bd7f5fc38b4f2ee3d9b2b79acbe5e7406342a891a7b564fbc0cdf57e0" }, { "name": "cat_cafe_withdraw_deferred_person_memory", @@ -17649,7 +17549,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleWithdrawDeferredPersonMemory", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_withdraw_thread_proposal", @@ -17693,7 +17593,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/callback-tools.js#handleWithdrawThreadProposal", - "protectedImplementationDigest": "sha256:a91d689a6f7b8349bcd3d1a3cabb201c6414fff9b0a25d902ac438e89aa153b4" + "protectedImplementationDigest": "sha256:c937307455a35ddfee4ad15cb4eb9fc642e6fd0080e0a6c3e1465156344c41fd" }, { "name": "cat_cafe_workspace_navigate", @@ -17755,7 +17655,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:77968119104740aaf241cb2d1f5e278717958aa722b09945347b3d7e28a1d5e1", + "inputSchemaDigest": "sha256:5c3117dd00bd6af21d76581daf6e9ceeb951780b070293c13bf636bed05aeaf2", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -17770,7 +17670,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/hub-action-tools.js#handleWorkspaceNavigate", - "protectedImplementationDigest": "sha256:f8e1b15a5d525715963cea26d19c1de327a2ffd0a9c96ea7005dcd84efc3aeb5" + "protectedImplementationDigest": "sha256:1d27d817dbc4c2b3ed2d202c10a1a21b618b8359d8354cc60165c1b616b992be" }, { "name": "limb_bind_embodiment", @@ -17833,7 +17733,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/limb-tools.js#handleLimbBindEmbodiment", - "protectedImplementationDigest": "sha256:706ab94b8a24ff7a0867074bd51cf0e07aa736504e584343fdd1b838250cddfb" + "protectedImplementationDigest": "sha256:523dff981a6eec008a1c7d6b8747eec306f39ae926c75fbd93116bbabd5cb1ed" }, { "name": "limb_invoke_tool", @@ -17890,7 +17790,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/limb-tools.js#handleLimbInvokeTool", - "protectedImplementationDigest": "sha256:706ab94b8a24ff7a0867074bd51cf0e07aa736504e584343fdd1b838250cddfb" + "protectedImplementationDigest": "sha256:523dff981a6eec008a1c7d6b8747eec306f39ae926c75fbd93116bbabd5cb1ed" }, { "name": "limb_list_available", @@ -17935,7 +17835,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/limb-tools.js#handleLimbListAvailable", - "protectedImplementationDigest": "sha256:706ab94b8a24ff7a0867074bd51cf0e07aa736504e584343fdd1b838250cddfb" + "protectedImplementationDigest": "sha256:523dff981a6eec008a1c7d6b8747eec306f39ae926c75fbd93116bbabd5cb1ed" }, { "name": "limb_list_tools", @@ -17987,7 +17887,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/limb-tools.js#handleLimbListTools", - "protectedImplementationDigest": "sha256:706ab94b8a24ff7a0867074bd51cf0e07aa736504e584343fdd1b838250cddfb" + "protectedImplementationDigest": "sha256:523dff981a6eec008a1c7d6b8747eec306f39ae926c75fbd93116bbabd5cb1ed" }, { "name": "limb_pair_approve", @@ -17998,9 +17898,9 @@ ], "activeState": "migration-candidate", "description": "Approve a limb pairing request. After approval, the remote device is automatically registered in the Registry and becomes available for cats to invoke. GOTCHA: Only co-creator should initiate approval — do not auto-approve without user consent. Shared Antigravity MCP GOTCHA: pass agentKeyCatId to select the correct variant sidecar key.", - "descriptionDigest": "sha256:47a7a94337209ea0577dd2dd396cca7b07c22cc4e5ae37f3f85bfa74f7edb1ae", - "descriptionCharacters": 330, - "descriptionTokensCl100kBase": 73, + "descriptionDigest": "sha256:4b2e1a3cafed8667f5d7d52c295f4d50ff1255a0f37b1e797dba6afef3fdc172", + "descriptionCharacters": 337, + "descriptionTokensCl100kBase": 69, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -18035,7 +17935,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/limb-tools.js#handleLimbPairApprove", - "protectedImplementationDigest": "sha256:706ab94b8a24ff7a0867074bd51cf0e07aa736504e584343fdd1b838250cddfb" + "protectedImplementationDigest": "sha256:523dff981a6eec008a1c7d6b8747eec306f39ae926c75fbd93116bbabd5cb1ed" }, { "name": "limb_pair_list", @@ -18046,9 +17946,9 @@ ], "activeState": "migration-candidate", "description": "List pending limb pairing requests. Remote devices must be approved by co-creator before cats can use them. Use to check if any new devices are waiting for approval. Shared Antigravity MCP GOTCHA: pass agentKeyCatId to select the correct variant sidecar key.", - "descriptionDigest": "sha256:ff3f8f2629816dfef23a36e2a49fed849cdc0e1d28a92e9d7f8bf475d74da77a", - "descriptionCharacters": 251, - "descriptionTokensCl100kBase": 59, + "descriptionDigest": "sha256:e7b0c84b8e313daf5e4802a1e339c6f2f2c6d5ad1457909a30e26caef4f6a269", + "descriptionCharacters": 258, + "descriptionTokensCl100kBase": 55, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -18076,7 +17976,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/limb-tools.js#handleLimbPairList", - "protectedImplementationDigest": "sha256:706ab94b8a24ff7a0867074bd51cf0e07aa736504e584343fdd1b838250cddfb" + "protectedImplementationDigest": "sha256:523dff981a6eec008a1c7d6b8747eec306f39ae926c75fbd93116bbabd5cb1ed" }, { "name": "signal_delete_article", @@ -18087,9 +17987,9 @@ ], "activeState": "migration-candidate", "description": "Soft-delete one or more articles. Use when co-creator wants to clean up garbage or irrelevant signals. Accepts multiple IDs for batch deletion. Articles are soft-deleted (recoverable).", - "descriptionDigest": "sha256:59db94e603b820dda71f6a48dca74d4d0e89c0bb09c907bd6fd09a4aac478fde", - "descriptionCharacters": 177, - "descriptionTokensCl100kBase": 42, + "descriptionDigest": "sha256:51a685b1896d89c7dbb93523fe411c416adc391ab3d7cf0ce283cd4f4eb70d3d", + "descriptionCharacters": 184, + "descriptionTokensCl100kBase": 38, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -18123,7 +18023,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signal-study-tools.js#handleDeleteArticle", - "protectedImplementationDigest": "sha256:345f7942e9ce00a85d1c24e9a50b00ed85041176c9ceaae9d43983bb62023ce7" + "protectedImplementationDigest": "sha256:b94e5cff6ac8c8838e0b4fa11d69f2de31976be6386cc2dd27c81587014a1416" }, { "name": "signal_generate_podcast", @@ -18183,7 +18083,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signal-study-tools.js#handleGeneratePodcast", - "protectedImplementationDigest": "sha256:345f7942e9ce00a85d1c24e9a50b00ed85041176c9ceaae9d43983bb62023ce7" + "protectedImplementationDigest": "sha256:b94e5cff6ac8c8838e0b4fa11d69f2de31976be6386cc2dd27c81587014a1416" }, { "name": "signal_get_article", @@ -18194,9 +18094,9 @@ ], "activeState": "migration-candidate", "description": "Get full signal article detail by id or URL. Returns title, content, source, tier, timestamps, and metadata. Use when you need to read the full content of a specific article. PARAM GUIDE: Use id (from list_inbox/search results) OR url (if co-creator shared a link) — not both.", - "descriptionDigest": "sha256:aec4293f0a4a5c3ee061457f459412e1657087eb35e07d2f2fad0f01ca03dbf9", - "descriptionCharacters": 269, - "descriptionTokensCl100kBase": 70, + "descriptionDigest": "sha256:15aa12fab4ace4e74395bbb588ebecba513f5bcf7ee2cc5821d126cd29cd8bb4", + "descriptionCharacters": 276, + "descriptionTokensCl100kBase": 66, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -18230,7 +18130,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signals-tools.js#handleSignalGetArticle", - "protectedImplementationDigest": "sha256:f3cf699accb521ff3531c3a09ba6027e4fd168099b098889f396c2a2035a0da2" + "protectedImplementationDigest": "sha256:620b675ee3f8c98d6ca007e7199095211b8659b4180c1cae535bfee6ff81c77e" }, { "name": "signal_link_thread", @@ -18288,7 +18188,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signal-study-tools.js#handleLinkThread", - "protectedImplementationDigest": "sha256:345f7942e9ce00a85d1c24e9a50b00ed85041176c9ceaae9d43983bb62023ce7" + "protectedImplementationDigest": "sha256:b94e5cff6ac8c8838e0b4fa11d69f2de31976be6386cc2dd27c81587014a1416" }, { "name": "signal_list_inbox", @@ -18299,9 +18199,9 @@ ], "activeState": "migration-candidate", "description": "List recent signal articles from inbox. Use when co-creator asks to check signals, or when you need to browse unread articles. Supports optional limit, tier, and source filters. TIER GUIDE: T1 = critical/breaking, T2 = important, T3 = interesting, T4 = low priority. Returns article IDs needed for other signal tools (get_article, mark_read, start_study).", - "descriptionDigest": "sha256:72f79f2966f5d078fe2018dcdd2fdc7ae373f7e97f128d8e1805257f9b03bf08", - "descriptionCharacters": 348, - "descriptionTokensCl100kBase": 86, + "descriptionDigest": "sha256:18fd2082d82599f940689d802483354d14eb234026b5adf638ca6fe4ee5bf5e0", + "descriptionCharacters": 355, + "descriptionTokensCl100kBase": 82, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -18347,7 +18247,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signals-tools.js#handleSignalListInbox", - "protectedImplementationDigest": "sha256:f3cf699accb521ff3531c3a09ba6027e4fd168099b098889f396c2a2035a0da2" + "protectedImplementationDigest": "sha256:620b675ee3f8c98d6ca007e7199095211b8659b4180c1cae535bfee6ff81c77e" }, { "name": "signal_list_studies", @@ -18403,7 +18303,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signal-study-tools.js#handleListStudies", - "protectedImplementationDigest": "sha256:345f7942e9ce00a85d1c24e9a50b00ed85041176c9ceaae9d43983bb62023ce7" + "protectedImplementationDigest": "sha256:b94e5cff6ac8c8838e0b4fa11d69f2de31976be6386cc2dd27c81587014a1416" }, { "name": "signal_mark_read", @@ -18414,9 +18314,9 @@ ], "activeState": "migration-candidate", "description": "Mark a signal article as read. Use after you or co-creator have reviewed an article. This removes it from the inbox view.", - "descriptionDigest": "sha256:3ec23ea67172eb78cd09355f3fa74a661cfdf8984999b7720b4cdf92ca3a468b", - "descriptionCharacters": 114, - "descriptionTokensCl100kBase": 31, + "descriptionDigest": "sha256:f058e2d612179784ef809c37ebea647f9ebb934826afe35283fe8ce66b2381a8", + "descriptionCharacters": 121, + "descriptionTokensCl100kBase": 27, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -18447,7 +18347,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signals-tools.js#handleSignalMarkRead", - "protectedImplementationDigest": "sha256:f3cf699accb521ff3531c3a09ba6027e4fd168099b098889f396c2a2035a0da2" + "protectedImplementationDigest": "sha256:620b675ee3f8c98d6ca007e7199095211b8659b4180c1cae535bfee6ff81c77e" }, { "name": "signal_save_notes", @@ -18504,7 +18404,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signal-study-tools.js#handleSaveNotes", - "protectedImplementationDigest": "sha256:345f7942e9ce00a85d1c24e9a50b00ed85041176c9ceaae9d43983bb62023ce7" + "protectedImplementationDigest": "sha256:b94e5cff6ac8c8838e0b4fa11d69f2de31976be6386cc2dd27c81587014a1416" }, { "name": "signal_search", @@ -18590,7 +18490,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signals-tools.js#handleSignalSearch", - "protectedImplementationDigest": "sha256:f3cf699accb521ff3531c3a09ba6027e4fd168099b098889f396c2a2035a0da2" + "protectedImplementationDigest": "sha256:620b675ee3f8c98d6ca007e7199095211b8659b4180c1cae535bfee6ff81c77e" }, { "name": "signal_start_study", @@ -18638,7 +18538,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signal-study-tools.js#handleStartStudy", - "protectedImplementationDigest": "sha256:345f7942e9ce00a85d1c24e9a50b00ed85041176c9ceaae9d43983bb62023ce7" + "protectedImplementationDigest": "sha256:b94e5cff6ac8c8838e0b4fa11d69f2de31976be6386cc2dd27c81587014a1416" }, { "name": "signal_summarize", @@ -18688,7 +18588,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signals-tools.js#handleSignalSummarize", - "protectedImplementationDigest": "sha256:f3cf699accb521ff3531c3a09ba6027e4fd168099b098889f396c2a2035a0da2" + "protectedImplementationDigest": "sha256:620b675ee3f8c98d6ca007e7199095211b8659b4180c1cae535bfee6ff81c77e" }, { "name": "signal_update_article", @@ -18699,9 +18599,9 @@ ], "activeState": "migration-candidate", "description": "Update article fields: status, tags, or note. Use for managing articles from chat. STATUS VALUES: inbox (unread), read, starred (important), archived (done). TIP: Use tags for categorization (e.g. [\"ai\", \"infrastructure\"]) and note for co-creator personal remarks.", - "descriptionDigest": "sha256:c1494a5f3da6b8e0db4bf850863bc886284d630f8122854ea10336005e8105c6", - "descriptionCharacters": 257, - "descriptionTokensCl100kBase": 67, + "descriptionDigest": "sha256:921bb851a77fa7982ca6447d05cfbb40c9744acf031c8707fa21e61806d0883c", + "descriptionCharacters": 264, + "descriptionTokensCl100kBase": 63, "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -18738,7 +18638,7 @@ ], "type": "object" }, - "inputSchemaDigest": "sha256:9dc228186560e949613295a89a1c26b41b1c70defe2752f693db67938917bb90", + "inputSchemaDigest": "sha256:10debad04ecf4ffe9eab893cf4b5ab047ee40bdbbb7c4de4e7ed253f486665f7", "annotations": { "destructiveHint": false, "openWorldHint": false, @@ -18753,7 +18653,7 @@ "policy": "host-default" }, "implementationRef": "module:./tools/signal-study-tools.js#handleUpdateArticle", - "protectedImplementationDigest": "sha256:345f7942e9ce00a85d1c24e9a50b00ed85041176c9ceaae9d43983bb62023ce7" + "protectedImplementationDigest": "sha256:b94e5cff6ac8c8838e0b4fa11d69f2de31976be6386cc2dd27c81587014a1416" } ] } diff --git a/packages/mcp-server/src/server-toolsets.ts b/packages/mcp-server/src/server-toolsets.ts index 30447fb3b0..96f02ee0a8 100644 --- a/packages/mcp-server/src/server-toolsets.ts +++ b/packages/mcp-server/src/server-toolsets.ts @@ -187,7 +187,6 @@ async function maybeFreshnessNotice(toolName: string, isReadOnly: boolean): Prom return null; } - // Gate passed — call API to check for unseen messages if (!getCallbackConfig()) return null; try { @@ -198,16 +197,13 @@ async function maybeFreshnessNotice(toolName: string, isReadOnly: boolean): Prom if (result.isError) return null; const data = JSON.parse((result.content[0] as { text: string }).text); - // Advance interval counter after ANY API call, not just successful delivery. - // Otherwise quiet threads (no unseen) bypass the interval gate on every call. - // (Cloud review R2 P2-R2-2) freshnessNoticeState.lastNoticeToolCallNum = freshnessNoticeState.toolCallCount; if (data?.notice?.text) { freshnessNoticeState.noticeDeliveredCount++; return data.notice.text; } } catch { - // Fail-open: notice errors never block tool execution + // Fail-open: notice errors never block tool execution. } return null; } @@ -226,13 +222,11 @@ function projectRegistrationContract(tool: ToolDef, principal: PostMessageRegist ? projectAgentKeyCollaborationContract(tool.name, canonicalSchema, tool.description) : undefined; const schema = agentContract?.inputSchema ?? canonicalSchema; - // Callback tools use Zod raw shapes; limb tools use plain JSON Schema. let inputSchema = typeof schema.type === 'string' && typeof schema.properties === 'object' && schema.properties !== null ? jsonSchemaToZod(schema) : z.object(schema as z.ZodRawShape); if ((isPostMessage && principal === 'invocation') || agentContract) { - // Reject unsupported fields before default Zod stripping erases their evidence. inputSchema = (inputSchema as z.ZodObject).strict(); } return { description: agentContract?.description ?? tool.description, inputSchema }; @@ -272,12 +266,9 @@ function registerTools(server: McpServer, tools: readonly ToolDef[], env: Toolse [key: string]: unknown; }; - // F254 B1: Piggyback freshness notice on successful read-only tool results if (!typed.isError && annotations.readOnlyHint) { const noticeText = await maybeFreshnessNotice(tool.name, annotations.readOnlyHint); - if (noticeText) { - typed.content = [...typed.content, { type: 'text', text: `\n\n${noticeText}` }]; - } + if (noticeText) typed.content = [...typed.content, { type: 'text', text: `\n\n${noticeText}` }]; } return typed; diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts index 61ed00ab23..905dc5f448 100644 --- a/packages/mcp-server/src/tools/callback-tools.ts +++ b/packages/mcp-server/src/tools/callback-tools.ts @@ -414,14 +414,6 @@ export const postMessageInputSchema = { ACTION_SUBJECT_REF_DESCRIPTION, ), agentKeyCatId: agentKeyCatIdSchema, - // F254 Phase A: acknowledge held — force send even when there are unseen messages - acknowledgeHeld: z - .boolean() - .optional() - .describe( - 'F254 Freshness Gate escape hatch. Set to true to force-send your message even when the thread has unseen messages. ' + - 'Use only after you have reviewed the held envelope previews and decided your message is still appropriate.', - ), }; export type PostMessageRegistrationPrincipal = 'invocation' | 'agent-key' | 'unconfigured'; @@ -523,8 +515,9 @@ export const getThreadContextInputSchema = { 'Response projection mode. "anchor" (DEFAULT — omit for normal browsing): token-lean previews with drillDown pointers to full content. ' + '"full": returns complete message bodies inside a bounded aggregate page; use nextCursor when hasMore=true. A persisted message larger than the page is returned as an honest anchor with a precise drill pointer; a transient queued body without a persisted message anchor remains unseen and says to retry after persistence. ' + 'An oversized workflow SOP is likewise returned as an honest anchor that points to cat_cafe_get_workflow_sop instead of overflowing the aggregate envelope. ' + - 'Use "full" whenever a freshness catch asks you to consume the contiguous unread set: current-thread reads prefer the unread delta, and only complete bodies advance queued-read evidence. ' + - 'GOTCHA: anchor previews are not a freshness closure and cannot prove queued messages were handled.', + 'Use "full" for a complete catch-up of the current unread selection: current-thread reads prefer the unread delta, and only complete bodies advance queued-read evidence. ' + + 'The response contextScope names that selection: contextScope="unread_delta" contains only currently unread relevant messages, so hasMore=false closes that unread selection rather than proving that all thread history was returned. Use anchor browsing or a messageId window to inspect older history. ' + + 'GOTCHA: anchor previews do not consume queued bodies and cannot prove queued messages were handled.', ), agentKeyCatId: agentKeyCatIdSchema, }; @@ -848,11 +841,6 @@ export const crossPostMessageInputSchema = { PROPOSED_ACTION_EXECUTABLE_CONTRACT_DESCRIPTION, ), agentKeyCatId: agentKeyCatIdSchema, - // F254 Phase A: acknowledge held — force send even when there are unseen messages - acknowledgeHeld: z - .boolean() - .optional() - .describe('F254 Freshness Gate escape hatch. Force-send despite unseen messages in the target thread.'), }; export const listTasksInputSchema = { @@ -927,7 +915,6 @@ async function _executePostMessage( acceptedRevision?: string | undefined; action?: ActionSuccessorRequestMetadata | undefined; proposedAction?: ActionSuccessorRequestMetadata | undefined; - acknowledgeHeld?: boolean | undefined; }, transportOptions?: CallbackTransportOptions, ): Promise { @@ -984,7 +971,6 @@ async function _executePostMessage( ...(input.acceptedRevision ? { acceptedRevision: input.acceptedRevision } : {}), ...(input.action ? { action: input.action } : {}), ...(input.proposedAction ? { proposedAction: input.proposedAction } : {}), - ...(input.acknowledgeHeld ? { acknowledgeHeld: true } : {}), }, { enableOutbox: true, @@ -1008,31 +994,6 @@ async function _executePostMessage( 'Include the message content in your stdout response instead.', ); } - - // F254 Phase A: Detect held — server returned 200 but message was NOT sent - // because the cat has unseen messages in the thread (freshness gate). - if (data?.status === 'held') { - const previews = (data.previews ?? []) as Array<{ from: string; messageId: string; preview: string }>; - const previewLines = previews.map( - (p: { from: string; preview: string }) => - ` [${p.from}]: "${p.preview.slice(0, 100)}${p.preview.length > 100 ? '…' : ''}"`, - ); - const omitted = (data.omittedCount ?? 0) as number; - const omittedLine = omitted > 0 ? ` ...and ${omitted} more message(s)\n` : ''; - - return errorResult( - `⚠️ Message NOT sent (HELD)\n` + - `━━━━━━━━━━━━━━━━━━━━━━━━━\n` + - `Reason: You have ${data.unseenCount ?? 'unknown'} unseen message(s) in this thread.\n\n` + - (previewLines.length > 0 - ? `Recent messages you haven't read:\n${previewLines.join('\n')}\n${omittedLine}\n` - : '') + - `Your options:\n` + - `1. Call cat_cafe_list_recent or cat_cafe_get_thread_context to read the new messages first\n` + - `2. Revise your message based on what you learn, then call post_message again\n` + - `3. Call post_message with acknowledgeHeld: true to force-send your original message as-is`, - ); - } } catch { // parse failure is fine — means result is not a stale_ignored/held response } @@ -1106,7 +1067,6 @@ export async function handlePostMessage( acceptedRevision?: string | undefined; agentKeyCatId?: string | undefined; action?: ActionSuccessorRequestMetadata | undefined; - acknowledgeHeld?: boolean | undefined; }, transportOptions?: CallbackTransportOptions, ): Promise { @@ -1580,7 +1540,6 @@ export async function handleCrossPostMessage(input: { acceptedRevision?: string | undefined; action?: ActionSuccessorRequestMetadata | undefined; proposedAction?: ActionSuccessorRequestMetadata | undefined; - acknowledgeHeld?: boolean | undefined; }): Promise { if (containsPawFeelMarker(input.content)) { return errorResult( @@ -1689,7 +1648,6 @@ export async function handleCrossPostMessage(input: { ...(input.acceptedRevision ? { acceptedRevision: input.acceptedRevision } : {}), ...(input.action ? { action: input.action } : {}), ...(input.proposedAction ? { proposedAction: input.proposedAction } : {}), - ...(input.acknowledgeHeld ? { acknowledgeHeld: true } : {}), }); } @@ -3209,7 +3167,7 @@ export async function handleHoldBall(input: { isError: true, }; } - const result = await callbackPost( + return callbackPost( '/api/callbacks/hold-ball', { reason: input.reason, @@ -3220,47 +3178,13 @@ export async function handleHoldBall(input: { }, agentKeyOptions(input), ); - - // F254 B2: Check for unresolved freshness notices after successful hold_ball. - // If the cat has unacknowledged notices, append a reminder to the result. - // Fail-open: reminder errors never block hold_ball. - if (!result.isError && getCallbackConfig(agentKeyOptions(input))) { - try { - const reminderResult = await callbackPost( - '/api/callbacks/freshness-hold-ball-reminder', - {}, - agentKeyOptions(input), - ); - if (!reminderResult.isError) { - const data = JSON.parse((reminderResult.content[0] as { text: string }).text); - if (data?.reminder?.text) { - result.content = [...result.content, { type: 'text', text: `\n\n${data.reminder.text}` }]; - } - } - } catch { - // Fail-open: reminder check errors should never block hold_ball - } - } - - return result; } -/** - * F167 #1449 Slice 1 — Task-ID-independent hold observability. - * No input required; threadId + catId come from invocation auth. - */ +/** Read the current invocation-bound hold without requiring its internal task ID. */ export async function handleGetHoldStatus(): Promise { return callbackGet('/api/callbacks/hold-ball/current'); } -export async function handleCompleteManagedHold(input: { disposition: 'handled' | 'completed' }): Promise { - return callbackPost('/api/callbacks/complete-managed-hold', { disposition: input.disposition }); -} - -export async function handleCompleteA2ADispatch(input: { disposition: 'handled' | 'completed' }): Promise { - return callbackPost('/api/callbacks/complete-a2a-dispatch', { disposition: input.disposition }); -} - // ─── F236 Phase C: cat-controlled anchor mode ───────────────────────────── import { unlinkSync, writeFileSync } from 'node:fs'; @@ -3399,7 +3323,8 @@ export const callbackTools = [ 'Output: the message is persisted in the principal-selected thread; routed targets are queued, and action conflicts return safe_wait without creating work. ' + 'GOTCHA: action requires explicit clientMessageId + exactly one targetCats entry; ordinary single-cat notifications do not need action. ' + 'For a direct Claim/Release chain, pass coordination.phase=active on work hops and terminal on the final delivery; terminal recipients may clean-stop without another @. ' + - 'A terminal id or bound subject that conflicts with the incoming coordination fails with HTTP 409 before message persistence or wake; start genuinely new work with phase=active. ' + + 'An unaddressed terminal transition whose id or bound subject conflicts with the incoming coordination fails with HTTP 409 before persistence. After an incoming terminal, any authored target is genuinely new active work even if the caller repeats a stale terminal label. ' + + 'A reply after terminal is a quiet courtesy ACK only when it has no explicit routing credential; line-start @mentions and structured targetCats always start a new active routing generation and remain subject to the normal loop-streak guard. ' + 'For a local review result, route an ordinary @author message with localReviewVerdict + exact reviewedHeadSha + reviewSubjectRef + acceptedSourceRef + acceptedRevision + clientMessageId. This durable fact needs no action lease, coordination generation, replacement, or issuer route. Public prose is never parsed. ' + 'Existing standing uses claimOrigin="existing_standing" + groundingEvidenceRef; rejected custody uses returnToPredecessor and targets the persisted predecessor. ' + 'GOTCHA: structured action metadata currently requires invocation-token auth; agent-key callers fail closed with the non-retryable action_agent_key_unsupported status and never send an unfenced fallback. ' + @@ -3456,10 +3381,11 @@ export const callbackTools = [ name: 'cat_cafe_get_thread_context', description: 'Read messages from one thread, with token-lean anchor previews by default and optional full bodies, ranked keywords, or a bounded window around messageId. ' + - 'Use when: browsing the current conversation, reading a different known threadId, finding relevant messages inside that thread, opening context around a known messageId, or a freshness notice asks you to catch up. ' + + 'Use when: browsing the current conversation, reading a different known threadId, finding relevant messages inside that thread, opening context around a known messageId, or explicitly catching up. ' + 'NOT for: finding features, decisions, plans, lessons, or unknown threads across project knowledge; use search_evidence or list_threads first. ' + - 'Output: a bounded aggregate envelope with threadId, ordered messages, hasMore, and nextCursor when continuation is required; anchor mode includes drillDown pointers, while responseMode="full" returns complete bodies per ordinary item and includes same-target queued bodies. ' + - 'GOTCHA: keyword ranking is best-effort over a bounded recent scan; scanCapped=true means older history may contain additional matches. A single item larger than the full-page budget falls back to an honest anchor; drill an oversized workflow SOP with cat_cafe_get_workflow_sop using the returned threadId. Anchor mode does not consume queued bodies or close freshness responsibility; for a freshness catch, use responseMode="full" with no catId/keyword/messageId filters and follow nextCursor until hasMore=false. Pass threadId only to read a different thread; omit it for the current thread.', + 'Output: a bounded aggregate envelope with threadId, ordered messages, contextScope, hasMore, and nextCursor when continuation is required; anchor mode includes drillDown pointers, while responseMode="full" returns complete bodies per ordinary item and includes same-target queued bodies. contextScope="unread_delta" contains only currently unread relevant messages; in that scope hasMore=false closes the unread selection, not all thread history. Use anchor browsing or a messageId window to inspect older history. ' + + 'When available, situation reports exact lifecycle-backed active runs (target, source messages, response, invocation); situation.complete=false means runtime evidence could not be fully joined, so do not infer execution from recent speech. ' + + 'GOTCHA: keyword ranking is best-effort over a bounded recent scan; scanCapped=true means older history may contain additional matches. A single item larger than the full-page budget falls back to an honest anchor; drill an oversized workflow SOP with cat_cafe_get_workflow_sop using the returned threadId. Anchor mode does not consume queued bodies; for a complete catch-up of current unread work, use responseMode="full" with no catId/keyword/messageId filters and follow nextCursor until hasMore=false. Pass threadId only to read a different thread; omit it for the current thread.', inputSchema: getThreadContextInputSchema, handler: handleGetThreadContext, governance: { @@ -3609,9 +3535,9 @@ export const callbackTools = [ 'GOTCHA: Requires threadId — use feat_index/list_threads plus thread truth to verify the exact owning thread; never guess a nearby thread. ' + 'PAW-FEEL: The original [爪感差: ...] message is already collected. Cross-post only a marker-free sourceMessageId reference to a verified owner; if none exists, use cat_cafe_propose_thread (F128). New responsibility uses effectClass=assign_work plus proposedAction for Approval Hub review. ' + 'GOTCHA: For Claim/Release coordination, pass coordination.phase=active on Claim/work hops and terminal on Release. ' + - 'A terminal id or bound subject that conflicts with the incoming coordination fails with HTTP 409 before message persistence or wake; start genuinely new work with phase=active. ' + + 'An unaddressed terminal transition whose id or bound subject conflicts with the incoming coordination fails with HTTP 409 before persistence. Cross-thread routing credentials after an incoming terminal always name genuinely new active work. ' + 'For a local review result, route one ordinary @author cross-post with localReviewVerdict + exact reviewedHeadSha + reviewSubjectRef + acceptedSourceRef + acceptedRevision + clientMessageId. This durable fact needs no action lease, coordination generation, replacement, or issuer route; prose is never parsed. ' + - 'The server carries a stable id across active hops; a direct courtesy ACK after terminal is recorded without waking another cat. ' + + 'The server carries a stable id across active hops. Every cross-thread post supplies an explicit routing credential, so a line-start @mention or structured targetCats after terminal always starts a new active routing generation and remains subject to the normal loop-streak guard. ' + 'If terminal reveals genuinely new work, start a new coordination with phase=active instead of ACKing the closed chain. ' + 'GOTCHA: For a direct named external action, pass action + explicit clientMessageId + targetCats. For operator-gated new responsibility, pass proposedAction with effectClass=assign_work instead; action and assign_work remain mutually exclusive. ' + 'Output: direct action admission posts and queues one fenced carrier; assign_work publishes one pending approval card and posts nothing to the target until approval atomically acquires the fence. ' + @@ -4344,11 +4270,11 @@ export const callbackTools = [ '"let me think" / "I\'ll hold for now" → hesitation not hold, pick 接/退/升; ' + 'review/analysis done → MUST @ author, conclusion ≠ endpoint; status updates → use post_message. ' + 'Output: system schedules a one-shot wake-up after wakeAfterMs; you get re-invoked with reason + nextStep as trigger context. ' + - 'GOTCHA: max 3 holds per (thread, cat) within a sliding ~1h window (anchored to last successful hold) — 4th call returns 429 with retryAt/retryAfterMs, you MUST pass (@ another cat or @co-creator). ' + + 'GOTCHA: max 3 holds per (thread, cat) within a rolling ~1h window — 4th call returns 429, you MUST pass (@ another cat or @co-creator). ' + 'GOTCHA: the counter is process-local best-effort (in-memory on the API node); API restart or multi-instance deploys may reset it, so do not treat the 429 as a hard security boundary — treat it as a self-discipline guardrail. ' + 'GOTCHA: hold is an EXCEPTION state, not a default exit. Most turns should end with @ someone, not hold. ' + 'GOTCHA (F167 Phase M): only hold for harness-INVISIBLE waits — external conditions nothing will call you back about (cloud review verdict, remote CI, external webhook). Background work the harness already tracks (a background Bash command, a spawned task) AUTO-RE-INVOKES you on completion; holding for that just stacks a redundant wake on top. Ask "will something call me back already?" — if yes, do NOT hold. A co-creator or another cat sending a message into this thread IS such a callback (it re-invokes you), so "waiting for co-creator to answer" must be @co-creator, never a hold. ' + - 'GOTCHA: SINGLE-SLOT per (thread, cat) — calling hold_ball again while a previous hold is pending REPLACES the prior wake (prior taskId cancelled). This is intentional (KD-23): hold = "持一个球" exception, not a queue. If you need to track multiple waiting conditions, merge them into one nextStep (e.g. "等 CI + @co-creator 确认" 合并成一句). Sliding-window counter ticks on each successful hold; rejected 429 calls do not advance the window. ' + + 'GOTCHA: SINGLE-SLOT per (thread, cat) — calling hold_ball again while a previous hold is pending REPLACES the prior wake (prior taskId cancelled). This is intentional (KD-23): hold = "持一个球" exception, not a queue. If you need to track multiple waiting conditions, merge them into one nextStep (e.g. "等 CI + @co-creator 确认" 合并成一句). Rolling-window counter still ticks per call. ' + 'NEW (F167 Phase Q): event-backed retirement only works when waitSourceRef.expectedSignal is one of these exact structured keys: assignment, review_posted, ci_complete, comment_posted, managed_command_complete, user_message. Free-text values like "CI pass" remain valid narrative context but will NOT retire a timer by event, by design. ' + 'NEW (F167 Phase P): wakeWhen — instead of a timed delay, specify a shell command to run. The server spawns it, captures output, and wakes you when it completes (or times out). Use for: pnpm gate, pnpm test, build commands — anything you would run_in_background and poll. wakeWhen is for LOCAL COMMANDS ONLY — it does not turn hold_ball into a universal "smart wait": waiting on a person is still @co-creator / @ that cat, waiting on a cloud event (PR / CI / issue) is still register_pr_tracking / register_issue_tracking. wakeWhen and wakeAfterMs are MUTUALLY EXCLUSIVE — provide exactly one.', inputSchema: { @@ -4452,68 +4378,6 @@ export const callbackTools = [ }, }, }), - defineCanonicalTool({ - name: 'cat_cafe_complete_managed_hold', - description: - 'Terminally dispose the exact managed hold wake bound to this invocation. ' + - 'Use when: the current turn was triggered by a managed hold wake, or a full-body read adopted one into this same invocation, and its work is actually handled/completed. ' + - 'NOT for: ordinary holds, unfinished work, re-hold, a structured event wait, transfer, or unrelated task completion. ' + - 'Output: marks the exact F264 target receipt handled and terminalizes the original F167 hold ball; ' + - 'the server derives and fences threadId, holderCatId, invocationId, sourceMessageId, and taskId. ' + - 'GOTCHA: full read, command exit, tests, merge truth, or ACK never substitute for this producer, ' + - 'and the caller cannot select or close another subject. Full-read managedHoldDisposition guidance reports a unique pending source, ambiguity, or no obligation; never guess through ambiguity.', - inputSchema: { - disposition: z - .enum(['handled', 'completed']) - .describe( - 'handled = wake consumed; completed = wake work completed. Both terminalize the exact original hold.', - ), - }, - handler: handleCompleteManagedHold, - governance: { - implementationExport: 'handleCompleteManagedHold', - resourceFamily: 'task-workflow', - action: 'complete', - authority: 'callback-owner', - risk: { level: 'write', openWorld: false }, - runtimeProfiles: ['full'], - standaloneReason: { - disposition: 'accepted-boundary', - kind: 'authority-boundary', - admissionRef: 'file:docs/features/F167-a2a-chain-quality.md', - }, - }, - }), - defineCanonicalTool({ - name: 'cat_cafe_complete_a2a_dispatch', - description: - 'Terminally dispose the exact ordinary A2A dispatch bound to this invocation. ' + - 'Use when: the current turn was triggered by a same-thread or queued agent handoff and its requested work is actually handled/completed. ' + - 'NOT for: user turns, managed holds, unfinished work, re-hold, event wait, transfer, or unrelated task completion. ' + - 'Output: terminalizes the exact F167 dispatch ball; the server derives and fences threadId, holderCatId, fromCatId, invocationId, and sourceMessageId. ' + - 'GOTCHA: command exit, tests, merge truth, another coordination terminal, or ACK never substitute for this producer, ' + - 'and the caller cannot select or close another subject. If replaced, the error names the latest verified same-thread successor ' + - 'event and any source message or coordination.', - inputSchema: { - disposition: z - .enum(['handled', 'completed']) - .describe('handled = exact A2A request consumed; completed = requested A2A work completed.'), - }, - handler: handleCompleteA2ADispatch, - governance: { - implementationExport: 'handleCompleteA2ADispatch', - resourceFamily: 'task-workflow', - action: 'complete', - authority: 'callback-owner', - risk: { level: 'write', openWorld: false }, - runtimeProfiles: ['full'], - standaloneReason: { - disposition: 'accepted-boundary', - kind: 'authority-boundary', - admissionRef: 'file:docs/features/F167-a2a-chain-quality.md', - }, - }, - }), defineTool({ name: 'cat_cafe_set_read_mode', description: diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index b66a65de98..2ecd084a0a 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -49,8 +49,6 @@ export { handleAckMentions, handleAdmitEntrustedWork, handleCloseEntrustedWork, - handleCompleteA2ADispatch, - handleCompleteManagedHold, handleCrossPostMessage, handleDrillMemoryCue, handleFeatIndex, diff --git a/packages/mcp-server/test/callback-tools.test.js b/packages/mcp-server/test/callback-tools.test.js index 02ba2bbb8b..6d224b4510 100644 --- a/packages/mcp-server/test/callback-tools.test.js +++ b/packages/mcp-server/test/callback-tools.test.js @@ -183,44 +183,6 @@ describe('MCP Callback Tools', () => { assert.equal(JSON.parse(capturedOptions.body).streamDisposition, 'replace_final'); }); - test('handleCompleteManagedHold exposes only the invocation-bound disposition input', async () => { - const { handleCompleteManagedHold } = await import('../dist/tools/callback-tools.js'); - let capturedUrl; - let capturedOptions; - globalThis.fetch = async (url, options) => { - capturedUrl = url; - capturedOptions = options; - return { ok: true, json: async () => ({ outcome: 'applied' }) }; - }; - - const result = await handleCompleteManagedHold({ disposition: 'completed' }); - - assert.equal(result.isError, undefined); - assert.ok(capturedUrl.endsWith('/api/callbacks/complete-managed-hold')); - assert.deepEqual(JSON.parse(capturedOptions.body), { disposition: 'completed' }); - assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); - assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); - }); - - test('handleCompleteA2ADispatch exposes only the invocation-bound disposition input', async () => { - const { handleCompleteA2ADispatch } = await import('../dist/tools/callback-tools.js'); - let capturedUrl; - let capturedOptions; - globalThis.fetch = async (url, options) => { - capturedUrl = url; - capturedOptions = options; - return { ok: true, json: async () => ({ outcome: 'applied' }) }; - }; - - const result = await handleCompleteA2ADispatch({ disposition: 'handled' }); - - assert.equal(result.isError, undefined); - assert.ok(capturedUrl.endsWith('/api/callbacks/complete-a2a-dispatch')); - assert.deepEqual(JSON.parse(capturedOptions.body), { disposition: 'handled' }); - assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); - assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); - }); - test('handleUpdateEntrustedWork forwards one typed nonterminal Task-owner action', async () => { const { handleUpdateEntrustedWork } = await import('../dist/tools/callback-tools.js'); let capturedUrl; diff --git a/packages/mcp-server/test/post-message-principal-schema.test.js b/packages/mcp-server/test/post-message-principal-schema.test.js index e3e2cba795..94aa8723d5 100644 --- a/packages/mcp-server/test/post-message-principal-schema.test.js +++ b/packages/mcp-server/test/post-message-principal-schema.test.js @@ -132,5 +132,14 @@ describe('post_message principal-specific public schema', () => { assert.match(definition.description, /same-thread structured single successor/i); assert.match(definition.description, /streamDisposition="replace_final"/i); assert.match(definition.description, /multi_mention.*parallel/i); + assert.match(definition.description, /courtesy ACK only when it has no explicit routing credential/i); + assert.match(definition.description, /line-start @mentions and structured targetCats always start a new active/i); + + const crossDefinition = callbackTools.find((tool) => tool.name === 'cat_cafe_cross_post_message'); + assert.ok(crossDefinition); + assert.match( + crossDefinition.description, + /line-start @mention or structured targetCats after terminal always starts a new active routing generation/i, + ); }); }); diff --git a/packages/mcp-server/test/tool-registration.test.js b/packages/mcp-server/test/tool-registration.test.js index e72d0b2429..1d08a02b8b 100644 --- a/packages/mcp-server/test/tool-registration.test.js +++ b/packages/mcp-server/test/tool-registration.test.js @@ -198,12 +198,17 @@ describe('MCP Server Tool Registration', () => { assert.match(tool.description, /resume capsule/i); }); - test('thread-context description does not discourage the full contiguous freshness path', async () => { + test('thread-context description documents the explicit full catch-up path without freshness prompting', async () => { const { callbackTools } = await import('../dist/tools/callback-tools.js'); const tool = callbackTools.find((candidate) => candidate.name === 'cat_cafe_get_thread_context'); assert.ok(tool); - assert.match(tool.description, /freshness notice/i); + assert.doesNotMatch(tool.description, /freshness notice/i); assert.match(tool.description, /same-target queued bodies/i); + assert.match(tool.description, /complete catch-up/i); + assert.match(tool.description, /contextScope="unread_delta"/); + assert.match(tool.description, /hasMore=false.*not all thread history/i); + assert.match(tool.inputSchema.responseMode.description, /contextScope="unread_delta"/); + assert.match(tool.inputSchema.responseMode.description, /hasMore=false.*all thread history/i); assert.match(tool.description, /GOTCHA:/); assert.doesNotMatch(tool.description, /full" ONLY.*bulk analysis, export/i); }); diff --git a/packages/shared/src/types/a2a-routing-mode.ts b/packages/shared/src/types/a2a-routing-mode.ts index 77ec3c27ee..19c222722f 100644 --- a/packages/shared/src/types/a2a-routing-mode.ts +++ b/packages/shared/src/types/a2a-routing-mode.ts @@ -1,54 +1,12 @@ -/** - * A2A dispatch scheduling mode — the structured carrier of "serial vs parallel". - * - * WHY THIS EXISTS (F086/F216 sub-item, #1291 field incident): - * A cat wrote two line-start @mentions in one reply and stated "并行,不串行" in prose. - * The runtime pushed both onto ONE sequential worklist (route-serial.ts) and only ever - * created the first target's invocation — but the projection layer emitted two identical - * `a2a_handoff` pills at the same millisecond, so the UI drew two equivalent arrows. - * The scheduling mode was carried by nothing at all; readers inferred it from the fact - * that two targets appeared side by side. That is a silent semantic downgrade. - * - * CONTRACT: - * - Mode is ALWAYS explicit and structured. It is never inferred from how many targets - * there are, from their ordering, or from natural language in the message body. - * - `serial` — one ordered worklist; target N+1 starts only after target N terminates. - * Inline line-start @mentions are serial BY CONTRACT (see A2A_INLINE_MENTION_MODE). - * - `parallel` — independent fan-out; every target obtains its own invocation/Queue custody - * before any target reaches a terminal. Only reachable through the structured - * `cat_cafe_multi_mention` + `action.mode="parallel"` + `parallelIntent` path. - */ +/** Structured scheduling mode used by explicit routing decisions. */ export type A2ARoutingMode = 'serial' | 'parallel'; /** - * Inline line-start @mentions are SERIAL, always. - * - * This is a normalization, not a guess: it names the semantics route-serial.ts has always - * executed (a single `worklist` drained by a single `while`). Declaring it explicitly is what - * stops multi-@ from silently *looking* parallel. Cats that want real fan-out must ask for it - * structurally via `cat_cafe_multi_mention(mode="parallel", parallelIntent=...)`; the runtime - * never reads the prose for words like "并行". - */ -export const A2A_INLINE_MENTION_MODE: A2ARoutingMode = 'serial'; - -/** - * Structured routing projection attached to one dispatched target. - * - * `index`/`total` are 1-based positions inside the dispatch's real scheduling unit — NOT merely the - * targets named in one turn (砚砚 R1): - * - `serial` — the position inside the whole pending worklist, so `total` also counts original - * targets still queued ahead. That is the honest start order: those cats really do - * run before this leg. Only `index === 1` is actually starting now. - * - `parallel` — bookkeeping over the siblings that ACTUALLY obtained custody (rejected/depth-limited - * targets are never projected), implying no ordering between them. + * User-visible projection for one admitted parallel A2A target. Serial A2A + * successors are independent durable wakes and do not emit handoff projections. */ export interface A2ARoutingProjection { - readonly mode: A2ARoutingMode; + readonly mode: 'parallel'; readonly index: number; readonly total: number; } - -/** True when this target is the one actually starting now (serial 第 1 棒, or any parallel target). */ -export function isRoutingProjectionStartingNow(projection: A2ARoutingProjection): boolean { - return projection.mode === 'parallel' || projection.index <= 1; -} diff --git a/packages/shared/src/types/active-execution.ts b/packages/shared/src/types/active-execution.ts index 268c92eea3..69a95d4fc4 100644 --- a/packages/shared/src/types/active-execution.ts +++ b/packages/shared/src/types/active-execution.ts @@ -1,5 +1,7 @@ /** F295 canonical read projection shared by API and every running-work UI surface. */ +import type { LifecycleInputCapabilities } from './message-lifecycle.js'; + export type ActiveExecutionKind = 'live_invocation' | 'managed_command'; export type ManagedCommandActivity = 'full_gate' | 'test' | 'build' | 'lint' | 'check' | 'command'; @@ -40,6 +42,8 @@ export interface ActiveExecutionProjection { readonly activity?: ManagedCommandActivity; readonly startedAt: number; readonly cancelability: ActiveExecutionCancelability; + /** Exact live input affordances projected by the server; absence fails closed. */ + readonly inputCapabilities?: LifecycleInputCapabilities; } export interface ActiveExecutionListResponse { diff --git a/packages/shared/src/types/cat-breed.ts b/packages/shared/src/types/cat-breed.ts index f12c8e98a2..0e054be037 100644 --- a/packages/shared/src/types/cat-breed.ts +++ b/packages/shared/src/types/cat-breed.ts @@ -13,6 +13,7 @@ import type { CliEffortValue } from '../cli-effort.js'; import type { CodexSpeedValue } from '../codex-speed.js'; import type { AgyProfileConfig, CatColor, ClientId } from './cat.js'; +import type { CatCarrier } from './cat-carrier.js'; import type { CatId } from './ids.js'; import type { VoiceConfig } from './tts.js'; @@ -27,13 +28,6 @@ export interface CliConfig { * Defaults: 'max' (claude) / 'xhigh' (codex). */ readonly effort?: CliEffortValue; - /** - * Codex-only carrier override (F254 D2): 'exec_json' (one-shot `codex exec`) - * or 'app_server' (pooled app-server host). Absent = follow the process-level - * CAT_CAFE_CODEX_CARRIER env. Only meaningful for clientId 'openai'; the cats - * API rejects it for other clients. - */ - readonly carrier?: 'exec_json' | 'app_server'; /** F291: Codex OAuth request tier. Absent inherits the Codex user config. */ readonly serviceTier?: CodexSpeedValue; } @@ -64,6 +58,8 @@ export interface CatVariant { readonly accountRef?: string; /** clowder-ai#340 P5: CLI client identity (renamed from `provider`). */ readonly clientId: ClientId; + /** Canonical member access mode. Config reads default to `cli`. */ + readonly carrier?: CatCarrier; readonly defaultModel: string; readonly mcpSupport: boolean; readonly cli: CliConfig; diff --git a/packages/shared/src/types/cat-carrier.ts b/packages/shared/src/types/cat-carrier.ts new file mode 100644 index 0000000000..6a92ce5b4d --- /dev/null +++ b/packages/shared/src/types/cat-carrier.ts @@ -0,0 +1,28 @@ +import type { ClientId } from './cat.js'; + +/** + * Member access mode. This is deliberately separate from wire transports such + * as MCP stdio/HTTP and ACP stdio/httpstream. + */ +export const CAT_CARRIERS = ['cli', 'sdk', 'app_server', 'acp'] as const; +export type CatCarrier = (typeof CAT_CARRIERS)[number]; + +const CARRIERS_BY_CLIENT: Readonly> = Object.freeze({ + anthropic: ['cli', 'sdk'], + openai: ['cli', 'app_server'], + google: ['cli', 'acp'], + kimi: ['cli', 'acp'], + opencode: ['cli', 'acp'], + acp: ['acp'], + antigravity: ['cli'], + catagent: ['cli'], + a2a: ['cli'], +}); + +export function getCatCarrierOptions(clientId: ClientId | string): readonly CatCarrier[] { + return CARRIERS_BY_CLIENT[clientId] ?? ['cli']; +} + +export function catClientSupportsCarrier(clientId: ClientId | string, carrier: CatCarrier): boolean { + return getCatCarrierOptions(clientId).includes(carrier); +} diff --git a/packages/shared/src/types/cat-routing.ts b/packages/shared/src/types/cat-routing.ts index 60c035a5f8..515e769cbf 100644 --- a/packages/shared/src/types/cat-routing.ts +++ b/packages/shared/src/types/cat-routing.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import type { CatId } from './ids.js'; export interface CatAlternative { @@ -12,3 +13,47 @@ export type CatRoutingError = | { kind: 'cat_disabled'; catId: CatId; displayName: string; alternatives: CatAlternative[] } | { kind: 'target_not_in_thread'; catId: CatId; threadId: string } | { kind: 'suppressed_by_terminal_ack'; droppedMentions: CatId[] }; + +const NonEmptyStringSchema = z.string().min(1); +const CatIdSchema = NonEmptyStringSchema.transform((value) => value as CatId); + +export const CatAlternativeSchema: z.ZodType = z + .object({ + catId: CatIdSchema, + mention: NonEmptyStringSchema, + displayName: NonEmptyStringSchema, + family: NonEmptyStringSchema, + }) + .strict(); + +/** Runtime counterpart of CatRoutingError for ingress and persistence boundaries. */ +export const CatRoutingErrorSchema: z.ZodType = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('cat_not_found'), + mention: NonEmptyStringSchema, + alternatives: z.array(CatAlternativeSchema), + }) + .strict(), + z + .object({ + kind: z.literal('cat_disabled'), + catId: CatIdSchema, + displayName: NonEmptyStringSchema, + alternatives: z.array(CatAlternativeSchema), + }) + .strict(), + z + .object({ + kind: z.literal('target_not_in_thread'), + catId: CatIdSchema, + threadId: NonEmptyStringSchema, + }) + .strict(), + z + .object({ + kind: z.literal('suppressed_by_terminal_ack'), + droppedMentions: z.array(CatIdSchema), + }) + .strict(), +]); diff --git a/packages/shared/src/types/cat.ts b/packages/shared/src/types/cat.ts index cee9be995c..59dbc6ad74 100644 --- a/packages/shared/src/types/cat.ts +++ b/packages/shared/src/types/cat.ts @@ -4,6 +4,7 @@ */ import type { CliConfig } from './cat-breed.js'; +import type { CatCarrier } from './cat-carrier.js'; import type { CatId, SessionId } from './ids.js'; import { createCatId } from './ids.js'; import type { VoiceConfig } from './tts.js'; @@ -67,6 +68,8 @@ export interface CatConfig { readonly accountRef?: string; /** clowder-ai#340 P5: CLI client identity (renamed from `provider`). */ readonly clientId: ClientId; + /** Canonical, config-resolved member access mode. */ + readonly carrier: CatCarrier; readonly defaultModel: string; readonly mcpSupport: boolean; readonly cli?: CliConfig; diff --git a/packages/shared/src/types/cli-diagnostics.ts b/packages/shared/src/types/cli-diagnostics.ts index 5e4d99e555..a80008449b 100644 --- a/packages/shared/src/types/cli-diagnostics.ts +++ b/packages/shared/src/types/cli-diagnostics.ts @@ -36,8 +36,6 @@ export type CliErrorReasonCode = * per se but surfaced via cliDiagnostics so users get evidence instead of generic * "completed without textual output" message. */ | 'silent_completion' - /** Codex preserved the native session after a bounded active-writer recovery refusal. */ - | 'active_writer_recovery' /** Phase H (Sol runtime forensics 2026-07-09): upstream provider (Codex 0.98+) sent * `{type:"error", message:"This content was flagged for possible cybersecurity risk..."}` * followed by `turn.failed` + exit 1. NOT a Clowder AI bug — upstream policy engine decision. @@ -52,8 +50,6 @@ export type CliErrorReasonCode = * invalid_config (a config *file* is malformed) and spawn_failed (binary missing). */ | 'incompatible_cli_arguments'; -export type CliActiveWriterRecoveryState = 'owner_busy' | 'retiring' | 'external_or_unknown'; - /** * Structured CLI error payload (Phase A KD-1 white-list admission). * @@ -83,10 +79,6 @@ export interface CliDiagnostics { * malformed payloads (no source) AND fails closed when older clients see a future * source value they don't recognize (e.g. a hypothetical 'pii_redacted'). */ excerptSource?: 'classifier' | 'cc_structured' | 'unknown_raw'; - /** Present only for typed active_writer_recovery diagnostics. */ - activeWriterRecovery?: { - state: CliActiveWriterRecoveryState; - }; /** Debug correlation metadata — safe to expose */ debugRef: { command: string; diff --git a/packages/shared/src/types/cloud-bridge-outbound-receipt.ts b/packages/shared/src/types/cloud-bridge-outbound-receipt.ts index 7c4d149006..484451fe5f 100644 --- a/packages/shared/src/types/cloud-bridge-outbound-receipt.ts +++ b/packages/shared/src/types/cloud-bridge-outbound-receipt.ts @@ -79,6 +79,14 @@ export interface CloudBridgeRecoveryV1 { readonly dispatchInvocationId: string; } +/** Durable refs-only provenance for the fresh user source created by Retry. */ +export interface CloudBridgeRetryV1 { + readonly v: 1; + readonly sourceMessageId: string; + readonly targetCatId: string; + readonly priorDispatchInvocationId: string; +} + const RECOVERY_FIELDS = new Set(['v', 'kind', 'sourceMessageId', 'targetCatId', 'dispatchInvocationId']); export function isCloudBridgeRecoveryV1(value: unknown): value is CloudBridgeRecoveryV1 { @@ -94,6 +102,20 @@ export function isCloudBridgeRecoveryV1(value: unknown): value is CloudBridgeRec ); } +const RETRY_FIELDS = new Set(['v', 'sourceMessageId', 'targetCatId', 'priorDispatchInvocationId']); + +export function isCloudBridgeRetryV1(value: unknown): value is CloudBridgeRetryV1 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const retry = value as Record; + return ( + Object.keys(retry).every((field) => RETRY_FIELDS.has(field)) && + retry.v === 1 && + isBoundedReceiptRef(retry.sourceMessageId) && + isBoundedReceiptRef(retry.targetCatId) && + isBoundedReceiptRef(retry.priorDispatchInvocationId) + ); +} + function isBoundedReceiptRef(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= 512; } diff --git a/packages/shared/src/types/connector.ts b/packages/shared/src/types/connector.ts index 7c0e133de9..dc69df4858 100644 --- a/packages/shared/src/types/connector.ts +++ b/packages/shared/src/types/connector.ts @@ -72,19 +72,14 @@ export interface ConnectorSource { readonly sender?: { readonly id: string; readonly name?: string }; } -/** - * F167 PR5: immutable identity for the user-visible managed-hold wake/result - * rows that Message Bundle may offer. Runtime authorship and owner access are - * additional server-side guards; this shared predicate keeps Web affordance - * and API source classification on the same connector contract. - */ -export function isSelectableManagedHoldConnectorSource( +/** Exact identity shared by the visible waiting fact and the queued wake source. */ +export function isManagedHoldConnectorSource( source: Pick | null | undefined, ): boolean { const meta = source?.meta; return Boolean( source?.connector === 'hold-ball' && - meta?.wakeWhen === true && + meta?.managedHold === true && typeof meta.taskId === 'string' && meta.taskId.trim().length > 0 && typeof meta.threadId === 'string' && @@ -94,6 +89,13 @@ export function isSelectableManagedHoldConnectorSource( ); } +/** Only the delivered wake/result is a quotable dispatch source; waiting status is context only. */ +export function isSelectableManagedHoldConnectorSource( + source: Pick | null | undefined, +): boolean { + return isManagedHoldConnectorSource(source) && source?.meta?.phase === 'wake'; +} + // ── Connector Definition (registry entry) ── /** How a connector's avatar icon is rendered. diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 14d29f956f..994d27c9ae 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -43,13 +43,8 @@ export type { A2ATask, A2ATaskStatus, } from './a2a.js'; -// F086/F216: structured A2A scheduling mode (serial vs parallel) — never inferred from ordering -export { - A2A_INLINE_MENTION_MODE, - type A2ARoutingMode, - type A2ARoutingProjection, - isRoutingProjectionStartingNow, -} from './a2a-routing-mode.js'; +// Structured routing mode and admitted parallel fan-out projection. +export type { A2ARoutingMode, A2ARoutingProjection } from './a2a-routing-mode.js'; // F167 Phase S: action-scoped A2A successor single-flight contract export { ACTION_SUBJECT_REF_DESCRIPTION, @@ -271,10 +266,17 @@ export type { Roster, RosterEntry, } from './cat-breed.js'; +export { + CAT_CARRIERS, + type CatCarrier, + catClientSupportsCarrier, + getCatCarrierOptions, +} from './cat-carrier.js'; // F182: Cat routing error types export type { CatAlternative, CatRoutingError } from './cat-routing.js'; +export { CatAlternativeSchema, CatRoutingErrorSchema } from './cat-routing.js'; // F212: CLI error diagnostics (cross-package data contract; classifier/sanitizer impl stays in api) -export type { CliActiveWriterRecoveryState, CliDiagnostics, CliErrorReasonCode } from './cli-diagnostics.js'; +export type { CliDiagnostics, CliErrorReasonCode } from './cli-diagnostics.js'; export type { BuiltinAccountClient } from './client-routing.js'; export { BUILTIN_ACCOUNT_CLIENT_FOR_ID, @@ -292,11 +294,13 @@ export type { CloudBridgeOutboundStatus, CloudBridgeOutboundTransport, CloudBridgeRecoveryV1, + CloudBridgeRetryV1, } from './cloud-bridge-outbound-receipt.js'; export { isCloudBridgeFailureDiagnosticV1, isCloudBridgeOutboundReceiptV1, isCloudBridgeRecoveryV1, + isCloudBridgeRetryV1, } from './cloud-bridge-outbound-receipt.js'; export * from './collective.js'; // Command types (F142 Phase B — slash command framework) @@ -440,6 +444,7 @@ export type { export { getAllConnectorDefinitions, getConnectorDefinition, + isManagedHoldConnectorSource, isSelectableManagedHoldConnectorSource, isStaticConnectorId, registerConnectorDefinition, @@ -1029,6 +1034,34 @@ export { createCatMessage, createUserMessage, } from './message.js'; +// #1354 / RFC #1356: canonical Queue → History → Active Run lifecycle contract. +export type { + LifecycleActiveRun, + LifecycleAppendAction, + LifecycleAppendCapability, + LifecycleAppendExpectedRun, + LifecycleDeliveryFailureReason, + LifecycleDeliveryFailureResult, + LifecycleDispatchRef, + LifecycleInlinePayload, + LifecycleInputCapabilities, + LifecycleMessageFrom, + LifecycleMessageMetadata, + LifecycleMessageRefPayload, + LifecycleQueueEntry, + LifecycleQueuePriority, + LifecycleQueueSnapshot, + LifecycleResponseBubble, + LifecycleStoredMessageMetadata, + MessageFrom, + ReorderVisibleLifecycleEntriesCommand, + StructuredOwnerAdmissionBinding, +} from './message-lifecycle.js'; +export { + hasExactLifecycleProcessingDispatch, + isLifecycleStoredMessageMetadata, + isMessageFrom, +} from './message-lifecycle.js'; // Mount Rules types (F228 Skill 挂载规则解耦) export type { CustomMountPointRule, @@ -1378,8 +1411,8 @@ export { type ProviderSubexecutionStage, type ProviderWarningSemanticEvent, } from './provider-semantic-event.js'; -// F264: durable per-target queued-message receipt and manual reminder truth export type { + ActiveInvocationGuidanceCapability, FreshnessCarrier, FreshnessCarrierCapability, FreshnessCarrierDeliverySemantics, @@ -1391,15 +1424,8 @@ export type { QueueHandledDisposition, QueueLineageEvidenceRef, QueueManagedHoldContinuationWitness, - QueueMessageReceipt, - QueueMessageReceiptProjection, - QueueReceiptTarget, - QueueReceiptTargetState, QueueRecoveryAction, QueueRecoveryRequest, - QueueReminderAttempt, - QueueReminderAttemptState, - QueueReminderMissedReason, QueueSourceResponseConsumptionWitness, QueueTargetAttempt, QueueTargetAttemptState, @@ -1410,6 +1436,15 @@ export type { QueueTerminalSilentConsumptionWitness, QueueTurnExecutionEvidenceRef, } from './queue-receipt.js'; +// F264: queued-message delivery intent and recovery actions +export { + ACTIVE_INVOCATION_GUIDANCE_CAPABILITIES, + FRESHNESS_CARRIER_DELIVERY_SEMANTICS, + FRESHNESS_CARRIER_PROVIDERS, + FRESHNESS_CARRIERS, + parseFreshnessCarrierCapability, + supportsActiveInvocationGuidance, +} from './queue-receipt.js'; // Reflux types (F076 Phase 2 — 回流) export type { CreateRefluxPatternInput, diff --git a/packages/shared/src/types/message-lifecycle.ts b/packages/shared/src/types/message-lifecycle.ts new file mode 100644 index 0000000000..5b8c111373 --- /dev/null +++ b/packages/shared/src/types/message-lifecycle.ts @@ -0,0 +1,377 @@ +import type { CatRoutingError } from './cat-routing.js'; +import type { MessageContent } from './message.js'; + +export type LifecycleQueuePriority = 'urgent' | 'normal'; + +/** RFC #1356: the only sender identity shared by Queue and History. */ +export type MessageFrom = + | { readonly kind: 'user'; readonly userId: string } + | { readonly kind: 'agent'; readonly catId: string } + | { + readonly kind: 'external'; + readonly connectorId: string; + readonly sender?: { readonly id: string; readonly name?: string }; + readonly address?: { readonly chatId: string; readonly messageId?: string }; + } + | { readonly kind: 'plugin'; readonly instanceId: string } + | { readonly kind: 'system'; readonly service: string }; + +/** @deprecated Use MessageFrom. */ +export type LifecycleMessageFrom = MessageFrom; + +export interface LifecycleInlinePayload { + readonly type: 'inline'; + readonly body: readonly MessageContent[]; + readonly routingWarnings?: readonly CatRoutingError[]; +} + +export interface LifecycleMessageRefPayload { + readonly type: 'message_ref'; + readonly messageId: string; +} + +interface LifecycleQueueEntryBase { + readonly id: string; + readonly threadId: string; + readonly from: MessageFrom; + readonly targets: readonly string[]; + readonly ownerAuthProvenance: 'strict' | 'compatibility_fallback' | 'unknown'; + readonly priority: LifecycleQueuePriority; + readonly enqueuedAt: number; +} + +export type LifecycleQueueEntry = + | (LifecycleQueueEntryBase & { + readonly kind: 'conversation_input'; + readonly sourceRecordId: string; + readonly payload: LifecycleInlinePayload; + readonly position?: number; + }) + | (LifecycleQueueEntryBase & { + readonly kind: 'message_wake'; + readonly payload: LifecycleMessageRefPayload; + readonly position?: number; + }) + | (LifecycleQueueEntryBase & { + readonly kind: 'private_input'; + readonly payload: LifecycleInlinePayload; + }); + +export interface LifecycleQueueSnapshot { + readonly revision: string; + readonly entries: readonly LifecycleQueueEntry[]; + /** Exact visible rows that the server declared reorderable at this revision. */ + readonly reorderableVisibleEntryIds: readonly string[]; +} + +export interface ReorderVisibleLifecycleEntriesCommand { + readonly threadId: string; + readonly expectedQueueRevision: string; + readonly orderedVisibleEntryIds: readonly string[]; +} + +export type LifecycleDispatchRef = + | { + readonly targetId: string; + readonly phase: 'dispatched'; + readonly statusMessageId: string; + /** Missing only on hydrated pre-v2 refs; new writers always persist it. */ + readonly dispatchedAt?: number; + } + | { + readonly targetId: string; + readonly phase: 'settled'; + readonly statusMessageId: string; + /** Missing only on hydrated pre-v2 refs; new writers always persist it. */ + readonly dispatchedAt?: number; + }; + +export interface LifecycleMessageMetadata { + readonly orderKey: string; + readonly dispatchRefs?: readonly LifecycleDispatchRef[]; + readonly producerInvocationId?: string; +} + +export type LifecycleDeliveryFailureReason = + | 'no_available_target' + | 'invalid_explicit_target' + | 'control_carrier_missing' + | 'control_carrier_replaced' + | 'control_plane_unavailable' + | 'execution_owner_lost' + | 'prestart_timeout'; + +/** + * Durable lifecycle metadata stored beside the canonical message body. + * + * The body intentionally remains on StoredMessage.content/contentBlocks so it + * cannot diverge from a second lifecycle-owned copy. + */ +export type LifecycleStoredMessageMetadata = + | (LifecycleMessageMetadata & { + readonly kind: 'input'; + }) + | (LifecycleMessageMetadata & { + readonly kind: 'response'; + readonly invocationId: string; + readonly targetId: string; + readonly inputEntryIds: readonly string[]; + readonly inputMessageIds: readonly string[]; + readonly status: 'processing' | 'completed' | 'failed' | 'canceled' | 'interrupted'; + readonly startedAt: number; + readonly completedAt?: number; + readonly reason?: string; + }) + | (LifecycleMessageMetadata & { + readonly kind: 'delivery_failure'; + readonly status: 'failed'; + readonly sourceEntryId: string; + readonly inputMessageId: string; + readonly requestedTargets: readonly string[]; + readonly reason: LifecycleDeliveryFailureReason; + readonly createdAt: number; + }); + +function isFiniteTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +export function isMessageFrom(value: unknown): value is MessageFrom { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + switch (candidate.kind) { + case 'user': + return isNonEmptyString(candidate.userId); + case 'agent': + return isNonEmptyString(candidate.catId); + case 'external': + return ( + isNonEmptyString(candidate.connectorId) && + (candidate.sender === undefined || + (candidate.sender !== null && + typeof candidate.sender === 'object' && + isNonEmptyString((candidate.sender as Record).id) && + ((candidate.sender as Record).name === undefined || + isNonEmptyString((candidate.sender as Record).name)))) && + (candidate.address === undefined || + (candidate.address !== null && + typeof candidate.address === 'object' && + isNonEmptyString((candidate.address as Record).chatId) && + ((candidate.address as Record).messageId === undefined || + isNonEmptyString((candidate.address as Record).messageId)))) + ); + case 'plugin': + return isNonEmptyString(candidate.instanceId); + case 'system': + return isNonEmptyString(candidate.service); + default: + return false; + } +} + +function isDispatchRef(value: unknown): value is LifecycleDispatchRef { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + if (!isNonEmptyString(candidate.targetId)) return false; + return ( + (candidate.phase === 'dispatched' || candidate.phase === 'settled') && + isNonEmptyString(candidate.statusMessageId) && + (candidate.dispatchedAt === undefined || isFiniteTimestamp(candidate.dispatchedAt)) + ); +} + +/** Fail-closed parser guard for the independent Redis lifecycle field. */ +export function isLifecycleStoredMessageMetadata(value: unknown): value is LifecycleStoredMessageMetadata { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + if ( + !isNonEmptyString(candidate.orderKey) || + // Legacy lifecycle rows duplicated MessageFrom here. Accept only a valid + // legacy copy so hydration can lift it to StoredMessage.from and strip it. + (candidate.from !== undefined && !isMessageFrom(candidate.from)) || + (candidate.dispatchRefs !== undefined && + (!Array.isArray(candidate.dispatchRefs) || !candidate.dispatchRefs.every(isDispatchRef))) || + (candidate.producerInvocationId !== undefined && !isNonEmptyString(candidate.producerInvocationId)) + ) { + return false; + } + if (candidate.kind === 'input') return true; + if (candidate.kind === 'delivery_failure') { + return ( + candidate.status === 'failed' && + isNonEmptyString(candidate.sourceEntryId) && + isNonEmptyString(candidate.inputMessageId) && + Array.isArray(candidate.requestedTargets) && + candidate.requestedTargets.every(isNonEmptyString) && + new Set(candidate.requestedTargets).size === candidate.requestedTargets.length && + [ + 'no_available_target', + 'invalid_explicit_target', + 'control_carrier_missing', + 'control_carrier_replaced', + 'control_plane_unavailable', + 'execution_owner_lost', + 'prestart_timeout', + ].includes(String(candidate.reason)) && + isFiniteTimestamp(candidate.createdAt) + ); + } + if ( + candidate.kind !== 'response' || + !isNonEmptyString(candidate.invocationId) || + !isNonEmptyString(candidate.targetId) || + !Array.isArray(candidate.inputEntryIds) || + !candidate.inputEntryIds.every(isNonEmptyString) || + !Array.isArray(candidate.inputMessageIds) || + !candidate.inputMessageIds.every(isNonEmptyString) || + !['processing', 'completed', 'failed', 'canceled', 'interrupted'].includes(String(candidate.status)) || + !isFiniteTimestamp(candidate.startedAt) + ) { + return false; + } + if (candidate.status === 'processing') { + return candidate.completedAt === undefined && candidate.reason === undefined; + } + return ( + isFiniteTimestamp(candidate.completedAt) && + candidate.completedAt >= candidate.startedAt && + (candidate.reason === undefined || isNonEmptyString(candidate.reason)) + ); +} + +export interface LifecycleDeliveryFailureResult { + readonly kind: 'delivery_failure'; + readonly status: 'failed'; + readonly id: string; + readonly threadId: string; + readonly orderKey: string; + readonly sourceEntryId: string; + readonly inputMessageId: string; + readonly requestedTargets: readonly string[]; + readonly reason: LifecycleDeliveryFailureReason; + readonly body: readonly MessageContent[]; + readonly createdAt: number; +} + +export interface LifecycleResponseBubble { + readonly id: string; + readonly threadId: string; + readonly orderKey: string; + readonly invocationId: string; + readonly targetId: string; + readonly inputEntryIds: readonly string[]; + readonly inputMessageIds: readonly string[]; + readonly body: readonly MessageContent[]; + readonly status: 'processing' | 'completed' | 'failed' | 'canceled' | 'interrupted'; + readonly dispatchRefs?: readonly LifecycleDispatchRef[]; + readonly startedAt: number; + readonly completedAt?: number; + readonly reason?: string; +} + +export interface LifecycleActiveRun { + readonly threadId: string; + readonly targetId: string; + readonly invocationId: string; + readonly responseMessageId: string; + readonly inputEntryIds: readonly string[]; + readonly inputMessageIds: readonly string[]; + readonly privateInputEntryIds: readonly string[]; + readonly startedAt: number; +} + +/** + * Canonical live-dispatch predicate shared by UI and agent situation projection. + * A source is "processing" only when its dispatch ref, response bubble, and one + * exact Active Run all agree. Ambiguous or partial evidence fails closed. + */ +export function hasExactLifecycleProcessingDispatch(input: { + readonly sourceMessageId: string; + readonly sourceDispatchRefs: readonly LifecycleDispatchRef[]; + readonly responseMessageId: string; + readonly responseLifecycle: LifecycleStoredMessageMetadata | undefined; + readonly activeRuns: readonly LifecycleActiveRun[]; +}): boolean { + const response = input.responseLifecycle; + if ( + !response || + response.kind !== 'response' || + response.status !== 'processing' || + !response.inputMessageIds.includes(input.sourceMessageId) + ) { + return false; + } + const refs = input.sourceDispatchRefs.filter( + (ref) => + ref.targetId === response.targetId && + ref.phase === 'dispatched' && + ref.statusMessageId === input.responseMessageId, + ); + const targetRefs = input.sourceDispatchRefs.filter((ref) => ref.targetId === response.targetId); + if (refs.length !== 1 || targetRefs.length !== 1) return false; + return ( + input.activeRuns.filter( + (run) => + run.targetId === response.targetId && + run.responseMessageId === input.responseMessageId && + run.invocationId === response.invocationId && + run.inputMessageIds.includes(input.sourceMessageId), + ).length === 1 + ); +} + +/** Exact live-run fence returned by the server for one explicit Queue Append. */ +export interface LifecycleAppendExpectedRun { + readonly targetId: string; + readonly invocationId: string; + readonly responseMessageId: string; +} + +/** + * Server-authored proof that one currently visible member can accept a new + * input without ending its Active Run. Presentation code may show an Append + * affordance only while this exact-run proof is present. + */ +export interface LifecycleAppendCapability { + readonly kind: 'append'; + readonly expectedRun: LifecycleAppendExpectedRun; +} + +export interface LifecycleInputCapabilities { + readonly append?: LifecycleAppendCapability; +} + +/** + * Server-authored Append affordance. The client must echo both fences and may + * never infer Append eligibility from a locally visible "working" badge. + */ +export interface LifecycleAppendAction { + readonly kind: 'append'; + readonly expectedQueueRevision: string; + readonly expectedRuns: readonly LifecycleAppendExpectedRun[]; +} + +export interface StructuredOwnerAdmissionBinding { + readonly invocationId: string; + readonly entryId: string; + readonly targetId: string; + readonly ownerKind: string; + readonly ownerSubjectRef: string; + readonly leaseId?: string; + readonly generation: number; + readonly frozenPredicate: { + readonly kind: string; + readonly value: string; + readonly headSha?: string; + }; + readonly principal: { + readonly tenantId: string; + readonly routeId: string; + readonly callbackPrincipalId: string; + }; + readonly admittedAt: number; +} diff --git a/packages/shared/src/types/queue-receipt.ts b/packages/shared/src/types/queue-receipt.ts index 3b7fdcff35..01b37b5ce0 100644 --- a/packages/shared/src/types/queue-receipt.ts +++ b/packages/shared/src/types/queue-receipt.ts @@ -1,23 +1,13 @@ -export type QueueReceiptTargetState = - | 'queued' - | 'notified' - | 'awakened' - | 'seen' - | 'failed' - | 'interrupted' - | 'steering' - | 'withdrawn' - | 'handled'; - -export type QueueHandledDisposition = 'responded' | 'completed_with_turn' | 'managed_hold_disposition'; +export type QueueHandledDisposition = 'responded' | 'completed_with_turn'; export type MessageWorkDisposition = 'continue_current' | 'next_work'; /** Exact provider + concrete transport truth used by composer, Queue and receipts. */ -export type FreshnessCarrierProvider = 'openai_codex' | 'anthropic' | 'kimi' | 'other'; +export type FreshnessCarrierProvider = 'openai_codex' | 'anthropic' | 'opencode' | 'kimi' | 'other'; export type FreshnessCarrier = | 'codex_app_server' | 'codex_exec_json' + | 'claude_agent_sdk' | 'claude_print_sdk' | 'claude_stream_json' | 'kimi_stream_json' @@ -29,11 +19,80 @@ export type FreshnessCarrierDeliverySemantics = | 'mcp_result_piggyback' | 'unsupported' | 'undeclared'; +export type ActiveInvocationGuidanceCapability = 'supported' | 'unsupported' | 'undeclared'; export interface FreshnessCarrierCapability { provider: FreshnessCarrierProvider; carrier: FreshnessCarrier; deliverySemantics: FreshnessCarrierDeliverySemantics; + /** Whether this concrete adapter can append input to its current invocation without interrupting it. */ + activeInvocationGuidance: ActiveInvocationGuidanceCapability; +} + +export const FRESHNESS_CARRIER_PROVIDERS = [ + 'openai_codex', + 'anthropic', + 'opencode', + 'kimi', + 'other', +] as const satisfies readonly FreshnessCarrierProvider[]; + +export const FRESHNESS_CARRIERS = [ + 'codex_app_server', + 'codex_exec_json', + 'claude_agent_sdk', + 'claude_print_sdk', + 'claude_stream_json', + 'kimi_stream_json', + 'mcp_result_piggyback', + 'other', +] as const satisfies readonly FreshnessCarrier[]; + +export const FRESHNESS_CARRIER_DELIVERY_SEMANTICS = [ + 'exact_active_turn', + 'queued_internal_turn', + 'mcp_result_piggyback', + 'unsupported', + 'undeclared', +] as const satisfies readonly FreshnessCarrierDeliverySemantics[]; +export const ACTIVE_INVOCATION_GUIDANCE_CAPABILITIES = [ + 'supported', + 'unsupported', + 'undeclared', +] as const satisfies readonly ActiveInvocationGuidanceCapability[]; + +const FRESHNESS_CARRIER_PROVIDER_SET = new Set(FRESHNESS_CARRIER_PROVIDERS); +const FRESHNESS_CARRIER_SET = new Set(FRESHNESS_CARRIERS); +const FRESHNESS_CARRIER_DELIVERY_SEMANTICS_SET = new Set(FRESHNESS_CARRIER_DELIVERY_SEMANTICS); +const ACTIVE_INVOCATION_GUIDANCE_CAPABILITY_SET = new Set(ACTIVE_INVOCATION_GUIDANCE_CAPABILITIES); + +/** Runtime parser shared by API and Web so new carrier literals cannot silently become undeclared. */ +export function parseFreshnessCarrierCapability(value: unknown): FreshnessCarrierCapability | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Record; + if ( + typeof candidate.provider !== 'string' || + typeof candidate.carrier !== 'string' || + typeof candidate.deliverySemantics !== 'string' || + typeof candidate.activeInvocationGuidance !== 'string' || + !FRESHNESS_CARRIER_PROVIDER_SET.has(candidate.provider) || + !FRESHNESS_CARRIER_SET.has(candidate.carrier) || + !FRESHNESS_CARRIER_DELIVERY_SEMANTICS_SET.has(candidate.deliverySemantics) || + !ACTIVE_INVOCATION_GUIDANCE_CAPABILITY_SET.has(candidate.activeInvocationGuidance) + ) { + return undefined; + } + return candidate as unknown as FreshnessCarrierCapability; +} + +/** + * Product capability for adding input to the member's current invocation. + * Freshness precision is a separate fact: exact-active-turn and queued-internal-turn + * carriers can both guide the same invocation, while only the former proves that + * the currently visible provider turn consumed the input. + */ +export function supportsActiveInvocationGuidance(capability: FreshnessCarrierCapability | undefined): boolean { + return capability?.activeInvocationGuidance === 'supported'; } export type QueueAuthorIntentFallbackReason = @@ -45,7 +104,9 @@ export type QueueAuthorIntentFallbackReason = /** * Immutable author request plus an append-only fail-closed fallback fact. - * `continue_current` is only an exposure permission; it is never read/handled proof. + * `continue_current` requests non-interrupting guidance of the member's current + * invocation. It is still only delivery permission: neither the request nor + * provider/local acceptance is proof that the currently visible turn read it. */ export interface QueueAuthorIntent { requested: MessageWorkDisposition; @@ -123,21 +184,6 @@ export interface QueueTargetOutcome { consumption?: QueueTerminalConsumptionWitness; } -export type QueueReminderAttemptState = 'requested' | 'delivered' | 'seen' | 'missed'; -export type QueueReminderMissedReason = 'invocation_ended_before_delivery' | 'delivered_not_read' | 'source_withdrawn'; - -export interface QueueReminderAttempt { - id: string; - targetCatId: string; - invocationId: string; - state: QueueReminderAttemptState; - requestedAt: number; - deliveredAt?: number; - seenAt?: number; - missedAt?: number; - missedReason?: QueueReminderMissedReason; -} - /** * One immutable delivery attempt for one target of an authored Queue message. * A retry always appends a new attempt; it never rewrites the failed one or @@ -153,6 +199,9 @@ export type QueueTargetAttemptState = | 'handled'; export type QueueTargetAttemptTerminalReason = | 'invocation_failed' + | 'control_plane_unavailable' + | 'execution_owner_lost' + | 'prestart_timeout' | 'runtime_restart' | 'invocation_cancelled' | 'source_withdrawn'; @@ -167,42 +216,15 @@ export interface QueueTargetAttempt { invocationId?: string; /** Exact prompt-body exposure time when this attempt reached a reply. */ seenAt?: number; + /** + * Durable provider acknowledgement that this source entered an already-active + * invocation through the client's generic append capability. Mere exposure, + * batching, or matching invocation ids must never populate this field. + */ + activeAppendAcceptedAt?: number; terminalReason?: QueueTargetAttemptTerminalReason; } -export interface QueueReceiptTarget { - catId: string; - state: QueueReceiptTargetState; - authorIntent?: QueueAuthorIntentReceipt; - invocationId?: string; - /** Exact time the durable child invocation was created for this target. */ - awakenedAt?: number; - /** Exact time this target's child invocation first received the persisted message body. */ - seenAt?: number; - /** Exact time the author removed this target from actionable Queue custody. */ - withdrawnAt?: number; - outcome?: QueueTargetOutcome; - /** Append-only target-local delivery history. Missing only on legacy receipts. */ - attempts?: QueueTargetAttempt[]; - /** False when no durable retry is possible or the fenced business action is already terminal. */ - retryable?: boolean; -} - -export interface QueueMessageReceipt { - version: 1; - entryId: string; - /** The message started this invocation; it is not a work-period receipt surface. */ - scope?: 'primary_trigger' | 'cross_thread_delivery'; - targets: QueueReceiptTarget[]; - reminderAttempts: QueueReminderAttempt[]; -} - -/** Message-bound receipt delta for live Queue publication after its actionable row disappears. */ -export interface QueueMessageReceiptProjection { - messageId: string; - queueReceipt: QueueMessageReceipt; -} - /** One server-projected Queue escape hatch, including its exact executable request. */ export interface QueueRecoveryRequest { method: 'POST' | 'DELETE'; diff --git a/packages/shared/src/types/user-preferences.ts b/packages/shared/src/types/user-preferences.ts index 4eaf6eb923..2647eb1937 100644 --- a/packages/shared/src/types/user-preferences.ts +++ b/packages/shared/src/types/user-preferences.ts @@ -13,8 +13,6 @@ export interface MessageDispositionPreferences { global?: MessageWorkDisposition; /** Per-thread override. Missing entry inherits global/product. */ threads?: Record; - /** Monotonic JIT-onboarding receipt. */ - onboardingSeen?: boolean; } export interface MessageDispositionPreferenceSnapshot { @@ -23,7 +21,6 @@ export interface MessageDispositionPreferenceSnapshot { thread: MessageWorkDisposition | null; effective: MessageWorkDisposition; source: MessageDispositionPreferenceSource; - onboardingSeen: boolean; } /** F277: owner-scoped Group read model projected from per-thread metadata. */ diff --git a/packages/web/src/__tests__/game-thread-switch-recovery.test.ts b/packages/web/src/__tests__/game-thread-switch-recovery.test.ts index a94603344a..0c45303d66 100644 --- a/packages/web/src/__tests__/game-thread-switch-recovery.test.ts +++ b/packages/web/src/__tests__/game-thread-switch-recovery.test.ts @@ -166,8 +166,6 @@ vi.mock('../components/ChatInput', () => ({ ChatInput: () => null })); vi.mock('../components/ChatContainerHeader', () => ({ ChatContainerHeader: () => null })); vi.mock('../components/ThreadSidebar', () => ({ ThreadSidebar: () => null })); vi.mock('../components/RightStatusPanel', () => ({ RightStatusPanel: () => null })); -vi.mock('../components/ParallelStatusBar', () => ({ ParallelStatusBar: () => null })); -vi.mock('../components/ThinkingIndicator', () => ({ ThinkingIndicator: () => null })); vi.mock('../components/MessageNavigator', () => ({ MessageNavigator: () => null })); vi.mock('../components/MessageActions', () => ({ MessageActions: ({ children }: { children: React.ReactNode }) => children, diff --git a/packages/web/src/__tests__/issue-586-state-trifecta.test.ts b/packages/web/src/__tests__/issue-586-state-trifecta.test.ts index 1509da712c..b7d611a699 100644 --- a/packages/web/src/__tests__/issue-586-state-trifecta.test.ts +++ b/packages/web/src/__tests__/issue-586-state-trifecta.test.ts @@ -154,7 +154,6 @@ describe('Issue #586 Bug 3: Ack-driven unread suppression', () => { hasUserMention: false, lastActivity: Date.now(), queue: [], - queuePaused: false, queueFull: false, workspaceWorktreeId: null, workspaceOpenTabs: [], @@ -312,7 +311,6 @@ describe('Issue #586 Review P1-1: clearAllUnread uses finite suppression', () => hasUserMention: false, lastActivity: Date.now(), queue: [], - queuePaused: false, queueFull: false, workspaceWorktreeId: null, workspaceOpenTabs: [], @@ -336,7 +334,6 @@ describe('Issue #586 Review P1-1: clearAllUnread uses finite suppression', () => hasUserMention: true, lastActivity: Date.now(), queue: [], - queuePaused: false, queueFull: false, workspaceWorktreeId: null, workspaceOpenTabs: [], diff --git a/packages/web/src/app/dev/f294-quote-plane-fixtures/page.tsx b/packages/web/src/app/dev/f294-quote-plane-fixtures/page.tsx index 7b0a727833..8ae781cb38 100644 --- a/packages/web/src/app/dev/f294-quote-plane-fixtures/page.tsx +++ b/packages/web/src/app/dev/f294-quote-plane-fixtures/page.tsx @@ -16,6 +16,7 @@ const DENSITY_CAT: CatData = { color: { primary: '#6b8f34', secondary: '#c8d8b5' }, mentionPatterns: ['codex-sol'], clientId: 'openai', + carrier: 'cli', defaultModel: 'gpt-5.6-sol', avatar: '/avatars/codex.png', roleDescription: '小太阳型攻坚猫', diff --git a/packages/web/src/app/dev/issue1371-adopted-hold/preview.tsx b/packages/web/src/app/dev/issue1371-adopted-hold/preview.tsx index fd98d33bdc..8f39699575 100644 --- a/packages/web/src/app/dev/issue1371-adopted-hold/preview.tsx +++ b/packages/web/src/app/dev/issue1371-adopted-hold/preview.tsx @@ -14,19 +14,12 @@ export function AdoptedHoldPreview() { messageId: 'preview-wake', mergedMessageIds: [], content: '[定时任务] 持球唤醒:检查已通过,通知尚未结算', - source: 'connector', + from: { kind: 'system', service: 'scheduler' }, sourceCategory: 'scheduled', targetCats: ['codex-astra'], intent: 'execute', status: 'queued', createdAt: Date.now(), - targetStates: { 'codex-astra': 'failed' }, - queueReceipt: { - version: 1, - entryId: 'adopted-hold-preview', - reminderAttempts: [], - targets: [{ catId: 'codex-astra', state: 'failed', invocationId: 'finished-child', seenAt: Date.now() - 1000 }], - }, recoveryActions: [ { id: 'queue-withdraw:adopted-hold-preview', @@ -47,13 +40,9 @@ export function AdoptedHoldPreview() { ...failed, id: 'routine-scheduler', content: '[定时任务] routine internal control', - targetStates: { 'codex-astra': 'queued' }, - queueReceipt: undefined, recoveryActions: [], }, ], - queuePaused: true, - queuePauseReason: 'failed', }); setReady(true); }, []); @@ -61,8 +50,8 @@ export function AdoptedHoldPreview() {
-

通知结算与队列恢复

-

开发预览 · 模拟回执,操作由浏览器测试拦截,不调用模型。

+

Queue pending-target 恢复

+

开发预览 · 模拟待投递 source entry,不调用模型。

diff --git a/packages/web/src/components/AppendedInputReceipts.tsx b/packages/web/src/components/AppendedInputReceipts.tsx new file mode 100644 index 0000000000..49612ac7aa --- /dev/null +++ b/packages/web/src/components/AppendedInputReceipts.tsx @@ -0,0 +1,101 @@ +'use client'; + +import type { ChatMessage } from '@/stores/chat-types'; +import { focusLineageMessage } from '@/utils/focusLineageMessage'; + +export function projectAppendedInputReceipts( + response: ChatMessage, + timelineMessages: readonly ChatMessage[], +): readonly ChatMessage[] { + if ( + response.lifecycle?.kind !== 'response' || + response.lifecycle.inputEntryIds.length < 2 || + response.lifecycle.inputMessageIds.length < 2 + ) { + return []; + } + const startedAt = response.lifecycle.startedAt; + const byId = new Map(timelineMessages.map((message) => [message.id, message])); + return response.lifecycle.inputMessageIds.slice(1).flatMap((messageId) => { + const source = byId.get(messageId); + return source && source.timestamp > startedAt ? [source] : []; + }); +} + +function formatReceiptTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const part = (value: number) => String(value).padStart(2, '0'); + return `${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}:${part(date.getSeconds())}`; +} + +function sourceLabel(message: ChatMessage, coCreatorName: string, getCatLabel: (catId: string) => string): string { + switch (message.from?.kind) { + case 'user': + return coCreatorName; + case 'agent': + return getCatLabel(message.from.catId); + case 'external': + return message.from.sender?.name ?? message.source?.label ?? message.from.connectorId; + case 'plugin': + return message.source?.label ?? message.from.instanceId; + case 'system': + return message.from.service; + default: + return message.catId ? getCatLabel(message.catId) : coCreatorName; + } +} + +interface AppendedInputReceiptsProps { + response: ChatMessage; + timelineMessages: readonly ChatMessage[]; + coCreatorName: string; + getCatLabel: (catId: string) => string; +} + +export function AppendedInputReceipts({ + response, + timelineMessages, + coCreatorName, + getCatLabel, +}: AppendedInputReceiptsProps) { + const appendedInputs = projectAppendedInputReceipts(response, timelineMessages); + if (appendedInputs.length === 0) return null; + + return ( +
+
补充消息
+
    + {appendedInputs.map((source) => { + const content = source.content.trim() || '(无文字内容)'; + return ( +
  1. +
    + {sourceLabel(source, coCreatorName, getCatLabel)}: + + {content} + + +
    + +
  2. + ); + })} +
+
+ ); +} diff --git a/packages/web/src/components/ChatContainer.tsx b/packages/web/src/components/ChatContainer.tsx index b73667ade3..170d548306 100644 --- a/packages/web/src/components/ChatContainer.tsx +++ b/packages/web/src/components/ChatContainer.tsx @@ -41,15 +41,14 @@ import { syncLocalBootcampState } from './first-run-quest/syncLocalBootcampState import { useFirstProjectMistakeTipGate } from './first-run-quest/useFirstProjectMistakeTipGate'; import { useFirstProjectPreviewAutoOpen } from './first-run-quest/useFirstProjectPreviewAutoOpen'; import { GameOverlayConnector } from './game/GameOverlayConnector'; +import { IdeateHeader } from './IdeateHeader'; import { BootcampIcon } from './icons/BootcampIcon'; import { GameIcon } from './icons/GameIcon'; import { PawIcon } from './icons/PawIcon'; -import { ParallelStatusBar } from './ParallelStatusBar'; import { ProjectSetupCard } from './ProjectSetupCard'; import { RightStatusPanel } from './RightStatusPanel'; import { RuntimeUpdateRequiredDialog } from './RuntimeUpdateRequiredDialog'; import { SplitPaneChatView } from './SplitPaneView'; -import { ThinkingIndicator } from './ThinkingIndicator'; import { ThreadSidebar } from './ThreadSidebar'; import { assignDocumentRoute, pushThreadRouteWithHistory } from './ThreadSidebar/thread-navigation'; import { ThreadChatExport, ThreadChatSurface, useThreadChatRuntime } from './thread-chat'; @@ -862,9 +861,7 @@ function InteractiveChatContainer({ threadId }: ChatContainerProps) { }} /> - {intentMode === 'ideate' && } - - + {intentMode === 'ideate' && } { if (event.key === 'Escape') { event.preventDefault(); diff --git a/packages/web/src/components/ChatInput.tsx b/packages/web/src/components/ChatInput.tsx index 986aa47224..e6591c216e 100644 --- a/packages/web/src/components/ChatInput.tsx +++ b/packages/web/src/components/ChatInput.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - CONTEXT_ATTACHMENT_MAX_COUNT, - type ContextAttachment, - ContextAttachmentSchema, - type FreshnessCarrierCapability, - type MessageWorkDisposition, -} from '@cat-cafe/shared'; +import { CONTEXT_ATTACHMENT_MAX_COUNT, type ContextAttachment, ContextAttachmentSchema } from '@cat-cafe/shared'; import { type Dispatch, KeyboardEvent, @@ -20,7 +14,6 @@ import { useSyncExternalStore, } from 'react'; import { useCatData } from '@/hooks/useCatData'; -import { useExecutionRecoveryVerification } from '@/hooks/useExecutionRecoveryVerification'; import { reconnectGame } from '@/hooks/useGameReconnect'; import { useIMEGuard } from '@/hooks/useIMEGuard'; import { useLiveExecutionCancelControl } from '@/hooks/useLiveExecutionCancelControl'; @@ -28,7 +21,6 @@ import { useMessageDispositionPreference } from '@/hooks/useMessageDispositionPr import { usePathCompletion } from '@/hooks/usePathCompletion'; import type { UploadStatus, WhisperOptions } from '@/hooks/useSendMessage'; import { useThreadLiveness } from '@/hooks/useThreadScopedSelectors'; -import type { DeliveryMode } from '@/stores/chat-types'; import { useChatStore } from '@/stores/chatStore'; import { useInputHistoryStore } from '@/stores/inputHistoryStore'; import { apiFetch } from '@/utils/api-client'; @@ -44,8 +36,6 @@ import { buildCatOptions, type CatOption, detectMenuTrigger, GAME_LIST, WEREWOLF import { deriveImageLifecycleStatus, isImageLifecycleBlockingSend } from './chat-input-upload-state'; import { GameLobby, type GameStartPayload } from './game/GameLobby'; import { HistorySearchModal } from './HistorySearchModal'; -import { MessageDispositionSelector } from './MessageDispositionSelector'; -import { classifyFreshnessCarrierSupport } from './message-disposition-presentation'; import { PathCompletionMenu } from './PathCompletionMenu'; import { ReplyPreviewBar } from './ReplyPreviewBar'; import { pushThreadRouteWithHistory } from './ThreadSidebar/thread-navigation'; @@ -83,10 +73,9 @@ interface ChatInputProps { content: string, images?: File[], whisper?: WhisperOptions, - deliveryMode?: DeliveryMode, replyToId?: string, - messageDisposition?: MessageWorkDisposition, contextAttachments?: ContextAttachment[], + explicitTargetCats?: string[], ) => void | boolean | Promise; disabled?: boolean; hasActiveInvocation?: boolean; @@ -140,53 +129,41 @@ export function ChatInput({ // F122B AC-B10: track which cats are actively executing (for whisper disable) const currentThreadId = useChatStore((s) => s.currentThreadId); const { + hasActive: legacyHasActiveInvocation, activeInvocations, - catInvocations, targetCats: storeTargetCats, } = useThreadLiveness(threadId ?? currentThreadId); const effectiveThreadId = threadId ?? currentThreadId; + const legacyCancelTargets = useMemo( + () => + Object.entries(activeInvocations).map(([executionId, invocation]) => ({ executionId, catId: invocation.catId })), + [activeInvocations], + ); const { executions: canonicalExecutions, state: projectedCancelState, cancelAll: handleProjectedStop, - } = useLiveExecutionCancelControl(effectiveThreadId); - // Shared with ThreadExecutionBar: both surfaces must answer "can we verify this - // thread's run state?" identically, or their independent fail-closed choices can - // combine into a state with no cancel AND no recovery exit. - const { canonicalProjectionStale, hasUnverifiedLegacyExecution } = useExecutionRecoveryVerification( - threadId, - unscopedHasActiveInvocation, - ); - const hasActiveInvocation = canonicalExecutions.length > 0 || hasUnverifiedLegacyExecution; - const stopState: 'available' | 'pending' | 'unavailable' | 'hidden' = - canonicalExecutions.length === 0 ? (hasUnverifiedLegacyExecution ? 'unavailable' : 'hidden') : projectedCancelState; + } = useLiveExecutionCancelControl(effectiveThreadId, legacyCancelTargets); + const effectiveLegacyActive = threadId + ? legacyHasActiveInvocation + : legacyHasActiveInvocation || unscopedHasActiveInvocation; + const hasActiveInvocation = canonicalExecutions.length > 0 || effectiveLegacyActive; + const stopState: 'available' | 'pending' | 'unavailable' | 'hidden' = hasActiveInvocation + ? projectedCancelState + : 'hidden'; const activeCatIds = useMemo(() => { const ids = new Set(); for (const execution of canonicalExecutions) { ids.add(execution.catId); } - if (ids.size === 0 && hasUnverifiedLegacyExecution) { + if (ids.size === 0 && legacyHasActiveInvocation) { for (const inv of Object.values(activeInvocations ?? {})) ids.add(inv.catId); if (ids.size === 0 && storeTargetCats?.length) { for (const catId of storeTargetCats) ids.add(catId); } } return ids; - }, [activeInvocations, canonicalExecutions, hasUnverifiedLegacyExecution, storeTargetCats]); - - // Stable identity key for the current execution set. Changes when any - // execution starts or ends, enabling the steer confirmation modal to - // detect same-render A→B invocation transitions. - const activeExecutionKey = useMemo( - () => - canonicalExecutions.length > 0 - ? canonicalExecutions - .map((e) => e.executionId) - .sort() - .join(',') - : undefined, - [canonicalExecutions], - ); + }, [activeInvocations, canonicalExecutions, legacyHasActiveInvocation, storeTargetCats]); const [unscopedInput, setUnscopedInput] = useState(''); const scopedInput = useSyncExternalStore( @@ -208,6 +185,7 @@ export function ChatInput({ [threadId], ); const appliedSeedIdRef = useRef(null); + const [showMentions, setShowMentions] = useState(false); const [showGameMenu, setShowGameMenu] = useState(false); const [gameStep, setGameStep] = useState<'list' | 'modes'>('list'); @@ -228,22 +206,7 @@ export function ChatInput({ if (!whisperMode || whisperTargets.size === 0) return false; return ![...whisperTargets].some((catId) => activeCatIds.has(catId)); }, [whisperMode, whisperTargets, activeCatIds]); - const dispositionIsMeaningful = Boolean(hasActiveInvocation && !whisperTargetsAllIdle); - const messageDisposition = useMessageDispositionPreference(threadId, dispositionIsMeaningful); - const dispositionCarrierCapabilities = useMemo(() => { - const targetCatIds = whisperMode - ? [...whisperTargets].filter((catId) => activeCatIds.has(catId)) - : [...activeCatIds]; - return targetCatIds.map((catId) => catInvocations[catId]?.freshnessCarrierCapability) as ( - | FreshnessCarrierCapability - | undefined - )[]; - }, [activeCatIds, catInvocations, whisperMode, whisperTargets]); - const dispositionCarrierSupport = classifyFreshnessCarrierSupport(dispositionCarrierCapabilities); - const displayedDisposition = - messageDisposition.effective === 'continue_current' && dispositionCarrierSupport !== 'exact' - ? 'next_work' - : messageDisposition.effective; + const messageDisposition = useMessageDispositionPreference(effectiveThreadId, Boolean(effectiveThreadId)); const [addMenuOpen, setAddMenuOpen] = useState(false); const [contextPickerMode, setContextPickerMode] = useState(null); @@ -319,102 +282,66 @@ export function ChatInput({ // F080-P2: path completion const pathCompletion = usePathCompletion(input); - const doSend = useCallback( - (deliveryMode?: DeliveryMode) => { - if (sendTemporarilyDisabled) return; - if (whisperMode && whisperTargets.size === 0) return; - const trimmed = input.trim(); - if ((trimmed || contextAttachments.length > 0) && !disabled) { - const draftSnapshot = { - text: input, - images: [...images], - contextAttachments: [...contextAttachments], - replyTo: replyToMessage, - }; - if (trimmed) addHistoryEntry(trimmed); - const whisper = - whisperMode && whisperTargets.size > 0 - ? { visibility: 'whisper' as const, whisperTo: [...whisperTargets] } - : undefined; - // Only a one-shot override belongs on this message. Thread/global/product - // inheritance resolves again at server admission, closing hydration races. - const declaredDisposition = - dispositionIsMeaningful && deliveryMode !== 'force' - ? dispositionCarrierSupport === 'exact' - ? (messageDisposition.oneShot ?? undefined) - : 'next_work' - : undefined; - const settleAdmission = beginComposerDraftAdmission(draftSnapshot); - let admission: ReturnType; - try { - admission = - contextAttachments.length > 0 - ? onSend( - trimmed, - images.length > 0 ? images : undefined, - whisper, - deliveryMode, - replyToMessage?.id, - declaredDisposition, - contextAttachments, - ) - : onSend( - trimmed, - images.length > 0 ? images : undefined, - whisper, - deliveryMode, - replyToMessage?.id, - declaredDisposition, - ); - } catch { - settleAdmission(false); - return; - } - void Promise.resolve(admission).then( - (accepted) => settleAdmission(accepted === false ? false : undefined), - () => settleAdmission(false), - ); - if (declaredDisposition && messageDisposition.oneShot !== null) { - void Promise.resolve(admission).then((accepted) => { - if (accepted !== false) messageDisposition.clearOneShot(); - }); - } - markComposerDraftOptimisticallyCleared(); - setInput(''); - ghostRef.current = null; - setGhostSuggestion(null); - setImages([]); - setContextAttachments([]); - setShowMentions(false); - setShowGameMenu(false); - // Only clear the reply snapshot that was actually sent. - if (replyToMessage && useChatStore.getState().replyToMessage?.id === replyToMessage.id) clearReplyTo(); + const doSend = useCallback(() => { + if (sendTemporarilyDisabled) return; + if (whisperMode && whisperTargets.size === 0) return; + const trimmed = input.trim(); + if ((trimmed || contextAttachments.length > 0) && !disabled) { + const draftSnapshot = { + text: input, + images: [...images], + contextAttachments: [...contextAttachments], + replyTo: replyToMessage, + }; + if (trimmed) addHistoryEntry(trimmed); + const whisper = + whisperMode && whisperTargets.size > 0 + ? { visibility: 'whisper' as const, whisperTo: [...whisperTargets] } + : undefined; + const settleAdmission = beginComposerDraftAdmission(draftSnapshot); + let admission: ReturnType; + try { + admission = + contextAttachments.length > 0 + ? onSend(trimmed, images.length > 0 ? images : undefined, whisper, replyToMessage?.id, contextAttachments) + : onSend(trimmed, images.length > 0 ? images : undefined, whisper, replyToMessage?.id); + } catch { + settleAdmission(false); + return; } - }, - [ - input, - disabled, - onSend, - images, - contextAttachments, - sendTemporarilyDisabled, - whisperMode, - whisperTargets, - addHistoryEntry, - replyToMessage, - clearReplyTo, - dispositionIsMeaningful, - messageDisposition, - dispositionCarrierSupport, - beginComposerDraftAdmission, - markComposerDraftOptimisticallyCleared, - setInput, - ], - ); + void Promise.resolve(admission).then( + (accepted) => settleAdmission(accepted === false ? false : undefined), + () => settleAdmission(false), + ); + markComposerDraftOptimisticallyCleared(); + setInput(''); + ghostRef.current = null; + setGhostSuggestion(null); + setImages([]); + setContextAttachments([]); + setShowMentions(false); + setShowGameMenu(false); + // Only clear the reply snapshot that was actually sent. + if (replyToMessage && useChatStore.getState().replyToMessage?.id === replyToMessage.id) clearReplyTo(); + } + }, [ + input, + disabled, + onSend, + images, + contextAttachments, + sendTemporarilyDisabled, + whisperMode, + whisperTargets, + addHistoryEntry, + replyToMessage, + clearReplyTo, + beginComposerDraftAdmission, + markComposerDraftOptimisticallyCleared, + setInput, + ]); - const handleSend = useCallback(() => doSend(undefined), [doSend]); - const handleQueueSend = useCallback(() => doSend('queue'), [doSend]); - const handleForceSend = useCallback(() => doSend('force'), [doSend]); + const handleSend = useCallback(() => doSend(), [doSend]); const closeMenus = useCallback(() => { setShowMentions(false); @@ -695,9 +622,7 @@ export function ChatInput({ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - // F39+F108B: Enter while cat running → queue send; whisper to idle targets → normal send - if (hasActiveInvocation && !whisperTargetsAllIdle) handleQueueSend(); - else handleSend(); + handleSend(); } }; @@ -868,45 +793,6 @@ export function ChatInput({ return (
- {/* F39: Queue status bar — visible when cat is running */} - {hasActiveInvocation && ( -
- - - {canonicalExecutions.length > 0 - ? canonicalProjectionStale - ? '猫猫正在回复中 · 状态暂不可核对' - : '猫猫正在回复中...' - : canonicalProjectionStale - ? '运行状态暂不可核对' - : '正在确认运行状态...'} - - - {displayedDisposition === 'continue_current' ? '当前轮可在安全断点读取' : '继续输入,消息会成为下一件工作'} - - {stopState !== 'hidden' && ( - - )} -
- )} - - {dispositionIsMeaningful && ( - - )} - {contextPickerMode && ( setAddMenuOpen(false)} + messageDisposition={messageDisposition} triggerRef={addButtonRef} disabled={disabled} sendDisabled={sendTemporarilyDisabled} @@ -1064,9 +951,7 @@ export function ChatInput({ whisperMode ? '悄悄话...' : hasActiveInvocation && !whisperTargetsAllIdle - ? displayedDisposition === 'continue_current' - ? '接着当前工作补充...' - : '继续输入,成为下一件工作...' + ? '继续输入,发送后进入队列...' : (placeholder ?? '输入消息... (@ 召唤猫猫 · /thread 引用对话)') } className={`w-full resize-none rounded-xl border p-3 text-sm focus:outline-none focus:ring-2 placeholder:text-cafe-muted ${ @@ -1094,12 +979,10 @@ export function ChatInput({ onSend={handleSend} onStop={() => void handleProjectedStop()} stopState={stopState} - onQueueSend={handleQueueSend} - onForceSend={handleForceSend} + onQueueSend={handleSend} disabled={disabled} sendDisabled={sendTemporarilyDisabled} hasActiveInvocation={whisperTargetsAllIdle ? false : hasActiveInvocation} - activeExecutionKey={whisperTargetsAllIdle ? undefined : activeExecutionKey} hasText={Boolean(input.trim() || contextAttachments.length > 0)} />
diff --git a/packages/web/src/components/ChatInputActionButton.tsx b/packages/web/src/components/ChatInputActionButton.tsx index dbd0d9269d..3d0ddd4d1e 100644 --- a/packages/web/src/components/ChatInputActionButton.tsx +++ b/packages/web/src/components/ChatInputActionButton.tsx @@ -1,42 +1,27 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect } from 'react'; import { useVoiceInput } from '@/hooks/useVoiceInput'; import { ExpandableProse } from './content-overflow'; import { LoadingIcon } from './icons/LoadingIcon'; import { MicIcon } from './icons/MicIcon'; import { SendIcon } from './icons/SendIcon'; import { StopRecordingIcon } from './icons/StopRecordingIcon'; -import { SteerQueuedEntryModal } from './SteerQueuedEntryModal'; interface ChatInputActionButtonProps { onTranscript: (text: string) => void; onSend: () => void; /** F39: Queue-mode send (content will be queued behind running invocation) */ onQueueSend?: () => void; - /** F39: Force-mode send (cancel running + execute immediately) */ - onForceSend?: () => void; onStop?: () => void; stopState?: 'available' | 'pending' | 'unavailable' | 'hidden'; disabled?: boolean; sendDisabled?: boolean; /** Whether the thread has an active invocation (broader than disabled/isLoading) */ hasActiveInvocation?: boolean; - /** Stable key derived from active execution IDs; changes when the execution set changes. */ - activeExecutionKey?: string; hasText: boolean; } -/** Queue send icon — arrow into a stack/list */ -function QueueSendIcon({ className }: { className?: string }) { - return ( - - - - - ); -} - /** Renders the action button states: * 1. Stop generation (disabled + active invocation) * 2. Stop recording @@ -51,43 +36,14 @@ export function ChatInputActionButton({ onTranscript, onSend, onQueueSend, - onForceSend, onStop, stopState, disabled, sendDisabled, hasActiveInvocation, - activeExecutionKey, hasText, }: ChatInputActionButtonProps) { const voice = useVoiceInput(); - const [confirmSteer, setConfirmSteer] = useState(false); - // Captures the execution identity when the steer modal opens. - // If the active execution set changes (A ends → B starts), the key - // will differ and we dismiss/reject the stale confirmation. - const steerBoundKeyRef = useRef(undefined); - - // P1 fix: auto-dismiss steer confirmation when the target invocation ends - // OR when the execution identity changes (A→B same-render transition). - // Fail closed: undefined key = unverifiable identity → dismiss. - useEffect(() => { - if (!hasActiveInvocation) { - setConfirmSteer(false); - return; - } - if (confirmSteer) { - // Bound key is undefined (legacy/unhydrated path) — we cannot verify - // identity, so dismiss the modal rather than risk a stale confirm. - if (steerBoundKeyRef.current === undefined) { - setConfirmSteer(false); - return; - } - // Same-render A→B: hasActiveInvocation stays true but the key changes. - if (activeExecutionKey !== steerBoundKeyRef.current) { - setConfirmSteer(false); - } - } - }, [hasActiveInvocation, activeExecutionKey, confirmSteer]); const isSendDisabled = Boolean(disabled || sendDisabled); const resolvedStopState = stopState ?? (onStop ? 'available' : 'hidden'); @@ -198,39 +154,17 @@ export function ChatInputActionButton({ ) : isQueueMode && onQueueSend ? ( - /* F39: Queue send — cat is running, user typed, queue the message */ -
- - {onForceSend && activeExecutionKey !== undefined && ( - - )} -
+ /* Every authored message takes the same send path. Queue admission + applies the strategy selected from the + menu after the click. */ + ) : hasText ? ( - ) : ( + ) : hasActiveInvocation ? null : ( )} - {confirmSteer && ( - setConfirmSteer(false)} - onConfirm={() => { - setConfirmSteer(false); - // Guard: only force-send if the execution identity that prompted - // confirmation is still current. Catches same-render A→B where - // hasActiveInvocation stays true but the execution set changed. - const keyMatch = activeExecutionKey !== undefined && activeExecutionKey === steerBoundKeyRef.current; - if (hasActiveInvocation && keyMatch) onForceSend?.(); - }} - /> - )} ); } diff --git a/packages/web/src/components/ChatInputAddMenu.tsx b/packages/web/src/components/ChatInputAddMenu.tsx index 24635f2289..530caa3795 100644 --- a/packages/web/src/components/ChatInputAddMenu.tsx +++ b/packages/web/src/components/ChatInputAddMenu.tsx @@ -1,8 +1,10 @@ 'use client'; -import { type RefObject, useEffect, useRef } from 'react'; +import { type RefObject, useEffect, useRef, useState } from 'react'; +import type { MessageDispositionPreferenceController } from '@/hooks/useMessageDispositionPreference'; import { AttachIcon } from './icons/AttachIcon'; import { GameIcon } from './icons/GameIcon'; +import { MessageDispositionSelector, messageDispositionLabel } from './MessageDispositionSelector'; interface ChatInputAddMenuProps { onAddContext: () => void; @@ -10,6 +12,7 @@ interface ChatInputAddMenuProps { onWhisperToggle: () => void; onGameClick: () => void; onClose: () => void; + messageDisposition: MessageDispositionPreferenceController; triggerRef?: RefObject; disabled?: boolean; sendDisabled?: boolean; @@ -23,6 +26,7 @@ export function ChatInputAddMenu({ onWhisperToggle, onGameClick, onClose, + messageDisposition, triggerRef, disabled, sendDisabled, @@ -30,6 +34,7 @@ export function ChatInputAddMenu({ whisperMode, }: ChatInputAddMenuProps) { const menuRef = useRef(null); + const [view, setView] = useState<'root' | 'disposition'>('root'); useEffect(() => { const closeOnOutsideClick = (event: MouseEvent) => { @@ -57,7 +62,7 @@ export function ChatInputAddMenu({ data-testid="composer-add-menu" role="menu" aria-label="添加内容" - className="absolute bottom-full left-4 z-20 mb-2 w-[min(19rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-cafe bg-cafe-surface-elevated shadow-lg" + className="absolute bottom-full left-4 z-20 mb-2 w-[min(19rem,calc(100vw-2rem))] overflow-hidden rounded-2xl bg-cafe-surface-canvas p-1.5 shadow-xl" onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); @@ -65,62 +70,73 @@ export function ChatInputAddMenu({ } }} > -
添加
-
- } - label="引用 Thread 或文件" - description="把家里的已有内容带进这条消息" - disabled={unavailable} - onClick={() => { - onClose(); - onAddContext(); - }} - /> - } - label="上传附件" - description="图片、文档或压缩包,最多 5 个" - disabled={unavailable || maxImages} - onClick={() => { - onClose(); - onAttach(); - }} - /> -
-
- } - label="悄悄话" - description={whisperMode ? '已开启;选择猫猫或点此关闭' : '只让选中的猫猫看见'} - active={whisperMode} - disabled={unavailable} - onClick={() => { - onClose(); - onWhisperToggle(); - }} - /> - } - label="游戏" - description="选择游戏与参与方式" - disabled={unavailable} - onClick={() => { - onClose(); - onGameClick(); - }} - /> -
+ {view === 'disposition' ? ( + setView('root')} /> + ) : ( +
+ } + label="引用 Thread 或文件" + description="把家里的已有内容带进这条消息" + disabled={unavailable} + onClick={() => { + onClose(); + onAddContext(); + }} + /> + } + label="上传附件" + description="图片、文档或压缩包,最多 5 个" + disabled={unavailable || maxImages} + onClick={() => { + onClose(); + onAttach(); + }} + /> + } + label="发送策略" + description={messageDispositionLabel(messageDisposition.effective)} + disabled={disabled} + onClick={() => setView('disposition')} + /> + } + label="悄悄话" + description={whisperMode ? '已开启;选择猫猫或点此关闭' : '只让选中的猫猫看见'} + active={whisperMode} + disabled={unavailable} + onClick={() => { + onClose(); + onWhisperToggle(); + }} + /> + } + label="游戏" + description="选择游戏与参与方式" + disabled={unavailable} + onClick={() => { + onClose(); + onGameClick(); + }} + /> +
+ )} ); } function AddMenuItem({ testId, + dataDispositionSource, icon, label, description, @@ -129,6 +145,7 @@ function AddMenuItem({ onClick, }: { testId: string; + dataDispositionSource?: string; icon: React.ReactNode; label: string; description: string; @@ -141,6 +158,7 @@ function AddMenuItem({ type="button" role="menuitem" data-testid={testId} + data-disposition-source={dataDispositionSource} disabled={disabled} onClick={onClick} className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-30 ${ @@ -158,6 +176,14 @@ function AddMenuItem({ ); } +function StrategyIcon() { + return ( + + ); +} + function ContextIcon() { return (
{catOptions.map((opt, i) => ( @@ -173,7 +173,7 @@ export function ChatInputMenus({ {showGameMenu && gameStep === 'list' && (
选择游戏
{GAME_LIST.map((game, i) => ( @@ -208,7 +208,7 @@ export function ChatInputMenus({ {showGameMenu && gameStep === 'modes' && (
); @@ -371,7 +366,8 @@ export const ChatMessage = memo(function ChatMessage({ // ADR-042 supplement speech is an ordinary additive reply. It may retain the // provider's stream provenance, but that provenance must not turn its body // into an internal CLI Output card. - const isStreamOrigin = message.origin === 'stream' && !message.extra?.supplement; + const isFailedLifecycleResponse = message.lifecycle?.kind === 'response' && message.lifecycle.status === 'failed'; + const isStreamOrigin = message.origin === 'stream' && !message.extra?.supplement && !isFailedLifecycleResponse; // F194 Phase Z11 follow-up: ordinary post_msg speech is projected as a // separate callback bubble, but exact-key callback_final records can still // merge into the stream bubble as terminal updates. Projection exposes the @@ -414,6 +410,10 @@ export const ChatMessage = memo(function ChatMessage({ } if (isSystem) { + // A failure linked from a cat-authored source is an internal settlement + // carrier. An unlinked origin failure is the canonical user-visible row. + if (isLinkedDeliveryFailureCarrier(message, threadMessages)) return null; + // F148 ContextBriefing and F233 duty briefing are user-visible, collapsed cards. // F148 remains distinguishable via extra.systemKind='context_briefing'. if (message.origin === 'briefing' && message.extra?.rich?.blocks?.length) { @@ -431,8 +431,8 @@ export const ChatMessage = memo(function ChatMessage({ } if (message.variant === 'governance_blocked' && message.extra?.governanceBlocked) { - const { projectPath, reasonKind, invocationId } = message.extra.governanceBlocked; - return ; + const { projectPath, reasonKind } = message.extra.governanceBlocked; + return ; } // F045: variant='thinking' is deprecated — thinking is now embedded in assistant bubbles. @@ -464,7 +464,7 @@ export const ChatMessage = memo(function ChatMessage({ // dropping the wrapper would silently break navigation/audit trail for the hidden // duplicates (codex review PR #1967 P2 catch). h-0 keeps the anchor at zero visual // cost; the group head's panel right above carries all the info via ×N badge. - if (hideDiagnosticsPanel && turnAbsorptionProjections.length === 0) { + if (hideDiagnosticsPanel) { return
@@ -577,19 +573,6 @@ export const ChatMessage = memo(function ChatMessage({ // is authoritative; this guard keeps stale client caches from flashing it. if (isUser && message.extra?.recall?.exposure === 'none') return null; - const messageReceiptDock = message.extra?.queueReceipt ? ( - { - const cat = getCatById(catId); - return cat ? formatCatName(cat) : catId; - }} - /> - ) : null; - if (isUser) { const coCreatorPrimary = coCreator.color?.primary ?? CO_CREATOR_COLOR.primary; /* F056: cocreator slug-keyed (cocreator is in SLUGS, has its own per-cat @@ -654,11 +637,10 @@ export const ChatMessage = memo(function ChatMessage({ {isRevealed ? '已揭秘' : `悄悄话 → ${message.whisperTo?.join(', ') ?? ''}`} )} - {message.replyTo && message.replyPreview && !isSchedulerReply && ( - + {message.replyTo && resolvedReplyPreview && !isSchedulerReply && ( + )} {formatDualTime(message.timestamp, message.deliveredAt)} - {coCreator.name} @@ -666,32 +648,8 @@ export const ChatMessage = memo(function ChatMessage({ ); const whisperActive = isWhisper && !isRevealed; - const foldedInvocationId = foldedSourceInvocationIdInTimeline(message, threadMessages); - const bodyIsFolded = foldedInvocationId !== undefined; const recalledAfterExposure = message.extra?.recall?.exposure === 'seen'; - if (bodyIsFolded && foldedInvocationId) { - return ( - - ); - } - return ( } > {recalledAfterExposure ? (
@@ -742,13 +701,11 @@ export const ChatMessage = memo(function ChatMessage({ sourceMessageId={message.id} targetCatId={cloudBindingRecovery.targetCatId} attemptId={cloudBindingRecovery.attemptId} - deliveryStatus={cloudBindingRecovery.deliveryStatus} /> ) : null} {message.extra?.custodyOfferV1 ? ( ) : null} - {messageReceiptDock} ); } @@ -756,23 +713,56 @@ export const ChatMessage = memo(function ChatMessage({ // Keep the real bubble and pending placeholder on the same visual predicate. // Identity/lifecycle metadata alone must not tear down the placeholder before // an assistant avatar and frame can actually take over. - if ( - !doesAssistantMessageRenderBubble(message, { - currentThreadId: renderThreadId, - hasCliBlock, - hasCrossThreadSource: Boolean(crossThreadSourceThreadId), - }) - ) { + const assistantRenderContext = { + currentThreadId: renderThreadId, + hasCliBlock, + hasCrossThreadSource: Boolean(crossThreadSourceThreadId), + }; + if (!doesAssistantMessageRenderBubble(message, assistantRenderContext)) { + const notice = projectEmptyResponseLifecycleNotice(message, assistantRenderContext); + if (notice?.tone === 'processing') { + return ( +
+ {catData && message.catId ? : null} +
+
+ + {catStyle?.label ?? message.catId} + + {formatTime(message.timestamp)} +
+ {showCapabilityTip && capabilityTipContexts ? ( + + ) : ( + + 处理中 + + + + + )} +
+
+ ); + } return null; } /* ── Cat (assistant) header ── */ const catHeader = - catStyle || - message.extra?.supplement || - message.extra?.turnExecution || - message.extra?.auxiliaryTurnExecutions?.length || - subexecutionEvents.length ? ( + catStyle || message.extra?.supplement || subexecutionEvents.length ? (
{formatTime(message.timestamp)} - - {message.extra?.recovery?.kind === 'f254_withheld_message' && ( )} - {message.extra?.turnExecution?.executionKind === 'routing_guard' && ( - - 系统补路由 - - )} - {message.extra?.turnExecution?.executionKind === 'freshness_supplement' && ( - - 后到消息补充{message.extra.supplement ? ` ${message.extra.supplement.seq}` : ''} - - )} - {message.extra?.auxiliaryTurnExecutions?.map((execution) => { - const label = - execution.executionKind === 'routing_guard' - ? '系统补路由' - : execution.executionKind === 'freshness_supplement' - ? '后到消息补充' - : '普通执行(无正文)'; - const title = - execution.executionKind === 'routing_guard' - ? `系统为这条普通回复补了一次路由出口;child ${execution.invocationId}` - : execution.executionKind === 'freshness_supplement' - ? `针对真正相关的后到消息执行补充;child ${execution.invocationId}` - : `本轮普通执行未产生独立正文;child ${execution.invocationId}`; - return ( - - {label} - - ); - })} {message.extra?.supplement && message.extra?.turnExecution?.executionKind !== 'freshness_supplement' && ( )} {!isWhisper && direction && } - {message.replyTo && message.replyPreview && !isSchedulerReply && ( - - )} - {hasTextContent && !message.isStreaming && ( - + {message.replyTo && resolvedReplyPreview && !isSchedulerReply && ( + )} +
{showSchedulerAccent && ( @@ -946,7 +878,6 @@ export const ChatMessage = memo(function ChatMessage({ onEditCat(message.catId!) : undefined} /> ) : null @@ -966,8 +897,42 @@ export const ChatMessage = memo(function ChatMessage({ ? { backgroundColor: catStyle.bgColor, color: 'var(--cat-msg-text)' } : { color: 'var(--cat-msg-text)' } } - footer={!message.isStreaming && message.metadata ? : undefined} + footer={ + <> + { + const cat = getCatById(catId); + return cat ? formatCatName(cat) : catId; + }} + /> + {!message.isStreaming && message.metadata ? : null} + + } > + {(() => { + const notice = projectEmptyResponseLifecycleNotice(message, { hasCliBlock }); + if (!notice) return null; + const toneClass = + notice.tone === 'failed' + ? 'text-conn-red-text' + : notice.tone === 'canceled' + ? 'text-conn-amber-text' + : 'text-cafe-muted'; + return ( + + {notice.tone === 'processing' ? ( + + ); + })()} {hasCliBlock && isStreamOrigin ? null : !isStreamOrigin && hasBlocks ? ( ) : !isStreamOrigin && hasTextContent ? ( @@ -976,8 +941,6 @@ export const ChatMessage = memo(function ChatMessage({ className={catStyle?.font} disclosureKey={bodyDisclosureKey} /> - ) : message.isStreaming ? ( - Thinking... ) : null} {message.thinking && ( )} - {messageReceiptDock} - {renderTurnAbsorptionDocks()} {showPawFeelDisposition ? : null} {message.isStreaming && !isStreamOrigin && ( )} ); +} + +export const ChatMessage = memo(function ChatMessage(props: ChatMessageProps) { + const lifecycleTimeline = useChatStore( + (state) => + props.timelineMessages ?? + (props.message.lifecycle?.dispatchRefs?.length ? state.messages : EMPTY_TIMELINE_MESSAGES), + ); + // Phase C compatibility boundary: legacy routing projections remain readable in + // History storage, but are not a user-facing message surface anymore. + if (props.message.extra?.systemKind === 'a2a_routing') return null; + // F167 routing/liveness guards are internal protocol diagnostics. History/API + // filters remain the primary boundary; this structured producer guard prevents + // persisted or stale client caches from flashing them as user-facing notices. + if (isInternalProtocolDiagnostic(props.message)) return null; + return ( + <> + + { + const cat = props.getCatById(catId); + return cat ? formatCatName(cat) : catId; + }} + /> + + ); }); diff --git a/packages/web/src/components/ChatMessageRow.tsx b/packages/web/src/components/ChatMessageRow.tsx index 49b83561ee..00058fe4cc 100644 --- a/packages/web/src/components/ChatMessageRow.tsx +++ b/packages/web/src/components/ChatMessageRow.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { CapabilityTipContext, LifecycleActiveRun } from '@cat-cafe/shared'; import { memo } from 'react'; import type { CatData } from '@/hooks/useCatData'; import type { ChatMessage as ChatMessageData } from '@/stores/chat-types'; @@ -12,7 +13,7 @@ interface ChatMessageRowProps { message: ChatMessageData; threadId: string; timelineMessages: readonly ChatMessageData[]; - activeInvocationIds?: ReadonlySet; + activeRuns?: readonly LifecycleActiveRun[]; getCatById: (id: string) => CatData | undefined; onEditCat: (catId: string) => void; onEditCoCreator: () => void; @@ -26,6 +27,8 @@ interface ChatMessageRowProps { forwardingDisabled: boolean; eager?: boolean; backgroundMountDelayMs?: number; + showCapabilityTip?: boolean; + capabilityTipContexts?: readonly CapabilityTipContext[]; /** Routes interactive rich-block sends back to the surface that rendered this row. */ sendContext?: string; confirmations?: CardConfirmationEntry[]; @@ -40,7 +43,7 @@ export const ChatMessageRow = memo(function ChatMessageRow({ message, threadId, timelineMessages, - activeInvocationIds, + activeRuns, getCatById, onEditCat, onEditCoCreator, @@ -54,6 +57,8 @@ export const ChatMessageRow = memo(function ChatMessageRow({ forwardingDisabled, eager, backgroundMountDelayMs, + showCapabilityTip, + capabilityTipContexts, sendContext, confirmations, }: ChatMessageRowProps) { @@ -73,13 +78,15 @@ export const ChatMessageRow = memo(function ChatMessageRow({ message={message} threadId={threadId} timelineMessages={timelineMessages} - activeInvocationIds={activeInvocationIds} + activeRuns={activeRuns} getCatById={getCatById} onEditCat={onEditCat} onEditCoCreator={onEditCoCreator} hideDiagnosticsPanel={hideDiagnosticsPanel} dedupCount={dedupCount} forwardingDisabled={forwardingDisabled} + showCapabilityTip={showCapabilityTip} + capabilityTipContexts={capabilityTipContexts} sendContext={sendContext} confirmations={confirmations} /> diff --git a/packages/web/src/components/CliDiagnosticsPanel.tsx b/packages/web/src/components/CliDiagnosticsPanel.tsx index 3fccf96558..bf0f90b8d5 100644 --- a/packages/web/src/components/CliDiagnosticsPanel.tsx +++ b/packages/web/src/components/CliDiagnosticsPanel.tsx @@ -90,7 +90,6 @@ const REASON_PALETTE: Record = { server_overloaded: { ...PALETTE_TRANSIENT, Icon: HourglassIcon }, cli_response_timeout: { ...PALETTE_TRANSIENT, Icon: HourglassIcon }, cli_stall_timeout: { ...PALETTE_TRANSIENT, Icon: HourglassIcon }, - active_writer_recovery: { ...PALETTE_TRANSIENT, Icon: HourglassIcon }, // Tier 3 — system / environment spawn_failed: { ...PALETTE_SYSTEM, Icon: TerminalIcon }, missing_rollout: { ...PALETTE_SYSTEM, Icon: FileXIcon }, diff --git a/packages/web/src/components/ConnectorBubble.tsx b/packages/web/src/components/ConnectorBubble.tsx index 9f0fe55533..1e3e011fba 100644 --- a/packages/web/src/components/ConnectorBubble.tsx +++ b/packages/web/src/components/ConnectorBubble.tsx @@ -21,6 +21,7 @@ import { } from './icons/ConnectorIcons'; import { BallotIcon } from './icons/VoteIcons'; import { MarkdownContent } from './MarkdownContent'; +import { MessageActionSlot } from './MessageActionSlot'; import { MessageBubble } from './MessageBubble'; import { RichBlocks } from './rich/RichBlocks'; @@ -278,6 +279,7 @@ export function ConnectorBubble({ message, threadId, timelineMessages }: Connect {source.sender.name || source.sender.id} 说 )} {formatTime(message.timestamp)} +
); diff --git a/packages/web/src/components/CopyIdButton.tsx b/packages/web/src/components/CopyIdButton.tsx index 2ff3906bb5..25b7c02f8d 100644 --- a/packages/web/src/components/CopyIdButton.tsx +++ b/packages/web/src/components/CopyIdButton.tsx @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react'; -/** Hover-visible button that copies messageId to clipboard. */ +/** Compact toolbar action that copies messageId to clipboard. */ export function CopyIdButton({ messageId }: { messageId: string }) { const [copied, setCopied] = useState(false); const timerRef = useRef>(); @@ -22,8 +22,7 @@ export function CopyIdButton({ messageId }: { messageId: string }) { -
- -
-
- - - - - 会做什么:取消这个对话里所有正在运行的猫,清掉卡住的“正在回复中”。 - -
-
- - - - - 会保留什么:消息和历史全部保留——只清运行状态。 - -
-
- - - - - 何时用:仅在猫卡死、点「停止」也没反应时的最后手段。 - -
-
- -
- - -
-
- - ); -} diff --git a/packages/web/src/components/GovernanceBlockedCard.tsx b/packages/web/src/components/GovernanceBlockedCard.tsx index 36c5bceafb..64d6c3fad0 100644 --- a/packages/web/src/components/GovernanceBlockedCard.tsx +++ b/packages/web/src/components/GovernanceBlockedCard.tsx @@ -1,48 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { apiFetch } from '@/utils/api-client'; import { GovernanceInstaller } from './GovernanceInstaller'; import { GovernanceShieldIcon } from './icons/GovernanceShieldIcon'; interface GovernanceBlockedCardProps { projectPath: string; reasonKind: 'needs_bootstrap' | 'needs_confirmation' | 'files_missing'; - invocationId?: string; } -type CardState = 'idle' | 'retrying' | 'done' | 'error'; - -export function GovernanceBlockedCard({ projectPath, invocationId }: GovernanceBlockedCardProps) { - const [state, setState] = useState('idle'); - const [errorMsg, setErrorMsg] = useState(''); - const prevInvIdRef = useRef(invocationId); - - useEffect(() => { - if (prevInvIdRef.current !== invocationId) { - prevInvIdRef.current = invocationId; - setState('idle'); - setErrorMsg(''); - } - }, [invocationId]); - - const handleRetry = useCallback(async () => { - if (!invocationId) return; - setState('retrying'); - setErrorMsg(''); - try { - const res = await apiFetch(`/api/invocations/${invocationId}/retry`, { method: 'POST' }); - if (!res.ok) { - const data = (await res.json()) as { error?: string }; - setState('error'); - setErrorMsg(data.error ?? '重试失败,请手动重新发送消息'); - return; - } - setState('done'); - } catch { - setState('error'); - setErrorMsg('网络错误'); - } - }, [invocationId]); - +export function GovernanceBlockedCard({ projectPath }: GovernanceBlockedCardProps) { const dirName = projectPath.split(/[/\\]/).pop() ?? projectPath; return ( @@ -55,24 +19,8 @@ export function GovernanceBlockedCard({ projectPath, invocationId }: GovernanceB 项目 {dirName} 的历史治理阻塞已解除

- 现在派遣不再要求目标仓先安装治理。可以直接重试;若需要项目文件,请在下方预览后显式确认。 + 现在派遣不再要求目标仓先安装治理。请重新发送原消息;若需要项目文件,请在下方预览后显式确认。

- {invocationId && ( -
- {(state === 'idle' || state === 'error') && ( - - )} - {state === 'retrying' && 正在重试...} - {state === 'done' && 已重试派遣} - {state === 'error' &&

{errorMsg}

} -
- )}
diff --git a/packages/web/src/components/HubCatEditor.tsx b/packages/web/src/components/HubCatEditor.tsx index a1bea1baff..66ac16bdad 100644 --- a/packages/web/src/components/HubCatEditor.tsx +++ b/packages/web/src/components/HubCatEditor.tsx @@ -667,8 +667,6 @@ export function HubCatEditor({ cat, draft, existingCats, hasDossier, open, onClo modelOptions={modelOptions} availableProfiles={availableProfiles} loadingProfiles={hasEmptyCreatableAccounts ? true : loadingProfiles} - effectiveCodexCarrier={cat?.codexCarrier ?? null} - codexLocalCapable={cat ? cat.cli != null : true} onChange={patchForm} /> {hasEmptyCreatableAccounts ? ( @@ -708,7 +706,7 @@ export function HubCatEditor({ cat, draft, existingCats, hasDossier, open, onClo codexSpeedVisible={ form.clientId === 'openai' && selectedProfile?.authType === 'oauth' && - !form.acpEnabled && + form.carrier !== 'acp' && (cat ? cat.cli != null : true) } codexFastSupported={supportsCodexFastModel(form.defaultModel)} diff --git a/packages/web/src/components/HubMemberOverviewCard.tsx b/packages/web/src/components/HubMemberOverviewCard.tsx index f01420ba69..4888934383 100644 --- a/packages/web/src/components/HubMemberOverviewCard.tsx +++ b/packages/web/src/components/HubMemberOverviewCard.tsx @@ -206,18 +206,9 @@ function MemberMeta({ cat, configCat }: { cat: CatData; configCat?: CatConfig }) {cat.id} {getMetaSummary(cat, configCat)} - {cat.adapterMode && ( - - {/* F254 D2: ACP wins over the Codex carrier (assembly checks getAcpConfig first) */} - {cat.adapterMode === 'acp' - ? 'ACP' - : cat.codexCarrier?.effective === 'app_server' - ? 'APP SERVER' - : cat.adapterMode.toUpperCase()} + {cat.carrier && ( + + {cat.carrier.replace('_', ' ').toUpperCase()} )} diff --git a/packages/web/src/components/IdeateHeader.tsx b/packages/web/src/components/IdeateHeader.tsx index d17301860b..cad0ebad90 100644 --- a/packages/web/src/components/IdeateHeader.tsx +++ b/packages/web/src/components/IdeateHeader.tsx @@ -1,22 +1,13 @@ -'use client'; - -import { PawIcon } from './icons/PawIcon'; - /** - * IdeateHeader — 独立观点采样横幅 - * Shown when parallel (ideate) mode is active. - * Displays above the message area with a gradient background. + * A single mode marker for independent sampling. Per-member execution, + * cancellation, and usage already have canonical homes in message bubbles and + * the composer, so this header must not duplicate them. */ export function IdeateHeader() { return ( -
-
- - - - 独立观点采样中 - 猫猫们各自独立思考中... -
+
+ 独立观点采样 + 本轮各成员独立思考并分别回答,彼此不会互相触发
); } diff --git a/packages/web/src/components/InvocationTrajectoryAnchor.tsx b/packages/web/src/components/InvocationTrajectoryAnchor.tsx index 252a2a8eb5..78a80c4aa4 100644 --- a/packages/web/src/components/InvocationTrajectoryAnchor.tsx +++ b/packages/web/src/components/InvocationTrajectoryAnchor.tsx @@ -20,6 +20,22 @@ function hasTimeoutEvidence(message: ChatMessage): boolean { return typeof reasonCode === 'string' && reasonCode.toLowerCase().includes('timeout'); } +function responseLifecycleTrajectoryStatus(message: ChatMessage): InvocationTrajectoryStatus | undefined { + const lifecycle = message.lifecycle; + if (lifecycle?.kind !== 'response') return undefined; + switch (lifecycle.status) { + case 'processing': + return 'running'; + case 'completed': + return 'done'; + case 'failed': + return hasTimeoutEvidence(message) ? 'timeout' : 'error'; + case 'canceled': + case 'interrupted': + return 'cancelled'; + } +} + export function describeMessageInvocationTrajectory( message: ChatMessage, ): MessageInvocationTrajectoryDescriptor | undefined { @@ -28,9 +44,11 @@ export function describeMessageInvocationTrajectory( message.extra?.timeoutDiagnostics?.invocationId ?? message.extra?.cliDiagnostics?.debugRef.invocationId; if (!invocationId || !message.catId) return undefined; + const responseLifecycleStatus = responseLifecycleTrajectoryStatus(message); const phase = message.extra?.invocationReconciliation?.phase; let status: InvocationTrajectoryStatus; - if (phase === 'failed') status = hasTimeoutEvidence(message) ? 'timeout' : 'error'; + if (responseLifecycleStatus) status = responseLifecycleStatus; + else if (phase === 'failed') status = hasTimeoutEvidence(message) ? 'timeout' : 'error'; else if (phase === 'canceled') status = 'cancelled'; else if (phase === 'running' || phase === 'unknown_running' || message.isStreaming) status = 'running'; else if (hasTimeoutEvidence(message)) status = 'timeout'; diff --git a/packages/web/src/components/MessageActions.tsx b/packages/web/src/components/MessageActions.tsx index 372ea894a6..0aed9aef03 100644 --- a/packages/web/src/components/MessageActions.tsx +++ b/packages/web/src/components/MessageActions.tsx @@ -8,22 +8,22 @@ import { } from '@cat-cafe/shared'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { useIsDesktop } from '@/hooks/useIsDesktop'; import { type TextSelectionAction, useTextSelectionAction } from '@/hooks/useTextSelectionAction'; +import { useTts } from '@/hooks/useTts'; import type { ChatMessage } from '@/stores/chatStore'; import { useChatStore } from '@/stores/chatStore'; import { useToastStore } from '@/stores/toastStore'; import { apiFetch } from '@/utils/api-client'; import { getUserId } from '@/utils/userId'; import { ConfirmDialog } from './ConfirmDialog'; +import { CopyIdButton } from './CopyIdButton'; import { createQuoteContextAttachment } from './chat-context-reference'; -import { describeMessageInvocationTrajectory, openMessageInvocationTrajectory } from './InvocationTrajectoryAnchor'; import { LiveSelectionAnnotationAction } from './LiveSelectionAnnotationAction'; import { MessageActionSlotProvider } from './MessageActionSlot'; import { SelectionAnnotationAction } from './SelectionAnnotationAction'; import { pushThreadRouteWithHistory } from './ThreadSidebar/thread-navigation'; import { TransferTargetPicker } from './TransferTargetPicker'; -import { TrueRecallActionButton } from './TrueRecallActionButton'; +import { TtsPlayButton } from './TtsPlayButton'; import { useMessageAnnotationMarkers } from './useMessageAnnotationMarkers'; function showErrorToast(title: string, body?: Record) { @@ -35,13 +35,7 @@ function showErrorToast(title: string, body?: Record) { }); } -type DialogState = - | { type: 'none' } - | { type: 'soft-delete' } - | { type: 'hard-delete'; threadTitle: string | null } - | { type: 'edit'; editedContent: string } - | { type: 'branch-confirm'; editedContent: string } - | { type: 'branch-direct' }; +type DialogState = { type: 'none' } | { type: 'soft-delete' } | { type: 'branch-edit'; editedContent: string }; interface MessageActionsProps { message: ChatMessage; @@ -165,7 +159,6 @@ export function MessageActions({ forwardingDisabled = false, }: MessageActionsProps) { const [dialog, setDialog] = useState({ type: 'none' }); - const [overflowOpen, setOverflowOpen] = useState(false); const [compactOpen, setCompactOpen] = useState(false); const [actionSlot, setActionSlot] = useState(null); const [forwardSelection, setForwardSelection] = useState<{ @@ -175,16 +168,21 @@ export function MessageActions({ const selectionAction = useTextSelectionAction(messageRef, !message.isStreaming, message.id, 'viewport'); const annotationMarkers = useMessageAnnotationMarkers(messageRef, threadId, message.id); const removeThreadMessage = useChatStore((s) => s.removeThreadMessage); - const isDesktop = useIsDesktop(); const compactActions = useCompactMessageActions(); + const { state: ttsState, synthesize: ttsSynthesize, activeMessageId } = useTts(); const isUser = message.type === 'user' && !message.catId; const isAssistant = message.type === 'assistant' || (message.type === 'user' && !!message.catId); const isRecalled = Boolean(message.extra?.recall); - const invocationTrajectory = describeMessageInvocationTrajectory(message); const canAct = (isUser || isAssistant) && !message.isStreaming && !isRecalled; // #699: Reply is available on all message types (not just user/assistant) const canReply = !message.isStreaming && !isRecalled; + // A processing lifecycle can exist before its assistant bubble has any body. + // In that state MessageActionSlot has not mounted, so the legacy fallback dock + // would float over the author row. Wait for the real bubble-owned slot instead. + const waitsForResponseBubble = + message.lifecycle?.kind === 'response' && message.lifecycle.status === 'processing' && !actionSlot; + const canShowActions = canReply && !waitsForResponseBubble; const hasSelectionShortcut = canAct && selectionEligible && Boolean(onEnterSelection); /* The author row owns a stable horizontal slot. Painting that slot on message hover cannot * move the message, and toolbar-local focus keeps unrelated body controls from summoning it. @@ -194,50 +192,26 @@ export function MessageActions({ const actionSlotPositionClass = isUser ? 'absolute right-0 top-1/2 -translate-y-1/2' : 'absolute left-0 top-1/2 -translate-y-1/2'; - const actionsExpanded = compactOpen ? true : overflowOpen; - const desktopOverflowOpen = overflowOpen && isDesktop && !compactActions; - const useOverflowSheet = !(isDesktop && !compactActions); - const toolbarVisibilityClass = actionsExpanded + const toolbarVisibilityClass = compactOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100'; useEffect(() => { - if (!overflowOpen && !compactOpen) return; + if (!compactOpen) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; - setOverflowOpen(false); setCompactOpen(false); }; window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); - }, [compactOpen, overflowOpen]); - useEffect(() => { - if (!desktopOverflowOpen) return; - const closeOnOutsidePointer = (event: PointerEvent) => { - if (!messageRef.current?.contains(event.target as Node)) setOverflowOpen(false); - }; - document.addEventListener('pointerdown', closeOnOutsidePointer); - return () => document.removeEventListener('pointerdown', closeOnOutsidePointer); - }, [desktopOverflowOpen]); + }, [compactOpen]); const handleSoftDelete = useCallback(() => setDialog({ type: 'soft-delete' }), []); - const handleHardDelete = useCallback(async () => { - try { - const res = await apiFetch(`/api/threads/${threadId}`, { method: 'GET' }); - const thread = res.ok ? await res.json() : null; - setDialog({ type: 'hard-delete', threadTitle: thread?.title ?? null }); - } catch { - setDialog({ type: 'hard-delete', threadTitle: null }); - } - }, [threadId]); - - const handleEdit = useCallback(() => { - setDialog({ type: 'edit', editedContent: message.content }); + const handleBranch = useCallback(() => { + setDialog({ type: 'branch-edit', editedContent: message.content }); }, [message.content]); - const handleBranchDirect = useCallback(() => setDialog({ type: 'branch-direct' }), []); - const confirmSoftDelete = useCallback(async () => { setDialog({ type: 'none' }); try { @@ -257,34 +231,10 @@ export function MessageActions({ } }, [message.id, threadId, removeThreadMessage]); - const confirmHardDelete = useCallback(async () => { - if (dialog.type !== 'hard-delete') return; - const confirmTitle = dialog.threadTitle ?? '确认删除'; - setDialog({ type: 'none' }); - try { - const res = await apiFetch(`/api/messages/${message.id}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId: getUserId(), mode: 'hard', confirmTitle }), - }); - if (res.ok) { - removeThreadMessage(threadId, message.id); - } else { - const body = await res.json().catch(() => ({})); - showErrorToast('删除失败', body); - } - } catch { - showErrorToast('删除失败'); - } - }, [dialog, message.id, threadId, removeThreadMessage]); - - const handleBranchConfirm = useCallback(() => { - if (dialog.type !== 'edit') return; - setDialog({ type: 'branch-confirm', editedContent: dialog.editedContent }); - }, [dialog]); - + const branchingRef = useRef(false); const confirmBranch = useCallback(async () => { - if (dialog.type !== 'branch-confirm') return; + if (dialog.type !== 'branch-edit' || branchingRef.current) return; + branchingRef.current = true; const { editedContent } = dialog; setDialog({ type: 'none' }); try { @@ -306,33 +256,10 @@ export function MessageActions({ } } catch { showErrorToast('分支创建失败'); - } - }, [dialog, message.id, message.content, threadId]); - - const branchingRef = useRef(false); - const confirmBranchDirect = useCallback(async () => { - if (branchingRef.current) return; - branchingRef.current = true; - setDialog({ type: 'none' }); - try { - const res = await apiFetch(`/api/threads/${threadId}/branch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fromMessageId: message.id, userId: getUserId() }), - }); - if (res.ok) { - const { threadId: newThreadId } = await res.json(); - pushThreadRouteWithHistory(newThreadId, typeof window !== 'undefined' ? window : undefined); - } else { - const body = await res.json().catch(() => ({})); - showErrorToast('分支创建失败', body); - } - } catch { - showErrorToast('分支创建失败'); } finally { branchingRef.current = false; } - }, [message.id, threadId]); + }, [dialog, message.id, message.content, threadId]); const close = useCallback(() => setDialog({ type: 'none' }), []); @@ -391,33 +318,6 @@ export function MessageActions({ [threadId], ); - const overflowMenuItems = ( - <> - - - - ); - const actionToolbar = (compactPresentation: boolean) => (
- {hasSelectionShortcut && ( - - )} -
+
+ {isAssistant && canAct && message.catId && message.content.trim() && ( + + )} + - {invocationTrajectory && ( + {hasSelectionShortcut && ( )} {canAct && ( <> + - {isUser && ( - <> - - - - )} -
- - {overflowOpen && isDesktop && !compactActions && ( -
- {overflowMenuItems} -
- )} -
)}
@@ -655,9 +511,13 @@ export function MessageActions({ /> ))} - {canReply && !selectionMode && !actionSlot && (compactActions ? compactEntry : actionToolbar(false))} - {canReply && !selectionMode && actionSlot && !compactActions && createPortal(actionToolbar(false), actionSlot)} - {canReply && !selectionMode && actionSlot && compactActions && createPortal(compactEntry, actionSlot)} + {canShowActions && !selectionMode && !actionSlot && (compactActions ? compactEntry : actionToolbar(false))} + {canShowActions && + !selectionMode && + actionSlot && + !compactActions && + createPortal(actionToolbar(false), actionSlot)} + {canShowActions && !selectionMode && actionSlot && compactActions && createPortal(compactEntry, actionSlot)} {compactOpen && typeof document !== 'undefined' && @@ -681,29 +541,6 @@ export function MessageActions({ document.body, )} - {overflowOpen && - useOverflowSheet && - typeof document !== 'undefined' && - createPortal( -
-
, - document.body, - )} - {forwardSelection !== null && !forwardingDisabled && ( - {/* Hard delete confirmation — requires title input */} - - - {/* Edit: inline textarea */} - {dialog.type === 'edit' && ( -
-
e.stopPropagation()} - > -

编辑消息

+ {/* One branch action: optionally edit the copied message before creating. */} + {dialog.type === 'branch-edit' && ( +
+